feat(attestation): replace tags in preview for v2

This commit is contained in:
Colin Darie 2024-01-18 12:38:46 +01:00
parent d4c4b3a212
commit 40353fee04
No known key found for this signature in database
GPG key ID: 8C76CADD40253590
12 changed files with 452 additions and 196 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:
"<header>#{children(content, tags, level + 1)}</header>"
"<header>#{children(content, substitutions, level + 1)}</header>"
in type: 'footer', content:, **rest
"<footer#{text_align(rest[:attrs])}>#{children(content, tags, level + 1)}</footer>"
"<footer#{text_align(rest[:attrs])}>#{children(content, substitutions, level + 1)}</footer>"
in type: 'headerColumn', content:, **rest
"<div#{text_align(rest[:attrs])}>#{children(content, tags, level + 1)}</div>"
"<div#{text_align(rest[:attrs])}>#{children(content, substitutions, level + 1)}</div>"
in type: 'paragraph', content:, **rest
"<p#{body_start_mark}#{text_align(rest[:attrs])}>#{children(content, tags, level + 1)}</p>"
"<p#{body_start_mark}#{text_align(rest[:attrs])}>#{children(content, substitutions, level + 1)}</p>"
in type: 'title', content:, **rest
"<h1#{text_align(rest[:attrs])}>#{children(content, tags, level + 1)}</h1>"
"<h1#{text_align(rest[:attrs])}>#{children(content, substitutions, level + 1)}</h1>"
in type: 'heading', attrs: { level: hlevel, **attrs }, content:
"<h#{hlevel}#{text_align(attrs)}>#{children(content, tags, level + 1)}</h#{hlevel}>"
"<h#{hlevel}#{text_align(attrs)}>#{children(content, substitutions, level + 1)}</h#{hlevel}>"
in type: 'bulletList', content:
"<ul>#{children(content, tags, level + 1)}</ul>"
"<ul>#{children(content, substitutions, level + 1)}</ul>"
in type: 'orderedList', content:
"<ol>#{children(content, tags, level + 1)}</ol>"
"<ol>#{children(content, substitutions, level + 1)}</ol>"
in type: 'listItem', content:
"<li>#{children(content, tags, level + 1)}</li>"
"<li>#{children(content, substitutions, level + 1)}</li>"
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

View file

@ -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?

View file

@ -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|

View file

@ -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("#{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

View file

@ -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') }

View file

@ -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

View file

@ -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) }

View file

@ -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
[
'<header><div>Left</div><div>Right</div></header>',
@ -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