From 40353fee04b3fa0c4b099153d544af0399fb5b77 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 18 Jan 2024 12:38:46 +0100 Subject: [PATCH] feat(attestation): replace tags in preview for v2 --- .../attestation_template_v2s_controller.rb | 5 +- app/models/attestation_template.rb | 67 +++- .../concerns/tags_substitution_concern.rb | 52 ++- app/models/procedure.rb | 8 + app/services/tiptap_service.rb | 47 ++- app/validators/tags_validator.rb | 2 +- lib/tasks/benchmarks.rake | 1 + ...ttestation_template_v2s_controller_spec.rb | 78 +++++ spec/factories/attestation_template.rb | 35 ++ spec/models/attestation_template_spec.rb | 11 + .../concern/tags_substitution_concern_spec.rb | 33 ++ spec/services/tiptap_service_spec.rb | 309 +++++++++--------- 12 files changed, 452 insertions(+), 196 deletions(-) create mode 100644 spec/controllers/administrateurs/attestation_template_v2s_controller_spec.rb diff --git a/app/controllers/administrateurs/attestation_template_v2s_controller.rb b/app/controllers/administrateurs/attestation_template_v2s_controller.rb index 608ae3e5d..a38c6d97d 100644 --- a/app/controllers/administrateurs/attestation_template_v2s_controller.rb +++ b/app/controllers/administrateurs/attestation_template_v2s_controller.rb @@ -5,8 +5,9 @@ module Administrateurs before_action :retrieve_procedure, :retrieve_attestation_template, :ensure_feature_active def show - json_body = @attestation_template.json_body&.deep_symbolize_keys - @body = TiptapService.new.to_html(json_body, {}) + preview_dossier = @procedure.dossier_for_preview(current_user) + + @body = @attestation_template.render_attributes_for(dossier: preview_dossier).fetch(:body) respond_to do |format| format.html do diff --git a/app/models/attestation_template.rb b/app/models/attestation_template.rb index 5fde87bd9..72312d7ee 100644 --- a/app/models/attestation_template.rb +++ b/app/models/attestation_template.rb @@ -64,26 +64,19 @@ class AttestationTemplate < ApplicationRecord end def render_attributes_for(params = {}) - attributes = { - created_at: Time.zone.now, + groupe_instructeur = params[:groupe_instructeur] + groupe_instructeur ||= params[:dossier]&.groupe_instructeur + + base_attributes = { + created_at: Time.current, footer: params.fetch(:footer, footer), - logo: params.fetch(:logo, logo.attached? ? logo : nil) + signature: signature_to_render(groupe_instructeur) } - dossier = params[:dossier] - - if dossier.present? - attributes.merge({ - title: replace_tags(title, dossier, escape: false), - body: replace_tags(body, dossier, escape: false), - signature: signature_to_render(dossier.groupe_instructeur) - }) + if version == 2 + render_attributes_for_v2(params, base_attributes) else - attributes.merge({ - title: params.fetch(:title, title), - body: params.fetch(:body, body), - signature: signature_to_render(params[:groupe_instructeur]) - }) + render_attributes_for_v1(params, base_attributes) end end @@ -113,6 +106,48 @@ class AttestationTemplate < ApplicationRecord private + def render_attributes_for_v1(params, base_attributes) + attributes = base_attributes.merge( + logo: params.fetch(:logo, logo.attached? ? logo : nil) + ) + + dossier = params[:dossier] + + if dossier.present? + attributes.merge( + title: replace_tags(title, dossier, escape: false), + body: replace_tags(body, dossier, escape: false) + ) + else + attributes.merge( + title: params.fetch(:title, title), + body: params.fetch(:body, body) + ) + end + end + + def render_attributes_for_v2(params, base_attributes) + dossier = params[:dossier] + + json = json_body&.deep_symbolize_keys + tiptap = TiptapService.new + + if dossier.present? + # 2x faster this way than with `replace_tags` which would reparse text + used_tags = tiptap.used_tags_and_libelle_for(json.deep_symbolize_keys) + substitutions = tags_substitutions(used_tags, dossier, escape: false) + body = tiptap.to_html(json, substitutions) + + attributes.merge( + body: + ) + else + attributes.merge( + body: params.fetch(:body) { tiptap.to_html(json) } + ) + end + end + def signature_to_render(groupe_instructeur) if groupe_instructeur&.signature&.attached? groupe_instructeur.signature diff --git a/app/models/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index bf827a770..b574d6034 100644 --- a/app/models/concerns/tags_substitution_concern.rb +++ b/app/models/concerns/tags_substitution_concern.rb @@ -155,14 +155,14 @@ module TagsSubstitutionConcern available_for_states: Dossier::SOUMIS }, { - id: 'individual_first_name', + id: 'individual_last_name', libelle: 'nom', description: "nom de l'usager", target: :nom, available_for_states: Dossier::SOUMIS }, { - id: 'individual_last_name', + id: 'individual_first_name', libelle: 'prénom', description: "prénom de l'usager", target: :prenom, @@ -254,6 +254,34 @@ module TagsSubstitutionConcern used_tags_and_libelle_for(text).map { _1.first.nil? ? _1.second : _1.first } end + def tags_substitutions(tokens, dossier, escape: true) + # NOTE: + # - tokens est un simple Set d'ids (pas la même structure que dans replace_tags) + # - dans replace_tags, on fait référence à des tags avec ou sans id, mais pas ici, + # a priori inutile car tiptap ne fait référence qu'aux ids. + + @escape_unsafe_tags = escape + + flat_tags = tags_and_datas_list(dossier).each_with_object({}) do |(tags, data), result| + next if data.nil? + + valid_tags = tags_for_dossier_state(tags) + + valid_tags.each do |tag| + result[tag[:id]] = [tag, data] + end + end + + tokens.index_with do |token| + case flat_tags[token] + in tag, data + replace_tag(tag, data) + else + token + end + end + end + private def format_date(date) @@ -323,14 +351,7 @@ module TagsSubstitutionConcern tokens = parse_tags(text) - tags_and_datas = [ - [champ_public_tags(dossier: dossier), dossier.champs_public], - [champ_private_tags(dossier: dossier), dossier.champs_private], - [dossier_tags, dossier], - [ROUTAGE_TAGS, dossier], - [INDIVIDUAL_TAGS, dossier.individual], - [ENTREPRISE_TAGS, dossier.etablissement&.entreprise] - ].filter_map do |(tags, data)| + tags_and_datas = tags_and_datas_list(dossier).filter_map do |(tags, data)| data && [tags_for_dossier_state(tags).index_by { _1[:id] }, data] end @@ -408,4 +429,15 @@ module TagsSubstitutionConcern end end end + + def tags_and_datas_list(dossier) + [ + [champ_public_tags(dossier:), dossier.champs_public], + [champ_private_tags(dossier:), dossier.champs_private], + [dossier_tags, dossier], + [ROUTAGE_TAGS, dossier], + [INDIVIDUAL_TAGS, dossier.individual], + [ENTREPRISE_TAGS, dossier.etablissement&.entreprise] + ] + end end diff --git a/app/models/procedure.rb b/app/models/procedure.rb index f9bd0dfdd..3da7aa07b 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -992,6 +992,14 @@ class Procedure < ApplicationRecord draft_revision.revision_types_de_champ_public.filter { _1.type_de_champ.header_section? } end + def dossier_for_preview(user) + # Try to use a preview or a dossier filled by current user + dossiers.where(for_procedure_preview: true).or(dossiers.not_brouillon) + .order(Arel.sql("CASE WHEN for_procedure_preview = True THEN 1 ELSE 0 END DESC, + CASE WHEN user_id = #{user.id} THEN 1 ELSE 0 END DESC")) \ + .first + end + private def pieces_jointes_list diff --git a/app/services/tiptap_service.rb b/app/services/tiptap_service.rb index 0f50ecc3d..767bd0bc2 100644 --- a/app/services/tiptap_service.rb +++ b/app/services/tiptap_service.rb @@ -1,8 +1,21 @@ class TiptapService - def to_html(node, tags) + def to_html(node, substitutions = {}) return '' if node.nil? - children(node[:content], tags, 0) + children(node[:content], substitutions, 0) + end + + def used_tags(node, tags = Set.new) + case node + in type: 'mention', attrs: { id: } + tags << id + in { content: } if content.is_a?(Array) + content.each { used_tags(_1, tags) } + else + # noop + end + + tags end private @@ -11,11 +24,11 @@ class TiptapService @body_started = false end - def children(content, tags, level) - content.map { node_to_html(_1, tags, level) }.join + def children(content, substitutions, level) + content.map { node_to_html(_1, substitutions, level) }.join end - def node_to_html(node, tags, level) + def node_to_html(node, substitutions, level) if level == 0 && !@body_started && node[:type] == 'paragraph' && node.key?(:content) @body_started = true body_start_mark = " class=\"body-start\"" @@ -23,23 +36,23 @@ class TiptapService case node in type: 'header', content: - "
#{children(content, tags, level + 1)}
" + "
#{children(content, substitutions, level + 1)}
" in type: 'footer', content:, **rest - "#{children(content, tags, level + 1)}" + "#{children(content, substitutions, level + 1)}" in type: 'headerColumn', content:, **rest - "#{children(content, tags, level + 1)}" + "#{children(content, substitutions, level + 1)}" in type: 'paragraph', content:, **rest - "#{children(content, tags, level + 1)}

" + "#{children(content, substitutions, level + 1)}

" in type: 'title', content:, **rest - "#{children(content, tags, level + 1)}" + "#{children(content, substitutions, level + 1)}" in type: 'heading', attrs: { level: hlevel, **attrs }, content: - "#{children(content, tags, level + 1)}" + "#{children(content, substitutions, level + 1)}" in type: 'bulletList', content: - "
    #{children(content, tags, level + 1)}
" + "
    #{children(content, substitutions, level + 1)}
" in type: 'orderedList', content: - "
    #{children(content, tags, level + 1)}
" + "
    #{children(content, substitutions, level + 1)}
" in type: 'listItem', content: - "
  • #{children(content, tags, level + 1)}
  • " + "
  • #{children(content, substitutions, level + 1)}
  • " in type: 'text', text:, **rest if rest[:marks].present? apply_marks(text, rest[:marks]) @@ -47,10 +60,12 @@ class TiptapService text end in type: 'mention', attrs: { id: }, **rest + text = substitutions.fetch(id) { "--#{id}--" } + if rest[:marks].present? - apply_marks(tags[id], rest[:marks]) + apply_marks(text, rest[:marks]) else - tags[id] + text end in { type: type } if ["paragraph", "title", "heading"].include?(type) && !node.key?(:content) # noop diff --git a/app/validators/tags_validator.rb b/app/validators/tags_validator.rb index 4b55c5cf9..ca57b6fe0 100644 --- a/app/validators/tags_validator.rb +++ b/app/validators/tags_validator.rb @@ -1,7 +1,7 @@ class TagsValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) procedure = record.procedure - tags = record.used_type_de_champ_tags(value || '') + tags = record.used_type_de_champ_tags(value.to_s) invalid_tags = tags.filter_map do |(tag, stable_id)| tag if stable_id.nil? diff --git a/lib/tasks/benchmarks.rake b/lib/tasks/benchmarks.rake index eb06fc3ef..237cd7fe5 100644 --- a/lib/tasks/benchmarks.rake +++ b/lib/tasks/benchmarks.rake @@ -102,6 +102,7 @@ namespace :benchmarks do controller.request = ActionDispatch::TestRequest.create controller.response = ActionDispatch::TestResponse.new controller.request.env['warden'] = warden + # controller.request.path_parameters[:format] = 'pdf' params = ENV.fetch("PARAMS") { "" }.split(",") params.each do |param| diff --git a/spec/controllers/administrateurs/attestation_template_v2s_controller_spec.rb b/spec/controllers/administrateurs/attestation_template_v2s_controller_spec.rb new file mode 100644 index 000000000..6c8dba830 --- /dev/null +++ b/spec/controllers/administrateurs/attestation_template_v2s_controller_spec.rb @@ -0,0 +1,78 @@ +describe Administrateurs::AttestationTemplateV2sController, type: :controller do + let(:admin) { create(:administrateur) } + let(:attestation_template) { build(:attestation_template, :v2) } + let!(:procedure) { create(:procedure, administrateur: admin, attestation_template: attestation_template, libelle: "Ma démarche") } + let(:logo) { fixture_file_upload('spec/fixtures/files/white.png', 'image/png') } + let(:signature) { fixture_file_upload('spec/fixtures/files/black.png', 'image/png') } + + before do + sign_in(admin.user) + Flipper.enable(:attestation_v2) + end + + describe 'GET #show' do + subject do + get :show, params: { procedure_id: procedure.id } + response.body + end + + context 'if an attestation template exists on the procedure' do + render_views + + context 'with preview dossier' do + let!(:dossier) { create(:dossier, :en_construction, procedure:, for_procedure_preview: true) } + + it do + is_expected.to include("Mon titre pour Ma démarche") + is_expected.to include("n° #{dossier.id}") + end + end + + context 'without preview dossier' do + it do + is_expected.to include("Mon titre pour --dossier_procedure_libelle--") + end + end + + context 'with logo label' do + it do + is_expected.to include("Ministère des devs") + is_expected.to match(/centered_marianne-\w+\.svg/) + end + end + + context 'with label direction' do + let(:attestation_template) { build(:attestation_template, :v2, label_direction: "calé à droite") } + + it do + is_expected.to include("calé à droite") + end + end + + context 'with footer' do + let(:attestation_template) { build(:attestation_template, :v2, footer: "c'est le pied") } + + it do + is_expected.to include("c'est le pied") + end + end + + context 'with additional logo' do + let(:attestation_template) { build(:attestation_template, :v2, logo:) } + + it do + is_expected.to include("Ministère des devs") + is_expected.to include("white.png") + end + end + + context 'with signature' do + let(:attestation_template) { build(:attestation_template, :v2, signature:) } + + it do + is_expected.to include("black.png") + end + end + end + end +end diff --git a/spec/factories/attestation_template.rb b/spec/factories/attestation_template.rb index a8b97432e..be4c39626 100644 --- a/spec/factories/attestation_template.rb +++ b/spec/factories/attestation_template.rb @@ -2,11 +2,46 @@ FactoryBot.define do factory :attestation_template do title { 'title' } body { 'body' } + json_body { nil } footer { 'footer' } activated { true } + version { 1 } + official_layout { true } + label_direction { nil } + label_logo { nil } association :procedure end + trait :v2 do + version { 2 } + body { nil } + title { nil } + label_logo { "Ministère des devs" } + + json_body do + { + "type" => "doc", + "content" => [ + { + "type" => "header", "content" => [ + { "type" => "headerColumn", "attrs" => { "textAlign" => "left" }, "content" => [{ "type" => "paragraph", "attrs" => { "textAlign" => "left" } }] }, + { "type" => "headerColumn", "attrs" => { "textAlign" => "left" }, "content" => [{ "type" => "paragraph", "attrs" => { "textAlign" => "left" } }] } + ] + }, + { "type" => "title", "attrs" => { "textAlign" => "center" }, "content" => [{ "text" => "Mon titre pour ", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_procedure_libelle", "label" => "libellé démarche" } }] }, + { "type" => "paragraph", "attrs" => { "textAlign" => "left" }, "content" => [{ "text" => "Dossier: n° ", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }, + { + "type" => "paragraph", + "content" => [ + { "text" => "Nom: ", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "individual_last_name", "label" => "prénom" } }, { "text" => " ", "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "individual_first_name", "label" => "nom" } }, { "text" => " ", "type" => "text" } + ] + } + ] + } + end + end + trait :with_files do logo { Rack::Test::UploadedFile.new('spec/fixtures/files/logo_test_procedure.png', 'image/png') } signature { Rack::Test::UploadedFile.new('spec/fixtures/files/logo_test_procedure.png', 'image/png') } diff --git a/spec/models/attestation_template_spec.rb b/spec/models/attestation_template_spec.rb index e47f596a7..638da8b4f 100644 --- a/spec/models/attestation_template_spec.rb +++ b/spec/models/attestation_template_spec.rb @@ -173,5 +173,16 @@ describe AttestationTemplate, type: :model do end end end + + context 'body v2' do + let(:attestation) { create(:attestation_template, :v2) } + let(:dossier) { create(:dossier, procedure: attestation.procedure, individual: build(:individual, nom: 'Doe', prenom: 'John')) } + + it do + body = attestation.render_attributes_for(dossier: dossier)[:body] + expect(body).to include("Mon titre pour #{dossier.procedure.libelle}") + expect(body).to include("Doe John") + end + end end end diff --git a/spec/models/concern/tags_substitution_concern_spec.rb b/spec/models/concern/tags_substitution_concern_spec.rb index 929982b0b..a696dcd58 100644 --- a/spec/models/concern/tags_substitution_concern_spec.rb +++ b/spec/models/concern/tags_substitution_concern_spec.rb @@ -32,6 +32,39 @@ describe TagsSubstitutionConcern, type: :model do end).new(procedure, state) end + describe 'tags_substitutions' do + let(:individual) { nil } + let(:etablissement) { create(:etablissement) } + let(:dossier) { create(:dossier, :en_construction, procedure:, individual:, etablissement:) } + let(:instructeur) { create(:instructeur) } + let(:tags) { Set.new(["dossier_number"]) } + + subject { template_concern.tags_substitutions(tags, dossier) } + + context 'dossiers metadata' do + before { travel_to(Time.zone.local(2024, 1, 15, 12)) } + let(:tags) { Set.new(["dossier_number", "dossier_depose_at", "dossier_processed_at", "dossier_procedure_libelle"]) } + + it do + is_expected.to eq( + "dossier_number" => dossier.id.to_s, + "dossier_depose_at" => "15/01/2024", + "dossier_processed_at" => "", + "dossier_procedure_libelle" => procedure.libelle + ) + end + end + + context 'when the dossier and the procedure has an individual' do + let(:for_individual) { true } + let(:individual) { Individual.create(nom: 'Adama', prenom: 'William', gender: 'M') } + + let(:tags) { Set.new(['individual_gender', 'individual_last_name']) } + + it { is_expected.to eq({ "individual_gender" => 'M', "individual_last_name" => "Adama" }) } + end + end + describe 'replace_tags' do let(:individual) { nil } let(:etablissement) { create(:etablissement) } diff --git a/spec/services/tiptap_service_spec.rb b/spec/services/tiptap_service_spec.rb index 37216b664..b9072048f 100644 --- a/spec/services/tiptap_service_spec.rb +++ b/spec/services/tiptap_service_spec.rb @@ -1,155 +1,156 @@ RSpec.describe TiptapService do + let(:json) do + { + type: 'doc', + content: [ + { + type: 'header', + content: [ + { + type: 'headerColumn', + content: [{ type: 'text', text: 'Left' }] + }, + { + type: 'headerColumn', + content: [{ type: 'text', text: 'Right' }] + } + ] + }, + { + type: 'title', + content: [{ type: 'text', text: 'Title' }] + }, + { + type: 'title' # remained empty in editor + }, + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Heading 1' }] + }, + { + type: 'heading', + attrs: { level: 2, textAlign: 'center' }, + content: [{ type: 'text', text: 'Heading 2' }] + }, + { + type: 'heading', + attrs: { level: 3 }, + content: [{ type: 'text', text: 'Heading 3' }] + }, + { + type: 'heading', + attrs: { level: 3 } # remained empty in editor + }, + { + type: 'paragraph', + attrs: { textAlign: 'right' }, + content: [{ type: 'text', text: 'First paragraph' }] + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Bonjour ', + marks: [{ type: 'italic' }, { type: 'strike' }] + }, + { + type: 'mention', + attrs: { id: 'name' }, + marks: [{ type: 'bold' }, { type: 'underline' }] + }, + { + type: 'text', + text: ' ' + }, + { + type: 'text', + text: '!', + marks: [{ type: 'highlight' }] + } + ] + }, + { + type: 'paragraph' + # no content, empty line + }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Item 1' + } + ] + } + ] + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Item 2' + } + ] + } + ] + } + ] + }, + { + type: 'orderedList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Item 1' + } + ] + } + ] + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Item 2' + } + ] + } + ] + } + ] + }, + { + type: 'footer', + content: [{ type: 'text', text: 'Footer' }] + } + ] + } + end + describe '.to_html' do - let(:json) do - { - type: 'doc', - content: [ - { - type: 'header', - content: [ - { - type: 'headerColumn', - content: [{ type: 'text', text: 'Left' }] - }, - { - type: 'headerColumn', - content: [{ type: 'text', text: 'Right' }] - } - ] - }, - { - type: 'title', - content: [{ type: 'text', text: 'Title' }] - }, - { - type: 'title' # remained empty in editor - }, - { - type: 'heading', - attrs: { level: 1 }, - content: [{ type: 'text', text: 'Heading 1' }] - }, - { - type: 'heading', - attrs: { level: 2, textAlign: 'center' }, - content: [{ type: 'text', text: 'Heading 2' }] - }, - { - type: 'heading', - attrs: { level: 3 }, - content: [{ type: 'text', text: 'Heading 3' }] - }, - { - type: 'heading', - attrs: { level: 3 } # remained empty in editor - }, - { - type: 'paragraph', - attrs: { textAlign: 'right' }, - content: [{ type: 'text', text: 'First paragraph' }] - }, - { - type: 'paragraph', - content: [ - { - type: 'text', - text: 'Bonjour ', - marks: [{ type: 'italic' }, { type: 'strike' }] - }, - { - type: 'mention', - attrs: { id: 'name' }, - marks: [{ type: 'bold' }, { type: 'underline' }] - }, - { - type: 'text', - text: ' ' - }, - { - type: 'text', - text: '!', - marks: [{ type: 'highlight' }] - } - ] - }, - { - type: 'paragraph' - # no content, empty line - }, - { - type: 'bulletList', - content: [ - { - type: 'listItem', - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: 'Item 1' - } - ] - } - ] - }, - { - type: 'listItem', - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: 'Item 2' - } - ] - } - ] - } - ] - }, - { - type: 'orderedList', - content: [ - { - type: 'listItem', - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: 'Item 1' - } - ] - } - ] - }, - { - type: 'listItem', - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: 'Item 2' - } - ] - } - ] - } - ] - }, - { - type: 'footer', - content: [{ type: 'text', text: 'Footer' }] - } - ] - } - end - let(:tags) { { 'name' => 'Paul' } } + let(:substitutions) { { 'name' => 'Paul' } } let(:html) do [ '
    Left
    Right
    ', @@ -166,7 +167,13 @@ RSpec.describe TiptapService do end it 'returns html' do - expect(described_class.new.to_html(json, tags)).to eq(html) + expect(described_class.new.to_html(json, substitutions)).to eq(html) + end + end + + describe '#used_tags' do + it 'returns used tags' do + expect(described_class.new.used_tags(json)).to eq(Set.new(['name'])) end end end