diff --git a/app/assets/stylesheets/card.scss b/app/assets/stylesheets/card.scss index 82b6f7cf1..809d4ce88 100644 --- a/app/assets/stylesheets/card.scss +++ b/app/assets/stylesheets/card.scss @@ -51,6 +51,7 @@ &.no-list { ul { list-style: none !important; + padding-left: 0; } } diff --git a/app/assets/stylesheets/icons.scss b/app/assets/stylesheets/icons.scss index b363a9172..76f02d614 100644 --- a/app/assets/stylesheets/icons.scss +++ b/app/assets/stylesheets/icons.scss @@ -175,6 +175,30 @@ } } + &-file-image-line { + &:before, + &:after { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M15 8V4H5V20H19V8H15ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11 9.5C11 10.3284 10.3284 11 9.5 11C8.67157 11 8 10.3284 8 9.5C8 8.67157 8.67157 8 9.5 8C10.3284 8 11 8.67157 11 9.5ZM17.5 17L13.5 10L8 17H17.5Z'%3E%3C/path%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M15 8V4H5V20H19V8H15ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11 9.5C11 10.3284 10.3284 11 9.5 11C8.67157 11 8 10.3284 8 9.5C8 8.67157 8.67157 8 9.5 8C10.3284 8 11 8.67157 11 9.5ZM17.5 17L13.5 10L8 17H17.5Z'%3E%3C/path%3E%3C/svg%3E"); + } + } + + &-folder-line { + &:before, + &:after { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M4 5V19H20V7H11.5858L9.58579 5H4ZM12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5Z'%3E%3C/path%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M4 5V19H20V7H11.5858L9.58579 5H4ZM12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5Z'%3E%3C/path%3E%3C/svg%3E"); + } + } + + &-folder-zip-line { + &:before, + &:after { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M10.4142 3L12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142ZM18 18H14V15H16V13H14V11H16V9H14V7H11.5858L9.58579 5H4V19H20V7H16V9H18V11H16V13H18V18Z'%3E%3C/path%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M10.4142 3L12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142ZM18 18H14V15H16V13H14V11H16V9H14V7H11.5858L9.58579 5H4V19H20V7H16V9H18V11H16V13H18V18Z'%3E%3C/path%3E%3C/svg%3E"); + } + } + &-intermediate-circle-fill { &:before, &:after { @@ -191,6 +215,14 @@ } } + &-pdf-2-line { + &:before, + &:after { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M5 4H15V8H19V20H5V4ZM3.9985 2C3.44749 2 3 2.44405 3 2.9918V21.0082C3 21.5447 3.44476 22 3.9934 22H20.0066C20.5551 22 21 21.5489 21 20.9925L20.9997 7L16 2H3.9985ZM10.4999 7.5C10.4999 9.07749 10.0442 10.9373 9.27493 12.6534C8.50287 14.3757 7.46143 15.8502 6.37524 16.7191L7.55464 18.3321C10.4821 16.3804 13.7233 15.0421 16.8585 15.49L17.3162 13.5513C14.6435 12.6604 12.4999 9.98994 12.4999 7.5H10.4999ZM11.0999 13.4716C11.3673 12.8752 11.6042 12.2563 11.8037 11.6285C12.2753 12.3531 12.8553 13.0182 13.5101 13.5953C12.5283 13.7711 11.5665 14.0596 10.6352 14.4276C10.7999 14.1143 10.9551 13.7948 11.0999 13.4716Z'%3E%3C/path%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M5 4H15V8H19V20H5V4ZM3.9985 2C3.44749 2 3 2.44405 3 2.9918V21.0082C3 21.5447 3.44476 22 3.9934 22H20.0066C20.5551 22 21 21.5489 21 20.9925L20.9997 7L16 2H3.9985ZM10.4999 7.5C10.4999 9.07749 10.0442 10.9373 9.27493 12.6534C8.50287 14.3757 7.46143 15.8502 6.37524 16.7191L7.55464 18.3321C10.4821 16.3804 13.7233 15.0421 16.8585 15.49L17.3162 13.5513C14.6435 12.6604 12.4999 9.98994 12.4999 7.5H10.4999ZM11.0999 13.4716C11.3673 12.8752 11.6042 12.2563 11.8037 11.6285C12.2753 12.3531 12.8553 13.0182 13.5101 13.5953C12.5283 13.7711 11.5665 14.0596 10.6352 14.4276C10.7999 14.1143 10.9551 13.7948 11.0999 13.4716Z'%3E%3C/path%3E%3C/svg%3E"); + } + } + &-underline { &:before, &:after { 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 fd29214f2..441149066 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 @@ -22,5 +22,5 @@ = 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_export_template_path(procedure_id: params[:procedure_id]), role: 'menuitem' do + = link_to [:new, :instructeur, @procedure, :export_template], role: 'menuitem' do Ajouter un modèle d'export diff --git a/app/controllers/instructeurs/export_templates_controller.rb b/app/controllers/instructeurs/export_templates_controller.rb index 64ef44a4e..65a4c7dc5 100644 --- a/app/controllers/instructeurs/export_templates_controller.rb +++ b/app/controllers/instructeurs/export_templates_controller.rb @@ -1,21 +1,18 @@ module Instructeurs class ExportTemplatesController < InstructeurController - before_action :set_procedure - before_action :set_groupe_instructeur, only: [:create, :update] + before_action :set_procedure_and_groupe_instructeurs before_action :set_export_template, only: [:edit, :update, :destroy] - before_action :set_groupe_instructeurs - before_action :set_all_pj + before_action :ensure_legitimate_groupe_instructeur, only: [:create, :update] def new - @export_template = ExportTemplate.new(kind: 'zip', groupe_instructeur: @groupe_instructeurs.first) - @export_template.set_default_values + @export_template = ExportTemplate.default(groupe_instructeur: @groupe_instructeurs.first) end def create - @export_template = @groupe_instructeur.export_templates.build(export_template_params) - @export_template.assign_pj_names(pj_params) + @export_template = ExportTemplate.new(export_template_params) + if @export_template.save - redirect_to exports_instructeur_procedure_path(procedure: @procedure), notice: "Le modèle d'export #{@export_template.name} a bien été créé" + redirect_to [:exports, :instructeur, @procedure], notice: "Le modèle d'export #{@export_template.name} a bien été créé" else flash[:alert] = @export_template.errors.full_messages render :new @@ -26,11 +23,8 @@ module Instructeurs end def update - @export_template.assign_attributes(export_template_params) - @export_template.groupe_instructeur = @groupe_instructeur - @export_template.assign_pj_names(pj_params) - if @export_template.save - redirect_to exports_instructeur_procedure_path(procedure: @procedure), notice: "Le modèle d'export #{@export_template.name} a bien été modifié" + if @export_template.update(export_template_params) + redirect_to [:exports, :instructeur, @procedure], notice: "Le modèle d'export #{@export_template.name} a bien été modifié" else flash[:alert] = @export_template.errors.full_messages render :edit @@ -39,62 +33,40 @@ module Instructeurs def destroy if @export_template.destroy - redirect_to exports_instructeur_procedure_path(procedure: @procedure), notice: "Le modèle d'export #{@export_template.name} a bien été supprimé" + redirect_to [:exports, :instructeur, @procedure], notice: "Le modèle d'export #{@export_template.name} a bien été supprimé" else - redirect_to exports_instructeur_procedure_path(procedure: @procedure), alert: "Le modèle d'export #{@export_template.name} n'a pu être supprimé" + redirect_to [:exports, :instructeur, @procedure], alert: "Le modèle d'export #{@export_template.name} n'a pu être supprimé" end end def preview - set_groupe_instructeur - @export_template = @groupe_instructeur.export_templates.build(export_template_params) - @export_template.assign_pj_names(pj_params) + export_template = ExportTemplate.new(export_template_params) - @sample_dossier = @procedure.dossier_for_preview(current_instructeur) - - render turbo_stream: turbo_stream.replace('preview', partial: 'preview', locals: { export_template: @export_template, procedure: @procedure, dossier: @sample_dossier }) + render turbo_stream: turbo_stream.replace('preview', partial: 'preview', locals: { export_template: }) end private def export_template_params - params.require(:export_template).permit(*export_params) + params.require(:export_template) + .permit(:name, :kind, :groupe_instructeur_id, dossier_folder: [:enabled, :template], export_pdf: [:enabled, :template], pjs: [:stable_id, :enabled, :template]) end - def set_procedure - @procedure = current_instructeur.procedures.find params[:procedure_id] - Sentry.configure_scope do |scope| - scope.set_tags(procedure: @procedure.id) - end + def set_procedure_and_groupe_instructeurs + @procedure = current_instructeur.procedures.find(params[:procedure_id]) + @groupe_instructeurs = current_instructeur.groupe_instructeurs.where(procedure: @procedure) + + Sentry.configure_scope { |scope| scope.set_tags(procedure: @procedure.id) } end def set_export_template @export_template = current_instructeur.export_templates.find(params[:id]) end - def set_groupe_instructeur - @groupe_instructeur = @procedure.groupe_instructeurs.find(params.require(:export_template)[:groupe_instructeur_id]) - end + def ensure_legitimate_groupe_instructeur + return if export_template_params[:groupe_instructeur_id].in?(@groupe_instructeurs.map { _1.id.to_s }) - def set_groupe_instructeurs - @groupe_instructeurs = current_instructeur.groupe_instructeurs.where(procedure: @procedure) - end - - def set_all_pj - @all_pj ||= @procedure.exportables_pieces_jointes - end - - def export_params - [:name, :kind, :tiptap_default_dossier_directory, :tiptap_pdf_name] - end - - def pj_params - @procedure = current_instructeur.procedures.find params[:procedure_id] - pj_params = [] - @all_pj.each do |pj| - pj_params << "tiptap_pj_#{pj.stable_id}".to_sym - end - params.require(:export_template).permit(*pj_params) + redirect_to [:exports, :instructeur, @procedure], alert: 'Vous n’avez pas le droit de créer un modèle d’export pour ce groupe' end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 856cc923e..177d5196e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -150,4 +150,6 @@ module ApplicationHelper .map { |word| word[0].upcase } .join end + + def asterisk = render(EditableChamp::AsteriskMandatoryComponent.new) end diff --git a/app/javascript/controllers/hide_target_controller.ts b/app/javascript/controllers/hide_target_controller.ts new file mode 100644 index 000000000..607543aac --- /dev/null +++ b/app/javascript/controllers/hide_target_controller.ts @@ -0,0 +1,19 @@ +import { Controller } from '@hotwired/stimulus'; + +export class HideTargetController extends Controller { + static targets = ['source', 'toHide']; + declare readonly toHideTargets: HTMLDivElement[]; + declare readonly sourceTargets: HTMLInputElement[]; + + connect() { + this.sourceTargets.forEach((source) => { + source.addEventListener('click', this.handleInput.bind(this)); + }); + } + + handleInput() { + this.toHideTargets.forEach((toHide) => { + toHide.classList.toggle('fr-hidden'); + }); + } +} diff --git a/app/javascript/controllers/lazy/tiptap_controller.ts b/app/javascript/controllers/lazy/tiptap_controller.ts index 58336e926..eeb2a58bb 100644 --- a/app/javascript/controllers/lazy/tiptap_controller.ts +++ b/app/javascript/controllers/lazy/tiptap_controller.ts @@ -10,7 +10,8 @@ import { createEditor } from '../../shared/tiptap/editor'; export class TiptapController extends ApplicationController { static targets = ['editor', 'input', 'button', 'tag']; static values = { - insertAfterTag: { type: String, default: '' } + insertAfterTag: { type: String, default: '' }, + attributes: { type: Object, default: {} } }; declare editorTarget: Element; @@ -18,6 +19,7 @@ export class TiptapController extends ApplicationController { declare buttonTargets: HTMLButtonElement[]; declare tagTargets: HTMLElement[]; declare insertAfterTagValue: string; + declare attributesValue: Record; #initializing = true; #editor?: Editor; @@ -28,6 +30,7 @@ export class TiptapController extends ApplicationController { content: this.content, tags: this.tags, buttons: this.menuButtons, + attributes: { class: 'fr-input', ...this.attributesValue }, onChange: ({ editor }) => { for (const button of this.buttonTargets) { const action = getAction(editor, button); diff --git a/app/javascript/controllers/tiptap_to_template_controller.ts b/app/javascript/controllers/tiptap_to_template_controller.ts new file mode 100644 index 000000000..90f58294e --- /dev/null +++ b/app/javascript/controllers/tiptap_to_template_controller.ts @@ -0,0 +1,20 @@ +import { Controller } from '@hotwired/stimulus'; + +export class TiptapToTemplateController extends Controller { + static targets = ['output', 'trigger']; + + declare readonly outputTarget: HTMLElement; + declare readonly triggerTarget: HTMLButtonElement; + + connect() { + this.triggerTarget.addEventListener('click', this.handleClick.bind(this)); + } + + handleClick() { + const template = this.element.querySelector('.tiptap.ProseMirror p'); + + if (template) { + this.outputTarget.innerHTML = template.innerHTML; + } + } +} diff --git a/app/javascript/shared/tiptap/editor.ts b/app/javascript/shared/tiptap/editor.ts index 3c12fc792..590aa0701 100644 --- a/app/javascript/shared/tiptap/editor.ts +++ b/app/javascript/shared/tiptap/editor.ts @@ -33,6 +33,7 @@ export function createEditor({ content, tags, buttons, + attributes, onChange }: { editorElement: Element; @@ -40,8 +41,15 @@ export function createEditor({ tags: TagSchema[]; buttons: string[]; onChange(change: { editor: Editor }): void; + attributes?: Record; }): Editor { - const options = getEditorOptions(editorElement, tags, buttons, content); + const options = getEditorOptions( + editorElement, + tags, + buttons, + content, + attributes + ); const editor = new Editor(options); editor.on('transaction', onChange); return editor; @@ -51,7 +59,8 @@ function getEditorOptions( element: Element, tags: TagSchema[], actions: string[], - content?: JSONContent + content?: JSONContent, + attributes?: Record ): Partial { const extensions: Extensions = []; for (const action of actions) { @@ -123,7 +132,7 @@ function getEditorOptions( return { element, content, - editorProps: { attributes: { class: 'fr-input' } }, + editorProps: { attributes }, extensions: [ actions.includes('title') ? DocumentWithHeader : Document, Hystory, diff --git a/app/models/concerns/pieces_jointes_list_concern.rb b/app/models/concerns/pieces_jointes_list_concern.rb index 05ba4b2da..222afa773 100644 --- a/app/models/concerns/pieces_jointes_list_concern.rb +++ b/app/models/concerns/pieces_jointes_list_concern.rb @@ -11,14 +11,26 @@ module PiecesJointesListConcern pieces_jointes(exclude_titre_identite: true) end + def exportables_pieces_jointes_for_all_versions + pieces_jointes( + exclude_titre_identite: true, + revision: revisions + ).sort_by { - _1.id }.uniq(&:stable_id) + end + + def outdated_exportables_pieces_jointes + exportables_pieces_jointes_for_all_versions - exportables_pieces_jointes + end + private def pieces_jointes( exclude_titre_identite: false, public_only: false, - wrap_with_parent: false + wrap_with_parent: false, + revision: active_revision ) - coordinates = active_revision.revision_types_de_champ + coordinates = ProcedureRevisionTypeDeChamp.where(revision:) .includes(:type_de_champ, revision_types_de_champ: :type_de_champ) coordinates = coordinates.public_only if public_only diff --git a/app/models/export_item.rb b/app/models/export_item.rb new file mode 100644 index 000000000..294946753 --- /dev/null +++ b/app/models/export_item.rb @@ -0,0 +1,70 @@ +class ExportItem + include TagsSubstitutionConcern + DOSSIER_STATE = Dossier.states.fetch(:en_construction) + FORMAT_DATE = "%Y-%m-%d".freeze + + attr_reader :template, :enabled, :stable_id + + def initialize(template:, enabled: true, stable_id: nil) + @template, @enabled, @stable_id = template, enabled, stable_id + end + + def self.default(prefix:, enabled: true, stable_id: nil) + new(template: prefix_dossier_id(prefix), enabled:, stable_id:) + end + + def self.default_pj(tdc) + default(prefix: tdc.libelle_as_filename, enabled: false, stable_id: tdc.stable_id) + end + + def enabled? = enabled + + def template_json = template.to_json + + def template_string = TiptapService.new.to_texts_and_tags(template) + + def path(dossier, attachment: nil, row_index: nil, index: nil) + used_tags = TiptapService.used_tags_and_libelle_for(template) + substitutions = tags_substitutions(used_tags, dossier, escape: false, memoize: true) + substitutions['original-filename'] = attachment.filename.base if attachment + + TiptapService.new.to_texts_and_tags(template, substitutions) + suffix(attachment, row_index, index) + end + + def ==(other) + self.class == other.class && + template == other.template && + enabled == other.enabled && + stable_id == other.stable_id + end + + private + + def self.prefix_dossier_id(prefix) + { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { text: "#{prefix}-", type: "text" }, + { type: "mention", attrs: DOSSIER_ID_TAG.slice(:id, :label) } + ] + } + ] + } + end + + def suffix(attachment, row_index, index) + suffix = "" + suffix += "-#{add_one_and_pad(row_index)}" if row_index.present? + suffix += "-#{add_one_and_pad(index)}" if index.present? + suffix += attachment.filename.extension_with_delimiter if attachment + + suffix + end + + def add_one_and_pad(number) + (number + 1).to_s.rjust(2, '0') if number.present? + end +end diff --git a/app/models/export_template.rb b/app/models/export_template.rb index f3ee41235..0f5210a42 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -1,157 +1,66 @@ class ExportTemplate < ApplicationRecord include TagsSubstitutionConcern + self.ignored_columns += ["content"] + belongs_to :groupe_instructeur has_one :procedure, through: :groupe_instructeur has_many :exports, dependent: :nullify + + enum kind: { zip: "zip" }, _prefix: :template + + attribute :dossier_folder, :export_item + attribute :export_pdf, :export_item + attribute :pjs, :export_item, array: true + + before_validation :ensure_pjs_are_legit + validates_with ExportTemplateValidator DOSSIER_STATE = Dossier.states.fetch(:en_construction) - FORMAT_DATE = "%Y-%m-%d" - def set_default_values - content["default_dossier_directory"] = tiptap_json("dossier-") - content["pdf_name"] = tiptap_json("export_") - - content["pjs"] = [] - procedure.exportables_pieces_jointes.each do |pj| - content["pjs"] << { "stable_id" => pj.stable_id.to_s, "path" => tiptap_json("#{pj.libelle.parameterize}-") } - end + # when a pj has been added to a revision, it will not be present in the previous pjs + # a default value is provided. + def pj(tdc) + pjs.find { _1.stable_id == tdc.stable_id } || ExportItem.default_pj(tdc) end - def tiptap_default_dossier_directory=(body) - self.content["default_dossier_directory"] = JSON.parse(body) + def self.default(name: nil, kind: 'zip', groupe_instructeur:) + 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) } + + new(name:, kind:, groupe_instructeur:, dossier_folder:, export_pdf:, pjs:) end - def tiptap_default_dossier_directory - tiptap_content("default_dossier_directory") - end - - def tiptap_pdf_name=(body) - self.content["pdf_name"] = JSON.parse(body) - end - - def tiptap_pdf_name - tiptap_content("pdf_name") - end - - def content_for_pj(pj) - content_for_pj_id(pj.stable_id)&.to_json - end - - def assign_pj_names(pj_params) - self.content["pjs"] = [] - pj_params.each do |pj_param| - self.content["pjs"] << { stable_id: pj_param[0].delete_prefix("tiptap_pj_"), path: JSON.parse(pj_param[1]) } - end - end - - def attachment_and_path(dossier, attachment, index: 0, row_index: nil, champ: nil) - [ - attachment, - path(dossier, attachment, index:, row_index:, champ:) - ] - end - - def tiptap_convert(dossier, param) - if content[param]["content"]&.first&.[]("content") - render_attributes_for(content[param], dossier) - end - end - - def tiptap_convert_pj(dossier, pj_stable_id, attachment = nil) - if content_for_pj_id(pj_stable_id)["content"]&.first&.[]("content") - render_attributes_for(content_for_pj_id(pj_stable_id), dossier, attachment) - end - end - - def render_attributes_for(content_for, dossier, attachment = nil) - used_tags = TiptapService.used_tags_and_libelle_for(content_for.deep_symbolize_keys) - substitutions = tags_substitutions(used_tags, dossier, escape: false, memoize: true) - substitutions['original-filename'] = attachment.filename.base if attachment - TiptapService.new.to_path(content_for.deep_symbolize_keys, substitutions) - end - - def specific_tags + def tags tags_categorized.slice(:individual, :etablissement, :dossier).values.flatten end - def tags_for_pj - specific_tags.push({ + def pj_tags + tags.push( libelle: 'nom original du fichier', - id: 'original-filename', - maybe_null: false - }) + id: 'original-filename' + ) + end + + def attachment_path(dossier, attachment, index: 0, row_index: nil, champ: nil) + file_path = if attachment.name == 'pdf_export_for_instructeur' + export_pdf.path(dossier, attachment:) + elsif attachment.record_type == 'Champ' && pj(champ.type_de_champ).enabled? + pj(champ.type_de_champ).path(dossier, attachment:, index:, row_index:) + else + nil + end + + File.join(dossier_folder.path(dossier), file_path) if file_path.present? end private - def tiptap_content(key) - content[key]&.to_json - end + def ensure_pjs_are_legit + legitimate_pj_stable_ids = procedure.exportables_pieces_jointes_for_all_versions.map(&:stable_id) - def tiptap_json(prefix) - { - "type" => "doc", - "content" => [ - { "type" => "paragraph", "content" => [{ "text" => prefix, "type" => "text" }, { "type" => "mention", "attrs" => DOSSIER_ID_TAG.stringify_keys }] } - ] - } - end - - def content_for_pj_id(stable_id) - content_for_stable_id = content["pjs"].find { _1.symbolize_keys[:stable_id] == stable_id.to_s } - content_for_stable_id.symbolize_keys.fetch(:path) - end - - def folder(dossier) - render_attributes_for(content["default_dossier_directory"], dossier) - end - - def export_path(dossier) - File.join(folder(dossier), export_filename(dossier)) - end - - def export_filename(dossier) - "#{render_attributes_for(content["pdf_name"], dossier)}.pdf" - end - - def path(dossier, attachment, index: 0, row_index: nil, champ: nil) - if attachment.name == 'pdf_export_for_instructeur' - return export_path(dossier) - end - - dir_path = case attachment.record_type - when 'Dossier' - 'dossier' - when 'Commentaire' - 'messagerie' - when 'Avis' - 'avis' - when 'Attestation', 'Etablissement' - 'pieces_justificatives' - else - # for attachment - return attachment_path(dossier, attachment, index, row_index, champ) - end - - File.join(folder(dossier), dir_path, attachment.filename.to_s) - end - - def attachment_path(dossier, attachment, index, row_index, champ) - stable_id = champ.stable_id - tiptap_pj = content["pjs"].find { |pj| pj["stable_id"] == stable_id.to_s } - if tiptap_pj - File.join(folder(dossier), tiptap_convert_pj(dossier, stable_id, attachment) + suffix(attachment, index, row_index)) - else - File.join(folder(dossier), "erreur_renommage", attachment.filename.to_s) - end - end - - def suffix(attachment, index, row_index) - suffix = "-#{index + 1}" - suffix += "-#{row_index + 1}" if row_index.present? - - suffix + attachment.filename.extension_with_delimiter + self.pjs = pjs.filter { _1.stable_id.in?(legitimate_pj_stable_ids) } end end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index bcf24c0f1..4033b0f09 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -676,6 +676,12 @@ class TypeDeChamp < ApplicationRecord end end + def libelle_as_filename + libelle.gsub(/[[:space:]]+/, ' ') + .truncate(30, omission: '', separator: ' ') + .parameterize + end + class << self def champ_value(type_champ, champ) dynamic_type_class = type_champ_to_class_name(type_champ).constantize diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 92d53b088..acdc9b459 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -5,28 +5,26 @@ class PiecesJustificativesService end def liste_documents(dossiers) - bill_ids = [] - docs = pjs_for_champs(dossiers) + pjs_for_commentaires(dossiers) + pjs_for_dossier(dossiers) + pjs_for_avis(dossiers) - if liste_documents_allows?(:with_bills) + # we do not export bills no more with the new export system + # the bills have never been properly understood by the users + # their export is now deprecated + if liste_documents_allows?(:with_bills) && @export_template.nil? # some bills are shared among operations # so first, all the bill_ids are fetched operation_logs, some_bill_ids = operation_logs_and_signature_ids(dossiers) docs += operation_logs - bill_ids += some_bill_ids - end - if liste_documents_allows?(:with_bills) # then the bills are retrieved without duplication - docs += signatures(bill_ids.uniq) + docs += signatures(some_bill_ids.uniq) end - docs + docs.filter { |_attachment, path| path.present? } end def generate_dossiers_export(dossiers) # TODO: renommer generate_dossier_export sans s @@ -46,14 +44,14 @@ class PiecesJustificativesService }) a = ActiveStorage::FakeAttachment.new( file: StringIO.new(pdf), - filename: "export-#{dossier.id}.pdf", + filename: ActiveStorage::Filename.new("export-#{dossier.id}.pdf"), name: 'pdf_export_for_instructeur', id: dossier.id, created_at: dossier.updated_at ) if @export_template - pdfs << @export_template.attachment_and_path(dossier, a) + pdfs << [a, @export_template.attachment_path(dossier, a)] else pdfs << ActiveStorage::DownloadableFile.pj_and_path(dossier.id, a) end @@ -148,7 +146,7 @@ class PiecesJustificativesService row_index = champs_id_row_index[champ.id] if @export_template - @export_template.attachment_and_path(champ.dossier, attachment, index:, row_index:, champ:) + [attachment, @export_template.attachment_path(champ.dossier, attachment, index:, row_index:, champ:)] else ActiveStorage::DownloadableFile.pj_and_path(champ.dossier_id, attachment) end @@ -171,7 +169,7 @@ class PiecesJustificativesService dossier_id = commentaire_id_dossier_id[a.record_id] if @export_template dossier = dossiers.find { _1.id == dossier_id } - @export_template.attachment_and_path(dossier, a) + [a, @export_template.attachment_path(dossier, a)] else ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) end @@ -197,7 +195,7 @@ class PiecesJustificativesService dossier_id = etablissement_id_dossier_id[a.record_id] if @export_template dossier = dossiers.find { _1.id == dossier_id } - @export_template.attachment_and_path(dossier, a) + [a, @export_template.attachment_path(dossier, a)] else ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) end @@ -213,7 +211,7 @@ class PiecesJustificativesService dossier_id = a.record_id if @export_template dossier = dossiers.find { _1.id == dossier_id } - @export_template.attachment_and_path(dossier, a) + [a, @export_template.attachment_path(dossier, a)] else ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) end @@ -234,7 +232,7 @@ class PiecesJustificativesService dossier_id = attestation_id_dossier_id[a.record_id] if @export_template dossier = dossiers.find { _1.id == dossier_id } - @export_template.attachment_and_path(dossier, a) + [a, @export_template.attachment_path(dossier, a)] else ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) end @@ -263,7 +261,7 @@ class PiecesJustificativesService dossier_id = avis_ids_dossier_id[a.record_id] if @export_template dossier = dossiers.find { _1.id == dossier_id } - @export_template.attachment_and_path(dossier, a) + [a, @export_template.attachment_path(dossier, a)] else ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) end diff --git a/app/services/tiptap_service.rb b/app/services/tiptap_service.rb index 3bbee3494..3b6e61d19 100644 --- a/app/services/tiptap_service.rb +++ b/app/services/tiptap_service.rb @@ -19,10 +19,10 @@ class TiptapService children(node[:content], substitutions, 0) end - def to_path(node, substitutions = {}) + def to_texts_and_tags(node, substitutions = {}) return '' if node.nil? - children_path(node[:content], substitutions) + children_texts_and_tags(node[:content], substitutions) end private @@ -31,18 +31,24 @@ class TiptapService @body_started = false end - def children_path(content, substitutions) - content.map { node_to_path(_1, substitutions) }.join + def children_texts_and_tags(content, substitutions) + content.map { node_to_texts_and_tags(_1, substitutions) }.join end - def node_to_path(node, substitutions) + def node_to_texts_and_tags(node, substitutions) case node in type: 'paragraph', content: - children_path(content, substitutions) - in type: 'text', text:, **rest + children_texts_and_tags(content, substitutions) + in type: 'paragraph' # empty paragraph + '' + in type: 'text', text: text.strip - in type: 'mention', attrs: { id: }, **rest - substitutions.fetch(id) { "--#{id}--" } + in type: 'mention', attrs: { id:, label: } + if substitutions.present? + substitutions.fetch(id) { "--#{id}--" } + else + "#{label}" + end end end diff --git a/app/types/export_item_type.rb b/app/types/export_item_type.rb new file mode 100644 index 000000000..182941eef --- /dev/null +++ b/app/types/export_item_type.rb @@ -0,0 +1,40 @@ +class ExportItemType < 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 ExportItem + value + in NilClass # default value + nil + # from db + in { template: Hash, enabled: TrueClass | FalseClass } => h + + ExportItem.new(**h.slice(:template, :enabled, :stable_id)) + # from form + in { template: String } => h + + template = JSON.parse(h[:template]).deep_symbolize_keys + enabled = h[:enabled] == 'true' + stable_id = h[:stable_id]&.to_i + ExportItem.new(template:, enabled:, stable_id:) + end + end + + # db -> ruby + def deserialize(value) = cast(value&.then { JSON.parse(_1) }) + + # ruby -> db + def serialize(value) + if value.is_a?(ExportItem) + JSON.generate({ + template: value.template, + enabled: value.enabled, + stable_id: value.stable_id + }.compact) + else + raise ArgumentError, "Invalid value for ExportItem serialization: #{value}" + end + end +end diff --git a/app/validators/export_template_validator.rb b/app/validators/export_template_validator.rb index 2a1158ed6..6d291ea3d 100644 --- a/app/validators/export_template_validator.rb +++ b/app/validators/export_template_validator.rb @@ -1,54 +1,66 @@ class ExportTemplateValidator < ActiveModel::Validator - def validate(record) - validate_default_dossier_directory(record) - validate_pdf_name(record) - validate_pjs(record) + def validate(export_template) + validate_all_templates(export_template) + + return if export_template.errors.any? # no need to continue if the templates are invalid + + validate_dossier_folder(export_template) + validate_export_pdf(export_template) + validate_pjs(export_template) + + validate_different_templates(export_template) end private - def validate_default_dossier_directory(record) - mention = attribute_content_mention(record, :default_dossier_directory) - if mention&.fetch("id", nil) != "dossier_number" - record.errors.add :tiptap_default_dossier_directory, :dossier_number_mandatory + def validate_all_templates(export_template) + [export_template.dossier_folder, export_template.export_pdf, *export_template.pjs].each(&:template_string) + + rescue StandardError + export_template.errors.add(:base, :invalid_template) + end + + def validate_dossier_folder(export_template) + if !mentions(export_template.dossier_folder.template).include?('dossier_number') + export_template.errors.add(:dossier_folder, :dossier_number_required) end end - def validate_pdf_name(record) - if attribute_content_text(record, :pdf_name).blank? && attribute_content_mention(record, :pdf_name).blank? - record.errors.add :tiptap_pdf_name, :blank + def mentions(template) + TiptapService.used_tags_and_libelle_for(template).map(&:first) + end + + def validate_export_pdf(export_template) + return if !export_template.export_pdf.enabled? + + if export_template.export_pdf.template_string.empty? + export_template.errors.add(:export_pdf, :blank) end end - def attribute_content_text(record, attribute) - attribute_content(record, attribute)&.find { |elem| elem["type"] == "text" }&.fetch("text", nil) - end + def validate_pjs(export_template) + libelle_by_stable_ids = pj_libelle_by_stable_id(export_template) - def attribute_content_mention(record, attribute) - attribute_content(record, attribute)&.find { |elem| elem["type"] == "mention" }&.fetch("attrs", nil) - end - - def attribute_content(record, attribute) - content = record.content[attribute.to_s]&.fetch("content", nil) - if content.is_a?(Array) - content.first&.fetch("content", nil) + export_template.pjs.filter(&:enabled?).each do |pj| + if pj.template_string.empty? + libelle = libelle_by_stable_ids[pj.stable_id] + export_template.errors.add(libelle, I18n.t(:blank, scope: 'errors.messages')) + end end end - def validate_pjs(record) - record.content["pjs"]&.each do |pj| - pj_sym = pj.symbolize_keys - libelle = record.groupe_instructeur.procedure.exportables_pieces_jointes.find { _1.stable_id.to_s == pj_sym[:stable_id] }&.libelle&.to_sym - validate_content(record, pj_sym[:path], libelle) - end + def validate_different_templates(export_template) + templates = [export_template.export_pdf, *export_template.pjs] + .filter(&:enabled?) + .map(&:template_string) + + return if templates.uniq.size == templates.size + + export_template.errors.add(:base, :different_templates) end - def validate_content(record, attribute_content, attribute) - if attribute_content.nil? || attribute_content["content"].nil? || - attribute_content["content"].first.nil? || - attribute_content["content"].first["content"].nil? || - (attribute_content["content"].first["content"].find { |elem| elem["text"].blank? } && attribute_content["content"].first["content"].find { |elem| elem["type"] == "mention" }["attrs"].blank?) - record.errors.add attribute, I18n.t(:blank, scope: 'errors.messages') - end + def pj_libelle_by_stable_id(export_template) + export_template.procedure.exportables_pieces_jointes + .pluck(:stable_id, :libelle).to_h end end diff --git a/app/views/instructeurs/export_templates/_export_item.html.haml b/app/views/instructeurs/export_templates/_export_item.html.haml new file mode 100644 index 000000000..6ab854adf --- /dev/null +++ b/app/views/instructeurs/export_templates/_export_item.html.haml @@ -0,0 +1,26 @@ +.card.no-list + = hidden_field_tag("#{prefix}[stable_id]", item.stable_id) + + .fr-checkbox-group{ data: { controller: 'hide-target' } } + - id = sanitize_to_id("#{prefix}_#{item.stable_id}_enabled") + = check_box_tag "#{prefix}[enabled]", true, item.enabled?, id:, data: { 'hide-target_target': 'source' } + = label_tag id, libelle, class: 'fr-label' + + %div{ class: class_names('fr-hidden': !item.enabled?), data: { hide_target_target: 'toHide' } } + %div{ data: { controller: 'hide-target tiptap-to-template'} } + .fr-mt-2w{ data: { hide_target_target: 'toHide' } } + %span Nom du fichier : + %span{ data: { 'tiptap-to-template_target': 'output'} }= sanitize(item.template_string) + .fr-mt-2w + %button.fr-btn.fr-btn--tertiary.fr-btn--sm{ type: 'button', data: { 'hide-target_target': 'source' } } Renommer le fichier + + .fr-mt-2w.fr-hidden{ data: { controller: 'tiptap', 'tiptap-attributes-value': { spellcheck: false }.to_json, hide_target_target: 'toHide' } } + %span Renommer le fichier : + .fr-mt-2w.tiptap-editor{ data: { tiptap_target: 'editor' } } + = hidden_field_tag "#{prefix}[template]", item.template_json, data: { tiptap_target: 'input' }, id: nil + + .fr-mt-2w + %span.fr-text--sm Cliquez sur les étiquettes que vous souhaitez intégrer au nom du fichier + .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.pj_tags }) + + = button_tag "Valider", type: 'button', class: 'fr-btn fr-mt-2w', data: { 'tiptap-to-template_target': 'trigger', 'hide-target_target': 'source'} diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index 00813d9b6..3f022902b 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -1,3 +1,5 @@ +- procedure = @export_template.procedure + #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| @@ -7,72 +9,82 @@ Essayez-le et donnez-nous votre avis en nous envoyant un email à #{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 url: form_url, model: @export_template, local: true, data: { turbo: 'true', controller: 'autosubmit' } do |f| + .fr-grid-row.fr-grid-row--gutters + .fr-col-12.fr-col-md-8.fr-pr-4w + = form_with model: [:instructeur, procedure, export_template], data: { turbo: 'true', controller: 'autosubmit' } do |f| + %input.hidden{ type: 'submit', formaction: preview_instructeur_procedure_export_templates_path, data: { autosubmit_target: 'submitter' }, formnovalidate: 'true', formmethod: 'get' } - = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field) + = f.hidden_field :kind, value: 'zip' - - if groupe_instructeurs.many? - .fr-input-group + = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field) + + .fr-input-group{ class: class_names('fr-hidden': groupe_instructeurs.one?) } = 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 ? + = "#{ExportTemplate.human_attribute_name('groupe_instructeur_id')} #{asterisk}" + %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 - = f.hidden_field :kind + .fr-input-group{ data: { controller: 'tiptap', 'tiptap-attributes-value': { spellcheck: false }.to_json } } + = f.label '[dossier_folder][template]', class: "fr-label" do + = "#{ExportTemplate.human_attribute_name('dossier_folder')} #{asterisk}" + %span.fr-hint-text Nom du répertoire contenant les différents fichiers à exporter + .tiptap-editor.fr-mt-1w{ data: { tiptap_target: 'editor' } } + = f.hidden_field "[dossier_folder][template]", data: { tiptap_target: 'input' }, value: export_template.dossier_folder.template_json + = f.hidden_field "[dossier_folder][enabled]", value: 'true' + .fr-mt-2w + %span.fr-text--sm Cliquez sur les étiquettes que vous souhaitez intégrer au nom du fichier + .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => export_template.tags }) - .fr-input-group{ data: { controller: 'tiptap' } } - = f.label :tiptap_default_dossier_directory, class: "fr-label" do - = f.object.class.human_attribute_name(:tiptap_default_dossier_directory) - = render EditableChamp::AsteriskMandatoryComponent.new - %span.fr-hint-text - = t('activerecord.attributes.export_template.hints.tiptap_default_dossier_directory') + = render Dsfr::NoticeComponent.new(data_attributes: { class: 'fr-my-4w' }) do |c| + - c.with_title do + Sélectionnez les fichiers que vous souhaitez exporter - .tiptap-editor.fr-mt-1w{ data: { tiptap_target: 'editor' } } - = f.hidden_field :tiptap_default_dossier_directory, data: { tiptap_target: 'input' } - .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.specific_tags }) + %h3 Dossier au format PDF + = render partial: 'export_item', + locals: { item: export_template.export_pdf, + libelle: ExportTemplate.human_attribute_name(:export_pdf), + prefix: 'export_template[export_pdf]' } - .fr-input-group{ data: { controller: 'tiptap' } } - = f.label :tiptap_pdf_name, class: "fr-label" do - = f.object.class.human_attribute_name(:tiptap_pdf_name) - = render EditableChamp::AsteriskMandatoryComponent.new - %span.fr-hint-text - = t('activerecord.attributes.export_template.hints.tiptap_pdf_name') - .tiptap-editor.fr-mt-1w{ data: { tiptap_target: 'editor' } } - = f.hidden_field :tiptap_pdf_name, data: { tiptap_target: 'input' } - .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.specific_tags }) + - if procedure.exportables_pieces_jointes_for_all_versions.any? + %h3 Pièces justificatives - - if @all_pj.any? - %h3 Pieces justificatives + - procedure.exportables_pieces_jointes.each do |tdc| + - item = export_template.pj(tdc) + = render partial: 'export_item', + locals: { item:, + libelle: tdc.libelle, + prefix: 'export_template[pjs][]'} - .fr-highlight - %p.fr-text--sm - N'incluez pas les extensions de fichier (.pdf, .jpg, …) dans les noms de pièces jointes. + - outdated_tdcs = procedure.outdated_exportables_pieces_jointes + - outdated_stable_ids = outdated_tdcs.map(&:stable_id) + - expanded = export_template.pjs.filter(&:enabled?).any? { _1.stable_id.in?(outdated_stable_ids) } - - @all_pj.each do |pj| - .fr-input-group{ data: { controller: 'tiptap' } } - = label_tag pj.libelle, nil, name: field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), class: "fr-label" - .tiptap-editor.fr-mt-1w{ data: { tiptap_target: 'editor' } } - = hidden_field_tag field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), "#{@export_template.content_for_pj(pj)}" , data: { tiptap_target: 'input' } - .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.tags_for_pj }) + - if outdated_tdcs.any? + %section.fr-accordion.fr-mb-3w + %h3.fr-accordion__title + %button.fr-accordion__btn{ "aria-controls" => "accordion-106", "aria-expanded" => expanded.to_s, "type" => "button" } + pièces justificatives uniquement présentes dans les versions précédentes + .fr-collapse#accordion-106 + + - outdated_tdcs.each do |tdc| + - item = export_template.pj(tdc) + = render partial: 'export_item', + locals: { item:, + libelle: tdc.libelle, + prefix: 'export_template[pjs][]'} + + .fixed-footer + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md + %li= f.button "Enregistrer", class: "fr-btn", data: { turbo: 'false' } + %li= link_to "Annuler", [:exports, :instructeur, 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" - .fixed-footer - .fr-container - %ul.fr-btns-group.fr-btns-group--inline-md - %li - %input.hidden{ type: 'submit', formaction: preview_instructeur_export_templates_path, data: { autosubmit_target: 'submitter' }, formnovalidate: 'true', formmethod: 'get' } - = f.button "Enregistrer", class: "fr-btn", data: { turbo: 'false' } - %li - = link_to "Annuler", instructeur_procedure_path(@procedure), class: "fr-btn fr-btn--secondary" - - if @export_template.persisted? - %li - = link_to "Supprimer", instructeur_export_template_path(@export_template, procedure_id: @procedure.id), 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" - - sample_dossier = @procedure.dossier_for_preview(current_instructeur) - - if sample_dossier .fr-col-12.fr-col-md-4.fr-background-alt--blue-france - = render partial: 'preview', locals: { dossier: sample_dossier, export_template: @export_template, procedure: @procedure } + = render partial: 'preview', locals: { export_template: } diff --git a/app/views/instructeurs/export_templates/_preview.html.haml b/app/views/instructeurs/export_templates/_preview.html.haml index 124abc2c1..0df0548e0 100644 --- a/app/views/instructeurs/export_templates/_preview.html.haml +++ b/app/views/instructeurs/export_templates/_preview.html.haml @@ -1,17 +1,33 @@ +- procedure = export_template.procedure +- dossier = procedure.dossier_for_preview(current_instructeur) + #preview.export-template-preview.fr-p-2w.sticky--top %h2.fr-h4 Aperçu - %ul.tree.fr-text--sm - %li #{DownloadableFileService::EXPORT_DIRNAME}/ - %li - %ul - %li - %span#preview_default_dossier_directory #{export_template.tiptap_convert(dossier, "default_dossier_directory")}/ - %ul - %li#preview_pdf_name #{export_template.tiptap_convert(dossier, "pdf_name")}.pdf - - procedure.exportables_pieces_jointes.each do |pj| - %li{ id: "preview_pj_#{pj.stable_id}" } #{export_template.tiptap_convert_pj(dossier, pj.stable_id)}-1.jpg - %ul - %li - %span messagerie/ - %ul - %li un-autre-fichier.png + - if dossier.nil? + %p.fr-text--sm + Pour générer un aperçu fidèle avec tous les champs et les dates, + = link_to 'créez-vous un dossier', commencer_url(procedure.path), target: '_blank' + et acceptez-le : l’aperçu l’utilisera. + + - else + %ul.tree.fr-text--sm + %li + %span.fr-icon-folder-zip-line + #{DownloadableFileService::EXPORT_DIRNAME}/ + %li + %ul + %li + %span.fr-icon-folder-line + #{export_template.dossier_folder.path(dossier)}/ + %ul + - if export_template.export_pdf.enabled? + %li + %span.fr-icon-pdf-2-line + #{export_template.export_pdf.path(dossier)}.pdf + + - procedure.exportables_pieces_jointes.each do |tdc| + - export_pj = export_template.pj(tdc) + - if export_pj.enabled? + %li + %span.fr-icon-file-image-line + #{export_pj.path(dossier)}-1.jpg diff --git a/app/views/instructeurs/export_templates/edit.html.haml b/app/views/instructeurs/export_templates/edit.html.haml index bd4fc02b4..ab4c5f8c7 100644 --- a/app/views/instructeurs/export_templates/edit.html.haml +++ b/app/views/instructeurs/export_templates/edit.html.haml @@ -4,4 +4,4 @@ .fr-container %h1 Mise à jour modèle d'export - = render partial: 'form', locals: { form_url: instructeur_export_template_path(@procedure, @export_template), groupe_instructeurs: @groupe_instructeurs } + = 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 eeff6baa9..10962b1cd 100644 --- a/app/views/instructeurs/export_templates/new.html.haml +++ b/app/views/instructeurs/export_templates/new.html.haml @@ -3,4 +3,4 @@ [t('.title')]] } .fr-container %h1 Nouveau modèle d'export - = render partial: 'form', locals: { form_url: instructeur_export_templates_path, groupe_instructeurs: @groupe_instructeurs } + = 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 793a7d960..e63eb4472 100644 --- a/app/views/instructeurs/procedures/exports.html.haml +++ b/app/views/instructeurs/procedures/exports.html.haml @@ -38,9 +38,9 @@ %tbody - @export_templates.each do |export_template| %tr - %td= link_to export_template.name, edit_instructeur_export_template_path(export_template, procedure_id: @procedure.id) + %td= link_to export_template.name, [:edit, :instructeur, @procedure, export_template] %td= export_template.groupe_instructeur.label if @procedure.groupe_instructeurs.many? %p - = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line' do + = 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 diff --git a/config/initializers/types.rb b/config/initializers/types.rb new file mode 100644 index 000000000..edf12e317 --- /dev/null +++ b/config/initializers/types.rb @@ -0,0 +1,5 @@ +require Rails.root.join("app/types/export_item_type") + +ActiveSupport.on_load(:active_record) do + ActiveRecord::Type.register(:export_item, ExportItemType) +end diff --git a/config/locales/models/export_templates/en.yml b/config/locales/models/export_templates/en.yml index e6cd08856..1952e0bc0 100644 --- a/config/locales/models/export_templates/en.yml +++ b/config/locales/models/export_templates/en.yml @@ -6,12 +6,14 @@ en: export_template: hints: name: "The name will be visible by you and the other instructors" - tiptap_default_dossier_directory: "How would you like to name the directory containing the documents of a folder?" - tiptap_pdf_name: "How would you like to name the pdf file containing all the user's answers?" + dossier_folder: "How would you like to name the directory containing the documents of a folder?" + export_pdf: "How would you like to name the pdf file containing all the user's answers?" name: "Template's name" - tiptap_default_dossier_directory: "Directory's name for pdf format" - tiptap_pdf_name: "Export's filename" + dossier_folder: "Directory's name for pdf format" + export_pdf: "File in pdf format" errors: models: export_template: - dossier_number_mandatory: "must contain dossier's number" + dossier_number_required: "must contain dossier's number" + different_templates: "Files must have different names" + invalid_template: "A file name is invalid" diff --git a/config/locales/models/export_templates/fr.yml b/config/locales/models/export_templates/fr.yml index 60852aee0..9d152bbc5 100644 --- a/config/locales/models/export_templates/fr.yml +++ b/config/locales/models/export_templates/fr.yml @@ -6,12 +6,14 @@ fr: export_template: hints: name: "Le nom sera visible par vous et les autres instructeurs pour générer un export" - tiptap_default_dossier_directory: "Comment souhaitez-vous nommer le répertoire contenant les documents d'un dossier ?" - tiptap_pdf_name: "Comment souhaitez-vous nommer le fichier pdf qui contient toutes les réponses de l'usager ?" + dossier_folder: "Comment souhaitez-vous nommer le répertoire contenant les documents d'un dossier ?" + export_pdf: "Comment souhaitez-vous nommer le fichier pdf qui contient toutes les réponses de l'usager ?" name: "Nom du modèle" - tiptap_default_dossier_directory: Nom du répertoire - tiptap_pdf_name: "Nom du dossier au format pdf" + dossier_folder: Nom du répertoire + export_pdf: "Dossier au format pdf" errors: models: export_template: - dossier_number_mandatory: doit contenir le numéro du dossier + 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 diff --git a/config/routes.rb b/config/routes.rb index 3a8e72fba..9c2239267 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -443,14 +443,17 @@ Rails.application.routes.draw do # scope module: 'instructeurs', as: 'instructeur' do + resources :procedures, only: [] do + resources :export_templates, only: [:new, :create, :edit, :update, :destroy] do + collection do + get 'preview' + end + end + end + resources :procedures, only: [:index, :show], param: :procedure_id do member do resources :archives, only: [:index, :create] - resources :export_templates, only: [:new, :create, :edit, :update, :destroy] do - collection do - get 'preview' - end - end resources :groupes, only: [:index, :show], controller: 'groupe_instructeurs' do resource :contact_information diff --git a/db/migrate/20240713090744_add_dossier_folder_column_to_export_template.rb b/db/migrate/20240713090744_add_dossier_folder_column_to_export_template.rb new file mode 100644 index 000000000..69a31e4b6 --- /dev/null +++ b/db/migrate/20240713090744_add_dossier_folder_column_to_export_template.rb @@ -0,0 +1,15 @@ +class AddDossierFolderColumnToExportTemplate < ActiveRecord::Migration[7.0] + def up + execute "DELETE FROM export_templates;" + + add_column :export_templates, :dossier_folder, :jsonb, default: nil, null: false + add_column :export_templates, :export_pdf, :jsonb, default: nil, null: false + add_column :export_templates, :pjs, :jsonb, array: true, default: [], null: false + end + + def down + remove_column :export_templates, :dossier_folder + remove_column :export_templates, :export_pdf + remove_column :export_templates, :pjs + end +end diff --git a/db/schema.rb b/db/schema.rb index 9239db004..0544b546c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -599,9 +599,12 @@ ActiveRecord::Schema[7.0].define(version: 2024_07_16_091043) do create_table "export_templates", force: :cascade do |t| t.jsonb "content", default: {} t.datetime "created_at", null: false + t.jsonb "dossier_folder", null: false + t.jsonb "export_pdf", null: false t.bigint "groupe_instructeur_id", null: false t.string "kind", null: false t.string "name", null: false + t.jsonb "pjs", default: [], null: false, array: true t.datetime "updated_at", null: false t.index ["groupe_instructeur_id"], name: "index_export_templates_on_groupe_instructeur_id" end diff --git a/spec/controllers/instructeurs/export_templates_controller_spec.rb b/spec/controllers/instructeurs/export_templates_controller_spec.rb index 36c9f665e..df19aa1b4 100644 --- a/spec/controllers/instructeurs/export_templates_controller_spec.rb +++ b/spec/controllers/instructeurs/export_templates_controller_spec.rb @@ -1,58 +1,31 @@ describe Instructeurs::ExportTemplatesController, type: :controller do before { sign_in(instructeur.user) } - let(:tiptap_pdf_name) { - { - "type" => "doc", - "content" => [ - { "type" => "paragraph", "content" => [{ "text" => "mon_export_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] } - ] - }.to_json - } - - let(:export_template_params) do - { - name: "coucou", - kind: "zip", - groupe_instructeur_id: groupe_instructeur.id, - tiptap_pdf_name: tiptap_pdf_name, - tiptap_default_dossier_directory: { - "type" => "doc", - "content" => [ - { "type" => "paragraph", "content" => [{ "text" => "DOSSIER_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] } - ] - }.to_json, - tiptap_pj_3: { - "type" => "doc", - "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] - }.to_json, - tiptap_pj_5: { - - "type" => "doc", - "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] - }.to_json, - tiptap_pj_10: { - - "type" => "doc", - "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] - }.to_json - } - end let(:instructeur) { create(:instructeur) } let(:procedure) do create( :procedure, instructeurs: [instructeur], - types_de_champ_public: [ - { type: :piece_justificative, libelle: "pj1", stable_id: 3 }, - { type: :piece_justificative, libelle: "pj2", stable_id: 5 }, - { type: :piece_justificative, libelle: "pj3", stable_id: 10 } - ] + types_de_champ_public: [{ type: :piece_justificative, libelle: "pj1", stable_id: 3 }] ) end let(:groupe_instructeur) { procedure.defaut_groupe_instructeur } + let(:groupe_instructeur_id) { groupe_instructeur.id } + + let(:export_template_params) do + { + name: "coucou", + kind: "zip", + groupe_instructeur_id:, + export_pdf:, + dossier_folder: item_params(text: "DOSSIER_"), + pjs: [pj_item_params(stable_id: 3, text: "avis-commission-"), pj_item_params(stable_id: 666, text: "evil-hack")] + } + end + + let(:export_pdf) { item_params(text: "mon_export_") } describe '#new' do - let(:subject) { get :new, params: { procedure_id: procedure.id } } + subject { get :new, params: { procedure_id: procedure.id } } it do subject @@ -61,18 +34,22 @@ describe Instructeurs::ExportTemplatesController, type: :controller do end describe '#create' do - let(:subject) { post :create, params: { procedure_id: procedure.id, export_template: export_template_params } } + let(:create_params) { export_template_params } + subject { post :create, params: { procedure_id: procedure.id, export_template: create_params } } context 'with valid params' do it 'redirect to some page' do subject - expect(response).to redirect_to(exports_instructeur_procedure_path(procedure:)) + expect(response).to redirect_to(exports_instructeur_procedure_path(procedure)) expect(flash.notice).to eq "Le modèle d'export coucou a bien été créé" end end context 'with invalid params' do - let(:tiptap_pdf_name) { { content: "invalid" }.to_json } + let(:export_pdf) do + item_params(text: 'toto').merge("template" => { "content" => [{ "content" => "invalid" }] }.to_json) + end + it 'display error notification' do subject expect(flash.alert).to be_present @@ -81,16 +58,37 @@ describe Instructeurs::ExportTemplatesController, type: :controller do context 'with procedure not accessible by current instructeur' do let(:another_procedure) { create(:procedure) } - let(:subject) { post :create, params: { procedure_id: another_procedure.id, export_template: export_template_params } } + subject { post :create, params: { procedure_id: another_procedure.id, export_template: export_template_params } } + it 'raise exception' do expect { subject }.to raise_error(ActiveRecord::RecordNotFound) end end + + context 'with invalid groupe_instructeur_id' do + let(:groupe_instructeur_id) { create(:groupe_instructeur).id } + + it 'display error notification' do + expect { subject }.not_to change(ExportTemplate, :count) + expect(flash.alert).to be_present + end + end + + context 'without pjs' do + let(:create_params) { export_template_params.tap { _1.delete(:pjs) } } + + it 'works' do + subject + + expect(flash.notice).to eq "Le modèle d'export coucou a bien été créé" + expect(ExportTemplate.last.pjs).to match_array([]) + end + end end describe '#edit' do let(:export_template) { create(:export_template, groupe_instructeur:) } - let(:subject) { get :edit, params: { procedure_id: procedure.id, id: export_template.id } } + subject { get :edit, params: { procedure_id: procedure.id, id: export_template.id } } it 'render edit' do subject @@ -109,42 +107,53 @@ describe Instructeurs::ExportTemplatesController, type: :controller do describe '#update' do let(:export_template) { create(:export_template, groupe_instructeur:) } - let(:tiptap_pdf_name) { - { - "type" => "doc", - "content" => [ - { "type" => "paragraph", "content" => [{ "text" => "exPort_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] } - ] - }.to_json - } + let(:export_pdf) { item_params(text: "exPort_") } - let(:subject) { put :update, params: { procedure_id: procedure.id, id: export_template.id, export_template: export_template_params } } + subject { put :update, params: { procedure_id: procedure.id, id: export_template.id, export_template: export_template_params } } context 'with valid params' do it 'redirect to some page' do subject - expect(response).to redirect_to(exports_instructeur_procedure_path(procedure:)) + expect(response).to redirect_to(exports_instructeur_procedure_path(procedure)) expect(flash.notice).to eq "Le modèle d'export coucou a bien été modifié" + + export_template.reload + + expect(export_template.export_pdf.template_json).to eq(item_params(text: "exPort_")["template"]) + expect(export_template.pjs.map(&:template_json)).to eq([item_params(text: "avis-commission-")["template"]]) end end context 'with invalid params' do - let(:tiptap_pdf_name) { { content: "invalid" }.to_json } + let(:export_pdf) do + item_params(text: 'a').merge("template" => { "content" => [{ "content" => "invalid" }] }.to_json) + end + it 'display error notification' do subject expect(flash.alert).to be_present end end + + context 'with invalid groupe_instructeur_id' do + let(:groupe_instructeur_id) { create(:groupe_instructeur).id } + + it 'display error notification' do + subject + expect(export_template.export_pdf.template_json).not_to eq(item_params(text: "exPort_")["template"]) + expect(flash.alert).to be_present + end + end end describe '#destroy' do let(:export_template) { create(:export_template, groupe_instructeur:) } - let(:subject) { delete :destroy, params: { procedure_id: procedure.id, id: export_template.id } } + subject { delete :destroy, params: { procedure_id: procedure.id, id: export_template.id } } context 'with valid params' do it 'redirect to some page' do subject - expect(response).to redirect_to(exports_instructeur_procedure_path(procedure:)) + expect(response).to redirect_to(exports_instructeur_procedure_path(procedure)) expect(flash.notice).to eq "Le modèle d'export Mon export a bien été supprimé" end end @@ -155,7 +164,7 @@ describe Instructeurs::ExportTemplatesController, type: :controller do let(:export_template) { create(:export_template, groupe_instructeur:) } - let(:subject) { get :preview, params: { procedure_id: procedure.id, id: export_template.id, export_template: export_template_params }, format: :turbo_stream } + subject { get :preview, params: { procedure_id: procedure.id, id: export_template.id, export_template: export_template_params }, format: :turbo_stream } it '' do dossier = create(:dossier, procedure: procedure, for_procedure_preview: true) @@ -164,4 +173,30 @@ describe Instructeurs::ExportTemplatesController, type: :controller do expect(response.body).to include "mon_export_#{dossier.id}.pdf" end end + + def pj_item_params(stable_id:, text:, enabled: true) + item_params(text: text, enabled: enabled).merge("stable_id" => stable_id.to_s) + end + + def item_params(text:, enabled: true) + { + "enabled" => enabled, + "template" => { + "type" => "doc", + "content" => content(text:) + }.to_json + } + end + + def content(text:) + [ + { + "type" => "paragraph", + "content" => [ + { "text" => text, "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } } + ] + } + ] + end end diff --git a/spec/factories/export_template.rb b/spec/factories/export_template.rb index 6785356af..875b08553 100644 --- a/spec/factories/export_template.rb +++ b/spec/factories/export_template.rb @@ -2,86 +2,11 @@ FactoryBot.define do factory :export_template do name { "Mon export" } groupe_instructeur - content { - { - "pdf_name" => - { - "type" => "doc", - "content" => [ - { "type" => "paragraph", "content" => [{ "text" => "export_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_id", "label" => "id dossier" } }, { "text" => " .pdf", "type" => "text" }] } - ] - }, - "default_dossier_directory" => { - "type" => "doc", - "content" => - [ - { - "type" => "paragraph", - "content" => - [ - { "text" => "dossier_", "type" => "text" }, - { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, - { "text" => " ", "type" => "text" } - ] - } - ] - } - } -} - kind { "zip" } + initialize_with { ExportTemplate.default(name:, groupe_instructeur: groupe_instructeur) } - to_create do |export_template, _context| - export_template.set_default_values - export_template.save - end - - trait :with_custom_content do - to_create do |export_template, context| - export_template.set_default_values - export_template.content = context.content - export_template.save - end - end - - trait :with_custom_ddd_prefix do - transient do - ddd_prefix { 'dossier_' } - end - - to_create do |export_template, context| - export_template.set_default_values - export_template.content["default_dossier_directory"]["content"] = [ - { - "type" => "paragraph", - "content" => - [ - { "text" => context.ddd_prefix, "type" => "text" }, - { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, - { "text" => " ", "type" => "text" } - ] - } - ] - export_template.save - end - end - - trait :with_date_depot_for_export_pdf do - to_create do |export_template, _| - export_template.set_default_values - export_template.content["pdf_name"]["content"] = [ - { - "type" => "paragraph", - "content" => - [ - { "text" => "export_", "type" => "text" }, - { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, - { "text" => "-", "type" => "text" }, - { "type" => "mention", "attrs" => { "id" => "dossier_depose_at", "label" => "date de dépôt" } }, - { "text" => " ", "type" => "text" } - ] - } - ] - export_template.save + trait :enabled_pjs do + after(:build) do |export_template, _evaluator| + export_template.pjs.each { _1.instance_variable_set('@enabled', true) } end end end diff --git a/spec/models/concerns/pieces_jointes_list_concern_spec.rb b/spec/models/concerns/pieces_jointes_list_concern_spec.rb index 0356a52c9..e90e2d4ae 100644 --- a/spec/models/concerns/pieces_jointes_list_concern_spec.rb +++ b/spec/models/concerns/pieces_jointes_list_concern_spec.rb @@ -1,50 +1,78 @@ describe PiecesJointesListConcern do describe '#pieces_jointes_list' do include Logic - let(:procedure) { create(:procedure, types_de_champ_public:, types_de_champ_private:) } - let(:types_de_champ_public) do - [ - { type: :integer_number, stable_id: 900 }, - { type: :piece_justificative, libelle: "pj1", stable_id: 910 }, - { type: :piece_justificative, libelle: "pj-cond", stable_id: 911, condition: ds_eq(champ_value(900), constant(1)) }, - { type: :repetition, libelle: "Répétition", stable_id: 920, children: [{ type: :piece_justificative, libelle: "pj2", stable_id: 921 }] }, - { type: :titre_identite, libelle: "pj3", stable_id: 930 } - ] - end - let(:types_de_champ_private) do - [ - { type: :integer_number, stable_id: 950 }, - { type: :piece_justificative, libelle: "pj5", stable_id: 960 }, - { type: :piece_justificative, libelle: "pj-cond2", stable_id: 961, condition: ds_eq(champ_value(900), constant(1)) }, - { type: :repetition, libelle: "Répétition2", stable_id: 970, children: [{ type: :piece_justificative, libelle: "pj6", stable_id: 971 }] } - ] - end + describe 'public_wrapped_partionned_pjs and exportables_pieces_jointes' do + let(:procedure) { create(:procedure, types_de_champ_public:, types_de_champ_private:) } + let(:types_de_champ_public) do + [ + { type: :integer_number, stable_id: 900 }, + { type: :piece_justificative, libelle: "pj1", stable_id: 910 }, + { type: :piece_justificative, libelle: "pj-cond", stable_id: 911, condition: ds_eq(champ_value(900), constant(1)) }, + { type: :repetition, libelle: "Répétition", stable_id: 920, children: [{ type: :piece_justificative, libelle: "pj2", stable_id: 921 }] }, + { type: :titre_identite, libelle: "pj3", stable_id: 930 } + ] + end - let(:types_de_champ) { procedure.active_revision.types_de_champ } - def find_by_stable_id(stable_id) = types_de_champ.find { _1.stable_id == stable_id } + let(:types_de_champ_private) do + [ + { type: :integer_number, stable_id: 950 }, + { type: :piece_justificative, libelle: "pj5", stable_id: 960 }, + { type: :piece_justificative, libelle: "pj-cond2", stable_id: 961, condition: ds_eq(champ_value(900), constant(1)) }, + { type: :repetition, libelle: "Répétition2", stable_id: 970, children: [{ type: :piece_justificative, libelle: "pj6", stable_id: 971 }] } + ] + end - let(:pj1) { find_by_stable_id(910) } - let(:pjcond) { find_by_stable_id(911) } - let(:repetition) { find_by_stable_id(920) } - let(:pj2) { find_by_stable_id(921) } - let(:pj3) { find_by_stable_id(930) } + let(:types_de_champ) { procedure.active_revision.types_de_champ } + def find_by_stable_id(stable_id) = types_de_champ.find { _1.stable_id == stable_id } - let(:pj5) { find_by_stable_id(960) } - let(:pjcond2) { find_by_stable_id(961) } - let(:repetition2) { find_by_stable_id(970) } - let(:pj6) { find_by_stable_id(971) } + let(:pj1) { find_by_stable_id(910) } + let(:pjcond) { find_by_stable_id(911) } + let(:repetition) { find_by_stable_id(920) } + let(:pj2) { find_by_stable_id(921) } + let(:pj3) { find_by_stable_id(930) } - it "returns the list of pieces jointes without conditional" do - expect(procedure.public_wrapped_partionned_pjs.first).to match_array([[pj1], [pj2, repetition], [pj3]]) - end + let(:pj5) { find_by_stable_id(960) } + let(:pjcond2) { find_by_stable_id(961) } + let(:repetition2) { find_by_stable_id(970) } + let(:pj6) { find_by_stable_id(971) } - it "returns the list of pieces jointes having conditional" do - expect(procedure.public_wrapped_partionned_pjs.second).to match_array([[pjcond]]) - end + it "returns the list of pieces jointes without conditional" do + expect(procedure.public_wrapped_partionned_pjs.first).to match_array([[pj1], [pj2, repetition], [pj3]]) + end - it "returns the list of pieces jointes with private, without parent repetition, without titre identite" do - expect(procedure.exportables_pieces_jointes.map(&:libelle)).to match_array([pj1, pj2, pjcond, pj5, pjcond2, pj6].map(&:libelle)) + it "returns the list of pieces jointes having conditional" do + expect(procedure.public_wrapped_partionned_pjs.second).to match_array([[pjcond]]) + end + + it "returns the list of pieces jointes with private, without parent repetition, without titre identite" do + expect(procedure.exportables_pieces_jointes.map(&:libelle)).to match_array([pj1, pj2, pjcond, pj5, pjcond2, pj6].map(&:libelle)) + end + + it "returns the same list but for all versions" do + expect(procedure.exportables_pieces_jointes.map(&:libelle)).to match_array([pj1, pj2, pjcond, pj5, pjcond2, pj6].map(&:libelle)) + end end end + + describe '#outdated_exportables_pieces_jointes' do + let(:types_de_champ_public) do + [ + { type: :piece_justificative, libelle: "outdated", stable_id: 1 }, + { type: :piece_justificative, libelle: "kept", stable_id: 2 } + ] + end + + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + + before do + procedure.draft_revision.remove_type_de_champ(1) + procedure.draft_revision.add_type_de_champ(type_champ: :piece_justificative, libelle: 'new', mandatory: false) + procedure.publish_revision! + end + + it { expect(procedure.exportables_pieces_jointes_for_all_versions.map(&:libelle)).to eq(["new", "kept", "outdated"]) } + it { expect(procedure.exportables_pieces_jointes.map(&:libelle)).to match_array(["kept", "new"]) } + it { expect(procedure.outdated_exportables_pieces_jointes.map(&:libelle)).to match_array(["outdated"]) } + end end diff --git a/spec/models/export_item_spec.rb b/spec/models/export_item_spec.rb new file mode 100644 index 000000000..05c3cd41a --- /dev/null +++ b/spec/models/export_item_spec.rb @@ -0,0 +1,20 @@ +describe ExportItem do + describe 'path' do + let(:export_item) { ExportItem.default(prefix: 'custom') } + let(:dossier) { create(:dossier) } + let(:attachment) do + ActiveStorage::Attachment.new( + name: 'filename', + blob: ActiveStorage::Blob.new(filename: "file.pdf") + ) + end + + context 'without index nor row_index' do + it do + expect(export_item.path(dossier, attachment:)).to eq("custom-#{dossier.id}.pdf") + expect(export_item.path(dossier, attachment:, index: 3)).to eq("custom-#{dossier.id}-04.pdf") + expect(export_item.path(dossier, attachment:, row_index: 2, index: 3)).to eq("custom-#{dossier.id}-03-04.pdf") + end + end + end +end diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb index 5b2a8ae99..bd950bc36 100644 --- a/spec/models/export_spec.rb +++ b/spec/models/export_spec.rb @@ -110,9 +110,10 @@ RSpec.describe Export, type: :model do end context 'with export template' do - let(:export_template) { build(:export_template) } + let(:export_template) { create(:export_template, groupe_instructeur: gi_1) } + it 'creates new export' do - expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, export_template: export_template, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } + expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, export_template:, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } .to change { Export.count }.by(1) end end diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index 595668aa2..839b9c015 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -1,8 +1,7 @@ describe ExportTemplate do let(:groupe_instructeur) { create(:groupe_instructeur, procedure:) } - let(:export_template) { create(:export_template, :with_custom_content, groupe_instructeur:, content:) } - let(:procedure) { create(:procedure_with_dossiers, types_de_champ_public:, for_individual:) } - let(:dossier) { procedure.dossiers.first } + let(:export_template) { build(:export_template, groupe_instructeur:) } + let(:procedure) { create(:procedure, types_de_champ_public:, for_individual:) } let(:for_individual) { false } let(:types_de_champ_public) do [ @@ -10,335 +9,80 @@ describe ExportTemplate do { type: :titre_identite, libelle: "CNI", mandatory: true, stable_id: 5 } ] end - let(:content) do - { - "pdf_name" => { - "type" => "doc", - "content" => [ - { "type" => "paragraph", "content" => [{ "text" => "mon_export_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] } - ] - }, - "default_dossier_directory" => { - "type" => "doc", - "content" => [ - { "type" => "paragraph", "content" => [{ "text" => "DOSSIER_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] } - ] - }, - "pjs" => - [ - { path: { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "type" => "mention", "attrs" => { "id" => "original-filename", "label" => "nom original du fichier" } }, { "text" => " _justif", "type" => "text" }] }] }, stable_id: "3" }, - { - path: - { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "cni_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] }] }, - stable_id: "5" - }, - { - path: { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "pj_repet_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] }] }, - stable_id: "10" - } - ] - } - end - describe 'new' do - let(:export_template) { build(:export_template, groupe_instructeur: groupe_instructeur) } + describe '.default' do it 'set default values' do - export_template.set_default_values - expect(export_template.content).to eq({ - "pdf_name" => { - "type" => "doc", - "content" => [ - { "type" => "paragraph", "content" => [{ "text" => "export_", "type" => "text" }, { "type" => "mention", "attrs" => ExportTemplate::DOSSIER_ID_TAG.stringify_keys }] } - ] - }, - "default_dossier_directory" => { - "type" => "doc", - "content" => [ - { "type" => "paragraph", "content" => [{ "text" => "dossier-", "type" => "text" }, { "type" => "mention", "attrs" => ExportTemplate::DOSSIER_ID_TAG.stringify_keys }] } - ] - }, - "pjs" => - [ - - { - "stable_id" => "3", - "path" => { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "justificatif-de-domicile-", "type" => "text" }, { "type" => "mention", "attrs" => ExportTemplate::DOSSIER_ID_TAG.stringify_keys }] }] } - } - ] - }) + expect(export_template.export_pdf).to eq(ExportItem.default(prefix: "export", enabled: true)) + expect(export_template.dossier_folder).to eq(ExportItem.default(prefix: "dossier", enabled: true)) + expect(export_template.pjs).to eq([ExportItem.default(stable_id: 3, prefix: "justificatif-de-domicile", enabled: false)]) end end - describe '#assign_pj_names' do - let(:pj_params) do - { - "tiptap_pj_1" => { - "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] - }.to_json - } + describe '#pj' do + context 'when pj exists' do + subject { export_template.pj(double(stable_id: 3)) } + + it { is_expected.to eq(ExportItem.default(stable_id: 3, prefix: "justificatif-de-domicile", enabled: false)) } end - it 'values content from pj params' do - export_template.assign_pj_names(pj_params) - expect(export_template.content["pjs"]).to eq [ - { :path => { "content" => [{ "content" => [{ "text" => "avis-commission-", "type" => "text" }, { "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" }, "type" => "mention" }], "type" => "paragraph" }], "type" => "doc" }, :stable_id => "1" } - ] + + context 'when pj does not exist' do + subject { export_template.pj(TypeDeChamp.new(libelle: 'hi', stable_id: 10)) } + + it { is_expected.to eq(ExportItem.default(stable_id: 10, prefix: "hi", enabled: false)) } end end - describe '#tiptap_default_dossier_directory' do - it 'returns tiptap_default_dossier_directory from content' do - expect(export_template.tiptap_default_dossier_directory).to eq({ - "type" => "doc", - "content" => [ - { "type" => "paragraph", "content" => [{ "text" => "DOSSIER_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] } - ] - }.to_json) - end - end - - describe '#tiptap_pdf_name' do - it 'returns tiptap_pdf_name from content' do - expect(export_template.tiptap_pdf_name).to eq({ - "type" => "doc", - "content" => [ - { "type" => "paragraph", "content" => [{ "text" => "mon_export_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] } - ] - }.to_json) - end - end - - describe '#content_for_pj' do - let(:type_de_champ_pj) { create(:type_de_champ_piece_justificative, stable_id: 3, libelle: 'Justificatif de domicile', procedure:) } - let(:champ_pj) { create(:champ_piece_justificative, type_de_champ: type_de_champ_pj) } - - let(:attachment) { ActiveStorage::Attachment.new(name: 'pj', record: champ_pj, blob: ActiveStorage::Blob.new(filename: "superpj.png")) } - - it 'returns tiptap content for pj' do - expect(export_template.content_for_pj(type_de_champ_pj)).to eq({ - "type" => "doc", - "content" => [ - { "type" => "paragraph", "content" => [{ "type" => "mention", "attrs" => { "id" => "original-filename", "label" => "nom original du fichier" } }, { "text" => " _justif", "type" => "text" }] } - ] - }.to_json) - end - end - - describe '#attachment_and_path' do - let(:dossier) { create(:dossier) } + describe '#attachment_path' do + let(:dossier) { create(:dossier, :en_construction, procedure:) } context 'for export pdf' do - let(:attachment) { double("attachment") } + let(:export_template) do + build(:export_template, groupe_instructeur:, dossier_folder: ExportItem.default(prefix: "DOSSIER"), export_pdf: ExportItem.default(prefix: "mon_export")) + end + + let(:attachment) { ActiveStorage::Attachment.new(name: 'pdf_export_for_instructeur', blob: ActiveStorage::Blob.new(filename: "export.pdf")) } it 'gives absolute filename for export of specific dossier' do - allow(attachment).to receive(:name).and_return('pdf_export_for_instructeur') - expect(export_template.attachment_and_path(dossier, attachment)).to eq([attachment, "DOSSIER_#{dossier.id}/mon_export_#{dossier.id}.pdf"]) + expect(export_template.attachment_path(dossier, attachment)).to eq("DOSSIER-#{dossier.id}/mon_export-#{dossier.id}.pdf") end end context 'for pj' do - let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } - let(:champ_pj) { dossier.champs.find(&:piece_justificative?) } + let(:champ_pj) { dossier.champs_public.first } + let(:export_template) { create(:export_template, groupe_instructeur:, pjs: [ExportItem.default(stable_id: 3, prefix: "justif", enabled: true)]) } + let(:attachment) { ActiveStorage::Attachment.new(name: 'pj', record: champ_pj, blob: ActiveStorage::Blob.new(filename: "superpj.png")) } it 'returns pj and custom name for pj' do - expect(export_template.attachment_and_path(dossier, attachment, champ: champ_pj)).to eq([attachment, "DOSSIER_#{dossier.id}/superpj_justif-1.png"]) - end - end - context 'pj repetable' do - let(:procedure) { create(:procedure, :for_individual, types_de_champ_public:) } - let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } - let(:draft) { procedure.draft_revision } - let(:types_de_champ_public) do - [ - { - type: :repetition, - stable_id: 3333, - mandatory: true, children: [ - { type: :text, libelle: 'sub type de champ' }, - { type: :piece_justificative, stable_id: 10, libelle: 'pj repet' } - ] - } - ] - end - let(:champ_pj) { dossier.champs.find(&:piece_justificative?) } - let(:attachment) { ActiveStorage::Attachment.new(name: 'pj', record: champ_pj, blob: ActiveStorage::Blob.new(filename: "superpj.png")) } - - it 'rename repetable pj' do - expect(export_template.attachment_and_path(dossier, attachment, champ: champ_pj)).to eq([attachment, "DOSSIER_#{dossier.id}/pj_repet_#{dossier.id}-1.png"]) + expect(export_template.attachment_path(dossier, attachment, champ: champ_pj)).to eq("dossier-#{dossier.id}/justif-#{dossier.id}-01.png") end end end - describe '#tiptap_convert' do - it 'convert default dossier directory' do - expect(export_template.tiptap_convert(procedure.dossiers.first, "default_dossier_directory")).to eq "DOSSIER_#{dossier.id}" - end + describe '#tags and #pj_tags' do + let(:procedure) { build(:procedure, for_individual:) } - it 'convert pdf_name' do - expect(export_template.tiptap_convert(procedure.dossiers.first, "pdf_name")).to eq "mon_export_#{dossier.id}" - end - - context 'for date' do - let(:export_template) { create(:export_template, :with_date_depot_for_export_pdf, groupe_instructeur:) } - let(:dossier) { create(:dossier, :en_construction, procedure:, depose_at: Date.parse("2024/03/30")) } - it 'convert date with dash' do - expect(export_template.tiptap_convert(dossier, "pdf_name")).to eq "export_#{dossier.id}-2024-03-30" - end - end - end - - describe '#tiptap_convert_pj' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative, stable_id: 3, libelle: 'Justificatif de domicile' }]) } - let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } - let(:champ_pj) { dossier.champs.first } - let(:attachment) { ActiveStorage::Attachment.new(name: 'pj', record: champ_pj, blob: ActiveStorage::Blob.new(filename: "superpj.png")) } - - it 'convert pj' do - attachment - expect(export_template.tiptap_convert_pj(dossier, 3, attachment)).to eq "superpj_justif" - end - end - - describe '#valid?' do - let(:subject) { build(:export_template, groupe_instructeur:, content:) } - let(:ddd_text) { "DoSSIER" } - let(:mention) { { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } } } - let(:ddd_mention) { mention } - let(:pdf_text) { "export" } - let(:pdf_mention) { mention } - let(:pj_text) { "_pj" } - let(:pj_mention) { mention } - let(:content) do - { - "pdf_name" => { - "type" => "doc", - "content" => [ - { "type" => "paragraph", "content" => [{ "text" => pdf_text, "type" => "text" }, pdf_mention] } - ] - }, - "default_dossier_directory" => { - "type" => "doc", - "content" => [ - { "type" => "paragraph", "content" => [{ "text" => ddd_text, "type" => "text" }, ddd_mention] } - ] - }, - "pjs" => - [ - { path: { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [pj_mention, { "text" => pj_text, "type" => "text" }] }] }, stable_id: "3" } - ] - } - end - - context 'with valid default dossier directory' do - it 'has no error for default_dossier_directory' do - expect(subject.valid?).to be_truthy - end - end - - context 'with no ddd text' do - let(:ddd_text) { " " } - context 'with mention' do - let(:ddd_mention) { { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } } } - it 'has no error for default_dossier_directory' do - expect(subject.valid?).to be_truthy - end + context 'for entreprise procedure' do + let(:for_individual) { false } + let(:expected_tags) do + ['entreprise_siren', 'entreprise_numero_tva_intracommunautaire', 'entreprise_siret_siege_social', 'entreprise_raison_sociale', 'entreprise_adresse', 'dossier_depose_at', 'dossier_procedure_libelle', 'dossier_service_name', 'dossier_number', 'dossier_groupe_instructeur'] end - context 'without numéro de dossier' do - let(:ddd_mention) { { "type" => "mention", "attrs" => { "id" => 'dossier_service_name', "label" => "nom du service" } } } - it "add error for tiptap_default_dossier_directory" do - expect(subject.valid?).to be_falsey - expect(subject.errors[:tiptap_default_dossier_directory]).to be_present - expect(subject.errors.full_messages).to include "Le champ « Nom du répertoire » doit contenir le numéro du dossier" - end - end - end - - context 'with valid pdf name' do - it 'has no error for pdf name' do - expect(subject.valid?).to be_truthy - expect(subject.errors[:tiptap_pdf_name]).not_to be_present - end - end - - context 'with pdf text and without mention' do - let(:pdf_text) { "export" } - let(:pdf_mention) { { "type" => "mention", "attrs" => {} } } - - it "add no error" do - expect(subject.valid?).to be_truthy - end - end - - context 'with no pdf text' do - let(:pdf_text) { " " } - - context 'with mention' do - it 'has no error for default_dossier_directory' do - expect(subject.valid?).to be_truthy - expect(subject.errors[:tiptap_pdf_name]).not_to be_present - end - end - - context 'without mention' do - let(:pdf_mention) { { "type" => "mention", "attrs" => {} } } - it "add error for pdf name" do - expect(subject.valid?).to be_falsey - expect(subject.errors.full_messages).to include "Le champ « Nom du dossier au format pdf » doit être rempli" - end - end - end - - context 'with no pj text' do - # let!(:type_de_champ_pj) { create(:type_de_champ_piece_justificative, stable_id: 3, libelle: 'Justificatif de domicile', procedure:) } - let(:pj_text) { " " } - - context 'with mention' do - it 'has no error for pj' do - expect(subject.valid?).to be_truthy - end - end - - context 'without mention' do - let(:pj_mention) { { "type" => "mention", "attrs" => {} } } - it "add error for pj" do - expect(subject.valid?).to be_falsey - expect(subject.errors.full_messages).to include "Le champ « Justificatif de domicile » doit être rempli" - end - end - end - end - - context 'for entreprise procedure' do - let(:for_individual) { false } - describe 'specific_tags' do it do - tags = export_template.specific_tags - expect(tags.map { _1[:id] }).to eq ["entreprise_siren", "entreprise_numero_tva_intracommunautaire", "entreprise_siret_siege_social", "entreprise_raison_sociale", "entreprise_adresse", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur"] + expect(export_template.tags.map { _1[:id] }).to eq(expected_tags) + expect(export_template.pj_tags.map { _1[:id] }).to eq(expected_tags + ['original-filename']) end end - describe 'tags_for_pj' do - it do - tags = export_template.tags_for_pj - expect(tags.map { _1[:id] }).to eq ["entreprise_siren", "entreprise_numero_tva_intracommunautaire", "entreprise_siret_siege_social", "entreprise_raison_sociale", "entreprise_adresse", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur", "original-filename"] + context 'for individual procedure' do + let(:for_individual) { true } + let(:expected_tags) do + ['individual_gender', 'individual_last_name', 'individual_first_name', 'dossier_depose_at', 'dossier_procedure_libelle', 'dossier_service_name', 'dossier_number', 'dossier_groupe_instructeur'] end - end - end - context 'for individual procedure' do - let(:for_individual) { true } - describe 'specific_tags' do it do - tags = export_template.specific_tags - expect(tags.map { _1[:id] }).to eq ["individual_gender", "individual_last_name", "individual_first_name", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur"] - end - end - - describe 'tags_for_pj' do - it do - tags = export_template.tags_for_pj - expect(tags.map { _1[:id] }).to eq ["individual_gender", "individual_last_name", "individual_first_name", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur", "original-filename"] + expect(export_template.tags.map { _1[:id] }).to eq(expected_tags) + expect(export_template.pj_tags.map { _1[:id] }).to eq(expected_tags + ['original-filename']) end end end diff --git a/spec/models/type_de_champ_spec.rb b/spec/models/type_de_champ_spec.rb index 95cd2bb44..8cacac706 100644 --- a/spec/models/type_de_champ_spec.rb +++ b/spec/models/type_de_champ_spec.rb @@ -302,4 +302,12 @@ describe TypeDeChamp do it { expect(create(:type_de_champ, :header_section, libelle: " 2.3 Test").libelle).to eq("2.3 Test") } it { expect(create(:type_de_champ, libelle: " fix me ").libelle).to eq("fix me") } end + + describe '#safe_filename' do + subject { build(:type_de_champ, libelle:).libelle_as_filename } + + let(:libelle) { " #/🐉 1 très intéressant Bilan " } + + it { is_expected.to eq("1-tres-interessant-bilan") } + end end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 5d731e76a..9c999588c 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -25,7 +25,7 @@ describe PiecesJustificativesService do before { attach_file_to_champ(champ) } it do - expect(export_template).to receive(:attachment_and_path) + expect(export_template).to receive(:attachment_path) .with(dossier, attachments(pj_champ(dossier)).first, index: 0, row_index: nil, champ:) subject end @@ -40,10 +40,10 @@ describe PiecesJustificativesService do end it do - expect(export_template).to receive(:attachment_and_path) + expect(export_template).to receive(:attachment_path) .with(dossier, attachments(pj_champ(dossier)).first, index: 0, row_index: nil, champ:) - expect(export_template).to receive(:attachment_and_path) + expect(export_template).to receive(:attachment_path) .with(dossier, attachments(pj_champ(dossier)).second, index: 1, row_index: nil, champ:) subject end @@ -66,13 +66,13 @@ describe PiecesJustificativesService do first_child_attachments = attachments(repetition(dossier).champs.first) second_child_attachments = attachments(repetition(dossier).champs.second) - expect(export_template).to receive(:attachment_and_path) + expect(export_template).to receive(:attachment_path) .with(dossier, first_child_attachments.first, index: 0, row_index: 0, champ: first_champ) - expect(export_template).to receive(:attachment_and_path) + expect(export_template).to receive(:attachment_path) .with(dossier, first_child_attachments.second, index: 1, row_index: 0, champ: first_champ) - expect(export_template).to receive(:attachment_and_path) + expect(export_template).to receive(:attachment_path) .with(dossier, second_child_attachments.first, index: 0, row_index: 1, champ: second_champ) count = 0 @@ -90,6 +90,7 @@ describe PiecesJustificativesService do describe '.liste_documents' do let(:dossier) { create(:dossier, procedure: procedure) } let(:dossiers) { Dossier.where(id: dossier.id) } + let(:default_export_template) { build(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur) } let(:export_template) { nil } subject do PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:first) @@ -99,39 +100,39 @@ describe PiecesJustificativesService do let(:user_profile) { build(:administrateur) } let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative }]) } let(:witness) { create(:dossier, procedure: procedure) } - let(:pj_champ) { -> (d) { d.champs_public.find { |c| c.type == 'Champs::PieceJustificativeChamp' } } } + def pj_champ(d) = d.champs_public.find { |c| c.type == 'Champs::PieceJustificativeChamp' } context 'with a single attachment' do before do - attach_file_to_champ(pj_champ.call(dossier)) - attach_file_to_champ(pj_champ.call(witness)) + attach_file_to_champ(pj_champ(dossier)) + attach_file_to_champ(pj_champ(witness)) end - it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) } + it { expect(subject).to match_array(pj_champ(dossier).piece_justificative_file.attachments) } context 'with export_template' do - let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur) } - it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) } + let(:export_template) { build(:export_template, :enabled_pjs, groupe_instructeur: procedure.defaut_groupe_instructeur) } + + it { expect(subject).to match_array(pj_champ(dossier).piece_justificative_file.attachments) } end end context 'with a multiple attachments' do before do - attach_file_to_champ(pj_champ.call(dossier)) - attach_file_to_champ(pj_champ.call(witness)) - attach_file_to_champ(pj_champ.call(dossier)) + attach_file_to_champ(pj_champ(dossier)) + attach_file_to_champ(pj_champ(witness)) + attach_file_to_champ(pj_champ(dossier)) end it { expect(subject.count).to eq(2) } - it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) } + it { expect(subject).to match_array(pj_champ(dossier).piece_justificative_file.attachments) } end context 'with a pj not safe on a champ' do let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative }]) } let(:dossier) { create(:dossier, procedure: procedure) } - let(:pj_champ) { -> (d) { d.champs_public.find { |c| c.type == 'Champs::PieceJustificativeChamp' } } } - before { attach_file_to_champ(pj_champ.call(dossier), safe = false) } + before { attach_file_to_champ(pj_champ(dossier), false) } it { expect(subject).to be_empty } end @@ -139,7 +140,6 @@ describe PiecesJustificativesService do context 'with a identite champ pj' do let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :titre_identite }]) } let(:dossier) { create(:dossier, procedure: procedure) } - let(:witness) { create(:dossier, procedure: procedure) } let(:champ_identite) { dossier.champs_public.find { |c| c.type == 'Champs::TitreIdentiteChamp' } } @@ -149,6 +149,12 @@ describe PiecesJustificativesService do expect(champ_identite.piece_justificative_file).to be_attached expect(subject).to be_empty end + + context 'with export_template' do + let(:export_template) { build(:export_template, :enabled_pjs, groupe_instructeur: procedure.defaut_groupe_instructeur) } + + it { expect(subject).to be_empty } + end end context 'with a pj on an commentaire' do @@ -166,10 +172,9 @@ describe PiecesJustificativesService do it { expect(subject).to match_array(dossier.commentaires.first.piece_jointe.attachments) } context 'with export_template' do - let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } - it 'uses specific name for dossier directory' do - expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/messagerie")).to be true - end + let(:export_template) { default_export_template } + + it { expect(subject).to be_empty } end end @@ -177,7 +182,7 @@ describe PiecesJustificativesService do let(:dossier) { create(:dossier) } let!(:commentaire) { create(:commentaire, dossier: dossier) } - before { attach_file(commentaire.piece_jointe, safe = false) } + before { attach_file(commentaire.piece_jointe, false) } it { expect(subject).to be_empty } end @@ -189,17 +194,16 @@ describe PiecesJustificativesService do it { expect(subject).to match_array(dossier.justificatif_motivation.attachment) } context 'with export_template' do - let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } - it 'uses specific name for dossier directory' do - expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/dossier")).to be true - end + let(:export_template) { default_export_template } + + it { expect(subject).to be_empty } end end context 'with a motivation not safe' do let(:dossier) { create(:dossier) } - before { attach_file(dossier.justificatif_motivation, safe = false) } + before { attach_file(dossier.justificatif_motivation, false) } it { expect(subject).to be_empty } end @@ -214,10 +218,9 @@ describe PiecesJustificativesService do end context 'with export_template' do - let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } - it 'uses specific name for dossier directory' do - expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/pieces_justificatives")).to be true - end + let(:export_template) { default_export_template } + + it { expect(subject).to be_empty } end end @@ -242,10 +245,9 @@ describe PiecesJustificativesService do end context 'with export_template' do - let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } - it 'uses specific name for dossier directory' do - expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/pieces_justificatives")).to be true - end + let(:export_template) { default_export_template } + + it { expect(subject).to be_empty } end end end @@ -256,32 +258,33 @@ describe PiecesJustificativesService do let(:witness) { create(:dossier, procedure: procedure) } let!(:private_pj) { create(:type_de_champ_piece_justificative, procedure: procedure, private: true) } - let(:private_pj_champ) { -> (d) { d.champs_private.find { |c| c.type == 'Champs::PieceJustificativeChamp' } } } + def private_pj_champ(d) = d.champs_private.find { |c| c.type == 'Champs::PieceJustificativeChamp' } before do - attach_file_to_champ(private_pj_champ.call(dossier)) - attach_file_to_champ(private_pj_champ.call(witness)) + attach_file_to_champ(private_pj_champ(dossier)) + attach_file_to_champ(private_pj_champ(witness)) end context 'given an administrateur' do let(:user_profile) { build(:administrateur) } - it { expect(subject).to match_array(private_pj_champ.call(dossier).piece_justificative_file.attachments) } + it { expect(subject).to match_array(private_pj_champ(dossier).piece_justificative_file.attachments) } end context 'given an instructeur' do let(:user_profile) { create(:instructeur) } - it { expect(subject).to match_array(private_pj_champ.call(dossier).piece_justificative_file.attachments) } + it { expect(subject).to match_array(private_pj_champ(dossier).piece_justificative_file.attachments) } end context 'given an expert' do let(:user_profile) { create(:expert) } - it { expect(subject).not_to match_array(private_pj_champ.call(dossier).piece_justificative_file.attachments) } + it { expect(subject).not_to match_array(private_pj_champ(dossier).piece_justificative_file.attachments) } end end context 'acl on bill' do let(:dossier) { create(:dossier) } let(:witness) { create(:dossier) } + let(:default_export_template) { build(:export_template, groupe_instructeur: dossier.procedure.defaut_groupe_instructeur) } let(:bill_signature) do bs = build(:bill_signature, :with_serialized, :with_signature) @@ -309,6 +312,12 @@ describe PiecesJustificativesService do expect(subject).to match_array([dossier_bs.serialized.attachment, dossier_bs.signature.attachment]) end + context 'with export_template' do + let(:export_template) { default_export_template } + + it { expect(subject).to be_empty } + end + context 'with a dol' do let(:dol) { create(:dossier_operation_log, dossier: dossier) } let(:witness_dol) { create(:dossier_operation_log, dossier: witness) } @@ -319,6 +328,12 @@ describe PiecesJustificativesService do end it { expect(subject).to include(dol.serialized.attachment) } + + context 'with export_template' do + let(:export_template) { default_export_template } + + it { expect(subject).to be_empty } + end end end @@ -361,6 +376,12 @@ describe PiecesJustificativesService do it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end + + context 'with export_template' do + let(:export_template) { default_export_template } + + it { expect(subject).to be_empty } + end end context 'given an instructeur' do @@ -415,13 +436,6 @@ describe PiecesJustificativesService do it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end - - context 'with export_template' do - let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } - it 'uses specific name for dossier directory' do - expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/avis")).to be true - end - end end context 'given an expert' do @@ -464,11 +478,12 @@ describe PiecesJustificativesService do end context 'with export template' do - let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + let(:groupe_instructeur) { procedure.defaut_groupe_instructeur } + let(:export_template) { create(:export_template, groupe_instructeur:, dossier_folder: ExportItem.default(prefix: 'DOSSIER')) } subject { PiecesJustificativesService.new(user_profile:, export_template:).generate_dossiers_export(dossiers) } it 'gives custom name to export pdf file' do - expect(subject.first.second).to eq "DOSSIER-#{dossier.id}/export_#{dossier.id}.pdf" + expect(subject.first.second).to eq "DOSSIER-#{dossier.id}/export-#{dossier.id}.pdf" end end end diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index f00471505..5863ea454 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -450,7 +450,7 @@ describe ProcedureExportService do context 'with export_template' do let!(:dossier) { create(:dossier, :accepte, :with_populated_champs, :with_individual, procedure: procedure) } let(:dossier_exports) { PiecesJustificativesService.new(user_profile: instructeur, export_template:).generate_dossiers_export(Dossier.where(id: dossier)) } - let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur) } + let(:export_template) { create(:export_template, :enabled_pjs, groupe_instructeur: procedure.defaut_groupe_instructeur) } before do allow_any_instance_of(ActiveStorage::Attachment).to receive(:url).and_return("https://opengraph.githubassets.com/d0e7862b24d8026a3c03516d865b28151eb3859029c6c6c2e86605891fbdcd7a/socketry/async-io") end @@ -465,10 +465,9 @@ describe ProcedureExportService do structure = [ "#{base_fn}/", "#{base_fn}/dossier-#{dossier.id}/", - "#{base_fn}/dossier-#{dossier.id}/piece_justificative-#{dossier.id}-1.txt", - "#{base_fn}/dossier-#{dossier.id}/export_#{dossier.id}.pdf" + "#{base_fn}/dossier-#{dossier.id}/piece_justificative-#{dossier.id}-01.txt", + "#{base_fn}/dossier-#{dossier.id}/export-#{dossier.id}.pdf" ] - expect(files.size).to eq(structure.size) expect(files.map(&:filename)).to match_array(structure) end FileUtils.remove_entry_secure('tmp.zip') diff --git a/spec/services/procedure_export_service_zip_spec.rb b/spec/services/procedure_export_service_zip_spec.rb index e4daaf3ee..4b8b78e60 100644 --- a/spec/services/procedure_export_service_zip_spec.rb +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -2,7 +2,7 @@ describe ProcedureExportService do let(:instructeur) { create(:instructeur) } let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative, libelle: 'pj' }, { type: :repetition, children: [{ type: :piece_justificative, libelle: 'repet_pj' }] }]) } let(:dossiers) { create_list(:dossier, 10, procedure: procedure) } - let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur) } + let(:export_template) { create(:export_template, :enabled_pjs, groupe_instructeur: procedure.defaut_groupe_instructeur) } let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } def pj_champ(d) = d.champs_public.find_by(type: 'Champs::PieceJustificativeChamp') @@ -49,11 +49,11 @@ describe ProcedureExportService do structure = [ "export/", "export/dossier-#{dossier.id}/", - "export/dossier-#{dossier.id}/export_#{dossier.id}.pdf", - "export/dossier-#{dossier.id}/pj-#{dossier.id}-1.png", - "export/dossier-#{dossier.id}/repet_pj-#{dossier.id}-1-1.png", - "export/dossier-#{dossier.id}/repet_pj-#{dossier.id}-2-1.png", - "export/dossier-#{dossier.id}/repet_pj-#{dossier.id}-1-2.png" + "export/dossier-#{dossier.id}/export-#{dossier.id}.pdf", + "export/dossier-#{dossier.id}/pj-#{dossier.id}-01.png", + "export/dossier-#{dossier.id}/repet_pj-#{dossier.id}-01-01.png", + "export/dossier-#{dossier.id}/repet_pj-#{dossier.id}-02-01.png", + "export/dossier-#{dossier.id}/repet_pj-#{dossier.id}-01-02.png" ] expect(files.size).to eq(dossiers.count * 6 + 1) diff --git a/spec/services/tiptap_service_spec.rb b/spec/services/tiptap_service_spec.rb index 1ec0468b2..365fcae3b 100644 --- a/spec/services/tiptap_service_spec.rb +++ b/spec/services/tiptap_service_spec.rb @@ -193,19 +193,40 @@ RSpec.describe TiptapService do end end - describe '.to_path' do - let(:substitutions) { { "dossier_number" => "42" } } - let(:json) do - { - "content" => [ - { "type" => "paragraph", "content" => [{ "text" => "export_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " .pdf", "type" => "text" }] } - ] + describe '.to_texts_and_tags' do + subject { described_class.new.to_texts_and_tags(json, substitutions) } - }.deep_symbolize_keys + context 'nominal' do + let(:json) do + { + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => "export_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " .pdf", "type" => "text" }] } + ] + + }.deep_symbolize_keys + end + + context 'with substitutions' do + let(:substitutions) { { "dossier_number" => "42" } } + it 'returns texts_and_tags' do + is_expected.to eq("export_42.pdf") + end + end + + context 'without substitutions' do + let(:substitutions) { nil } + + it 'returns texts_and_tags' do + is_expected.to eq("export_numéro du dossier.pdf") + end + end end - it 'returns path' do - expect(described_class.new.to_path(json, substitutions)).to eq("export_42.pdf") + context 'empty paragraph' do + let(:json) { { content: [{ type: 'paragraph' }] } } + let(:substitutions) { {} } + + it { is_expected.to eq('') } end end end diff --git a/spec/types/export_item_type_spec.rb b/spec/types/export_item_type_spec.rb new file mode 100644 index 000000000..1a0d270b0 --- /dev/null +++ b/spec/types/export_item_type_spec.rb @@ -0,0 +1,55 @@ +describe ExportItemType do + let(:type) { ExportItemType.new } + + describe 'cast' do + it 'from ExportItem' do + export_item = ExportItem.new(template: { foo: 'bar' }, enabled: true, stable_id: 42) + expect(type.cast(export_item)).to eq(export_item) + end + + it 'from nil' do + expect(type.cast(nil)).to eq(nil) + end + + it 'from db' do + h = { template: { foo: 'bar' }, enabled: true, stable_id: 42 } + expect(type.cast(h)).to eq(ExportItem.new(template: { foo: 'bar' }, enabled: true, stable_id: 42)) + end + + it 'from form' do + h = { template: '{"foo":{"bar":"zob"}}' } + expect(type.cast(h)).to eq(ExportItem.new(template: { foo: { bar: 'zob' } }, enabled: false)) + + h = { template: '{"foo":{"bar":"zob"}}', enabled: 'true' } + expect(type.cast(h)).to eq(ExportItem.new(template: { foo: { bar: 'zob' } }, enabled: true)) + + h = { template: '{"foo":{"bar":"zob"}}', stable_id: '42' } + expect(type.cast(h)).to eq(ExportItem.new(template: { foo: { bar: 'zob' } }, enabled: false, stable_id: 42)) + + h = { template: '{"foo":{"bar":"zob"}}', enabled: 'true', stable_id: '42' } + expect(type.cast(h)).to eq(ExportItem.new(template: { foo: { bar: 'zob' } }, enabled: true, stable_id: 42)) + end + + it 'from invalid value' do + expect { type.cast('invalid value') }.to raise_error(NoMatchingPatternError) + end + end + + describe 'deserialize' do + it 'from nil' do + expect(type.deserialize(nil)).to eq(nil) + end + + it 'from db' do + h = { template: { foo: 'bar' }, enabled: true, stable_id: 42 } + expect(type.deserialize(JSON.generate(h))).to eq(ExportItem.new(template: { foo: 'bar' }, enabled: true, stable_id: 42)) + end + end + + describe 'serialize' do + it 'from ExportItem' do + export_item = ExportItem.new(template: { foo: 'bar' }, enabled: true, stable_id: 42) + expect(type.serialize(export_item)).to eq('{"template":{"foo":"bar"},"enabled":true,"stable_id":42}') + end + end +end diff --git a/spec/validators/export_template_validator_spec.rb b/spec/validators/export_template_validator_spec.rb new file mode 100644 index 000000000..53e1c7f0a --- /dev/null +++ b/spec/validators/export_template_validator_spec.rb @@ -0,0 +1,79 @@ +describe ExportTemplateValidator do + let(:validator) { ExportTemplateValidator.new } + + describe 'validate' do + let(:exportables_pieces_jointes) { [double('pj', stable_id: 3, libelle: 'libelle')] } + let(:pj_libelle_by_stable_id) { exportables_pieces_jointes.map { |pj| [pj.stable_id, pj.libelle] }.to_h } + + def empty_template(enabled: true, stable_id: nil) + { template: { type: "doc", content: [] }, enabled: enabled, stable_id: stable_id }.compact + end + + def errors(export_template) = export_template.errors.map { [_1.attribute, _1.message] } + + before do + allow(validator).to receive(:pj_libelle_by_stable_id).and_return(pj_libelle_by_stable_id) + validator.validate(export_template) + end + + context 'with a default export template' do + let(:export_template) { build(:export_template) } + + it { expect(export_template.errors.count).to eq(0) } + end + + context 'with a invalid template' do + let(:export_template) do + export_pdf = { template: { is: 'invalid' }, enabled: true } + build(:export_template, export_pdf:) + end + + it { expect(errors(export_template)).to eq([[:base, "Un nom de fichier est invalide"]]) } + end + + context 'with a empty export_pdf' do + let(:export_template) { build(:export_template, export_pdf: empty_template) } + + it { expect(errors(export_template)).to eq([[:export_pdf, "doit être rempli"]]) } + end + + context 'with a empty export_pdf disabled' do + let(:export_template) { build(:export_template, export_pdf: empty_template(enabled: false)) } + + it { expect(export_template.errors.count).to eq(0) } + end + + context 'with a dossier_folder without dossier_number' do + let(:export_template) do + dossier_folder = ExportItem.default(prefix: 'dossier') + dossier_folder.template[:content][0][:content][1][:attrs][:id] = :other + + build(:export_template, dossier_folder:) + end + + it { expect(errors(export_template)).to eq([[:dossier_folder, "doit contenir le numéro du dossier"]]) } + end + + context 'with a empty pj' do + let(:export_template) { build(:export_template, pjs: [empty_template(stable_id: 3)]) } + + it { expect(errors(export_template)).to eq([[:libelle, "doit être rempli"]]) } + end + + context 'with a empty pj disabled' do + let(:export_template) { build(:export_template, pjs: [empty_template(enabled: false)]) } + + it { expect(export_template.errors.count).to eq(0) } + end + + context 'with multiple files bearing the same template' do + let(:export_item) { ExportItem.default(prefix: 'same') } + + let(:export_template) do + build(:export_template, export_pdf: export_item, pjs: [export_item]) + end + + it { expect(errors(export_template)).to eq([[:base, "Les fichiers doivent avoir des noms différents"]]) } + end + end +end