feat(documents): editor de texto rico (Tiptap) para templates de documento
Substitui o textarea de texto puro por um editor visual real, seguindo o
print de referência do usuário do editor "Contrato de Software" do
OrçaFácil legado: toolbar completa (negrito/itálico/sublinhado/tachado,
alinhamento, listas, tabela, imagem, quebra de página), variáveis inseridas
como chip inline via catálogo fechado (nunca {{texto livre}}), painel
"Variáveis usadas neste modelo" (com badge obrigatório ao vivo) e
"Histórico de versões".
Backend: editor-content-renderer.ts e merge-engine.ts portados/reescritos
a partir do renderEditorContent.js/variableRegistry.js reais do legado
(achados em .tmp/orcafacil-reference) — resolve a árvore Tiptap
diretamente em vez de regex sobre HTML cacheado. Novo catálogo de
variáveis + GET /documents/variable-catalog.
Fora de escopo desta leva (documentado em docs/progress.md): nó
`conditional`, repetição de linha de tabela, bloqueio por variável
obrigatória ausente, e auto-resolução de dados reais do cadastro na hora
de gerar o documento.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,7 +48,18 @@ describe("documents — PDF generation + download", () => {
|
||||
.patch(`/api/v1/documents/templates/${templateId}/draft`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({
|
||||
editorContent: { type: "doc", plainText: "Revenda: {{revenda.nome}}" },
|
||||
editorContent: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "Revenda: " },
|
||||
{ type: "mergeField", attrs: { key: "revenda.nome", label: "Nome da Revenda" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
cssContent: "p { color: red; }",
|
||||
})
|
||||
.expect(200);
|
||||
@@ -146,7 +157,21 @@ describe("documents — PDF generation + download", () => {
|
||||
.post(`/api/v1/documents/templates/${templateId}/draft/preview`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({
|
||||
editorContent: { plainText: "Olá {{cliente.nome}}, plano {{plano}}." },
|
||||
editorContent: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "Olá " },
|
||||
{ type: "mergeField", attrs: { key: "cliente.nome", label: "Nome do Cliente" } },
|
||||
{ type: "text", text: ", plano " },
|
||||
{ type: "mergeField", attrs: { key: "plano", label: "Plano" } },
|
||||
{ type: "text", text: "." },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
cssContent: "p { color: blue; }",
|
||||
})
|
||||
.expect(201);
|
||||
@@ -158,7 +183,7 @@ describe("documents — PDF generation + download", () => {
|
||||
const call = renderPdfMock.mock.calls[0]![0];
|
||||
expect(call.html).toContain("[cliente.nome]");
|
||||
expect(call.html).toContain("[plano]");
|
||||
expect(call.html).not.toContain("{{");
|
||||
expect(call.html).not.toContain("merge-field-chip");
|
||||
expect(call.css).toBe("p { color: blue; }");
|
||||
|
||||
// Never persisted: no S3 upload, and the real (published) generation
|
||||
|
||||
171
apps/api/test/editor-content-renderer.spec.ts
Normal file
171
apps/api/test/editor-content-renderer.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectMergeFieldKeys, renderToHtml } from "../src/documents/editor-content-renderer";
|
||||
|
||||
/**
|
||||
* Unidade pura, sem app/DB — cobre a porta do serializador Tiptap→HTML do
|
||||
* legado (renderEditorContent.js). documents-pdf.spec.ts cobre o pipeline
|
||||
* completo (geração real via HTTP); aqui é só a árvore de nós, isolada.
|
||||
*/
|
||||
describe("editor-content-renderer", () => {
|
||||
it("renderiza parágrafo com alinhamento e marks (bold/italic/underline/strike/fontSize)", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { textAlign: "center" },
|
||||
content: [
|
||||
{ type: "text", text: "Olá ", marks: [{ type: "bold" }] },
|
||||
{ type: "text", text: "mundo", marks: [{ type: "italic" }, { type: "underline" }, { type: "strike" }] },
|
||||
{ type: "text", text: " grande", marks: [{ type: "textStyle", attrs: { fontSize: "18px" } }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const html = renderToHtml(doc);
|
||||
expect(html).toBe(
|
||||
'<p style="text-align:center"><strong>Olá </strong><s><u><em>mundo</em></u></s><span style="font-size:18px"> grande</span></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it("renderiza heading, listas e parágrafo vazio como ", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Título" }] },
|
||||
{ type: "paragraph", content: [] },
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "item 1" }] }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "orderedList",
|
||||
content: [
|
||||
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "item A" }] }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const html = renderToHtml(doc);
|
||||
expect(html).toContain("<h2>Título</h2>");
|
||||
expect(html).toContain("<p> </p>");
|
||||
expect(html).toContain("<ul><li><p>item 1</p></li></ul>");
|
||||
expect(html).toContain("<ol><li><p>item A</p></li></ol>");
|
||||
});
|
||||
|
||||
it("renderiza imagem e quebra de página", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "image", attrs: { src: "data:image/png;base64,abc", alt: "logo" } },
|
||||
{ type: "pageBreak" },
|
||||
],
|
||||
};
|
||||
|
||||
const html = renderToHtml(doc);
|
||||
expect(html).toContain('<img src="data:image/png;base64,abc" alt="logo" />');
|
||||
expect(html).toContain('style="break-after:page;page-break-after:always;"');
|
||||
});
|
||||
|
||||
it("gera <thead> real só quando há tableHeader, mesmo no meio das linhas", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [{ type: "tableHeader", attrs: { colspan: 2 }, content: [{ type: "paragraph", content: [] }] }],
|
||||
},
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const html = renderToHtml(doc);
|
||||
expect(html).toMatch(/<thead>.*<th colspan="2">/s);
|
||||
expect(html).toMatch(/<tbody>.*<td>/s);
|
||||
});
|
||||
|
||||
it("mergeField vira chip visual sem resolveMergeField, e valor resolvido com resolveMergeField", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "mergeField", attrs: { key: "cliente.razao_social", label: "Razão Social" } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const chipHtml = renderToHtml(doc);
|
||||
expect(chipHtml).toContain('data-merge-field="cliente.razao_social"');
|
||||
expect(chipHtml).toContain("Razão Social");
|
||||
|
||||
const resolvedHtml = renderToHtml(doc, (key) => (key === "cliente.razao_social" ? "ACME LTDA" : ""));
|
||||
expect(resolvedHtml).toBe("<p>ACME LTDA</p>");
|
||||
expect(resolvedHtml).not.toContain("merge-field-chip");
|
||||
});
|
||||
|
||||
it("escapa HTML de texto e de valores resolvidos (proteção contra XSS)", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "<script>alert(1)</script>" }] },
|
||||
{ type: "paragraph", content: [{ type: "mergeField", attrs: { key: "x" } }] },
|
||||
],
|
||||
};
|
||||
|
||||
const html = renderToHtml(doc, () => "<img onerror=alert(1)>");
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
expect(html).not.toContain("<img onerror");
|
||||
expect(html).toContain("<img onerror=alert(1)>");
|
||||
});
|
||||
|
||||
it("collectMergeFieldKeys varre a árvore inteira e dedupe chaves repetidas", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "mergeField", attrs: { key: "cliente.razao_social" } },
|
||||
{ type: "mergeField", attrs: { key: "cliente.cnpj" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{
|
||||
type: "tableCell",
|
||||
content: [{ type: "paragraph", content: [{ type: "mergeField", attrs: { key: "cliente.razao_social" } }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(collectMergeFieldKeys(doc)).toEqual(["cliente.razao_social", "cliente.cnpj"]);
|
||||
});
|
||||
|
||||
it("devolve string vazia para conteúdo que não é um doc Tiptap válido", () => {
|
||||
expect(renderToHtml(null)).toBe("");
|
||||
expect(renderToHtml({ type: "doc" })).toBe("");
|
||||
expect(collectMergeFieldKeys(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,9 @@ describe("signature envelopes — automatic contratante signer derivation", () =
|
||||
await agent(app)
|
||||
.patch(`/api/v1/documents/templates/${templateId}/draft`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ editorContent: { type: "doc", plainText: "Contrato de teste." } })
|
||||
.send({
|
||||
editorContent: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Contrato de teste." }] }] },
|
||||
})
|
||||
.expect(200);
|
||||
await agent(app).post(`/api/v1/documents/templates/${templateId}/publish`).set(...bearer(admin.accessToken)).expect(201);
|
||||
const generation = await agent(app)
|
||||
|
||||
@@ -118,7 +118,14 @@ describe("signature envelopes — full lifecycle", () => {
|
||||
await agent(app)
|
||||
.patch(`/api/v1/documents/templates/${templateId}/draft`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ editorContent: { type: "doc", plainText: "Contrato de teste para assinatura eletrônica." } })
|
||||
.send({
|
||||
editorContent: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Contrato de teste para assinatura eletrônica." }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
await agent(app)
|
||||
.post(`/api/v1/documents/templates/${templateId}/publish`)
|
||||
|
||||
Reference in New Issue
Block a user