Skip to content

Commit e609af7

Browse files
feat: add FormattedJobDescription component and integrate it into JobDetailModal
1 parent c494421 commit e609af7

3 files changed

Lines changed: 187 additions & 5 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { Fragment, type ReactNode } from "react";
2+
3+
interface FormattedJobDescriptionProps {
4+
description: string;
5+
}
6+
7+
const discardedElements = new Set([
8+
"button",
9+
"canvas",
10+
"embed",
11+
"form",
12+
"iframe",
13+
"img",
14+
"input",
15+
"link",
16+
"meta",
17+
"noscript",
18+
"object",
19+
"script",
20+
"style",
21+
"svg",
22+
]);
23+
24+
function decodeEncodedMarkup(value: string) {
25+
let decoded = value;
26+
27+
for (
28+
let attempt = 0;
29+
attempt < 3 && /&(?:amp;)?(?:lt|gt);/i.test(decoded);
30+
attempt += 1
31+
) {
32+
const document = new DOMParser().parseFromString(decoded, "text/html");
33+
const nextValue = document.body.textContent ?? decoded;
34+
35+
if (nextValue === decoded) break;
36+
decoded = nextValue;
37+
}
38+
39+
return decoded;
40+
}
41+
42+
function safeExternalUrl(value: string | null) {
43+
if (!value) return null;
44+
45+
try {
46+
const url = new URL(value, window.location.origin);
47+
return url.protocol === "http:" || url.protocol === "https:"
48+
? url.href
49+
: null;
50+
} catch {
51+
return null;
52+
}
53+
}
54+
55+
function renderNode(node: Node, key: string): ReactNode {
56+
if (node.nodeType === Node.TEXT_NODE) {
57+
return node.textContent;
58+
}
59+
60+
if (node.nodeType !== Node.ELEMENT_NODE) {
61+
return null;
62+
}
63+
64+
const element = node as HTMLElement;
65+
const tag = element.tagName.toLowerCase();
66+
67+
if (discardedElements.has(tag)) {
68+
return null;
69+
}
70+
71+
const children = Array.from(element.childNodes).map((child, index) =>
72+
renderNode(child, `${key}-${index}`),
73+
);
74+
75+
switch (tag) {
76+
case "a": {
77+
const href = safeExternalUrl(element.getAttribute("href"));
78+
79+
return href ? (
80+
<a key={key} href={href} target="_blank" rel="noreferrer nofollow">
81+
{children}
82+
</a>
83+
) : (
84+
<span key={key}>{children}</span>
85+
);
86+
}
87+
case "br":
88+
return <br key={key} />;
89+
case "strong":
90+
case "b":
91+
return <strong key={key}>{children}</strong>;
92+
case "em":
93+
case "i":
94+
return <em key={key}>{children}</em>;
95+
case "h1":
96+
return <h1 key={key}>{children}</h1>;
97+
case "h2":
98+
return <h2 key={key}>{children}</h2>;
99+
case "h3":
100+
return <h3 key={key}>{children}</h3>;
101+
case "h4":
102+
case "h5":
103+
case "h6":
104+
return <h4 key={key}>{children}</h4>;
105+
case "ul":
106+
return <ul key={key}>{children}</ul>;
107+
case "ol":
108+
return <ol key={key}>{children}</ol>;
109+
case "li":
110+
return <li key={key}>{children}</li>;
111+
case "p":
112+
return <p key={key}>{children}</p>;
113+
case "blockquote":
114+
return <blockquote key={key}>{children}</blockquote>;
115+
case "code":
116+
return <code key={key}>{children}</code>;
117+
case "pre":
118+
return <pre key={key}>{children}</pre>;
119+
case "hr":
120+
return <hr key={key} />;
121+
case "div":
122+
return <div key={key}>{children}</div>;
123+
default:
124+
return <Fragment key={key}>{children}</Fragment>;
125+
}
126+
}
127+
128+
export function FormattedJobDescription({
129+
description,
130+
}: FormattedJobDescriptionProps) {
131+
const decodedDescription = decodeEncodedMarkup(description);
132+
const document = new DOMParser().parseFromString(
133+
decodedDescription,
134+
"text/html",
135+
);
136+
const hasMarkup = /<\/?[a-z][\s\S]*>/i.test(decodedDescription);
137+
138+
return (
139+
<div className="max-h-80 overflow-y-auto rounded-md border border-border bg-background p-4 text-sm leading-6 text-muted-foreground [&_a]:font-semibold [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-bold [&_h2]:mb-3 [&_h2]:text-lg [&_h2]:font-bold [&_h3]:mb-2 [&_h3]:mt-5 [&_h3]:text-base [&_h3]:font-bold [&_h4]:mb-2 [&_h4]:mt-4 [&_h4]:font-bold [&_hr]:my-4 [&_li]:mb-1 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:mb-3 [&_pre]:overflow-x-auto [&_pre]:whitespace-pre-wrap [&_strong]:font-bold [&_strong]:text-foreground [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-5">
140+
{hasMarkup ? (
141+
Array.from(document.body.childNodes).map((node, index) =>
142+
renderNode(node, String(index)),
143+
)
144+
) : (
145+
<p className="whitespace-pre-wrap">
146+
{document.body.textContent ?? decodedDescription}
147+
</p>
148+
)}
149+
</div>
150+
);
151+
}

frontend/src/domains/new_dashboard/components/jobs/JobDetailModal.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ExternalLink } from "lucide-react";
22
import { jobStatusClasses, jobStatuses } from "../../constants";
33
import type { Job, JobStatus } from "../../types";
44
import { Modal } from "../shared/Modal";
5+
import { FormattedJobDescription } from "./FormattedJobDescription";
56

67
interface JobDetailModalProps {
78
job: Job;
@@ -49,7 +50,9 @@ export function JobDetailModal({
4950
onStatusChange,
5051
onNotesChange,
5152
}: JobDetailModalProps) {
52-
const payloadEntries = Object.entries(job.rawPayload ?? {});
53+
const payloadEntries = Object.entries(job.rawPayload ?? {}).filter(
54+
([key]) => key !== "description",
55+
);
5356
const description = payloadValueToText(job.rawPayload?.description);
5457
const hasDescription = description !== "Não informado";
5558

@@ -131,9 +134,7 @@ export function JobDetailModal({
131134
<span className="text-xs font-bold uppercase text-muted-foreground">
132135
Descrição
133136
</span>
134-
<p className="max-h-48 overflow-y-auto whitespace-pre-wrap rounded-md border border-border bg-background p-3 text-sm leading-6 text-muted-foreground">
135-
{description}
136-
</p>
137+
<FormattedJobDescription description={description} />
137138
</div>
138139
) : null}
139140

frontend/tests/unit/new_dashboard/jobs.test.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,37 @@ describe("new_dashboard job components", () => {
315315

316316
expect(screen.getByText("LinkedIn, Gupy")).toBeInTheDocument();
317317
expect(screen.getByText(/seniority/i)).toBeInTheDocument();
318-
expect(screen.getAllByText(/detalhes avançados da vaga/i)).toHaveLength(2);
318+
expect(screen.getByText(/detalhes avançados da vaga/i)).toBeInTheDocument();
319+
});
320+
321+
it("formata HTML codificado e descarta conteúdo inseguro da descrição", () => {
322+
const { container } = render(
323+
<JobDetailModal
324+
job={{
325+
...baseJob,
326+
rawPayload: {
327+
description:
328+
"&lt;h3&gt;Sobre a vaga&lt;/h3&gt;&lt;p&gt;Crie produtos com &lt;strong&gt;React&lt;/strong&gt;.&lt;/p&gt;&lt;ul&gt;&lt;li&gt;TypeScript&lt;/li&gt;&lt;/ul&gt;&lt;a href=&quot;https://example.com/details&quot;&gt;Saiba mais&lt;/a&gt;&lt;script&gt;alert('xss')&lt;/script&gt;",
329+
},
330+
}}
331+
onClose={vi.fn()}
332+
onStatusChange={vi.fn()}
333+
onNotesChange={vi.fn()}
334+
/>,
335+
);
336+
337+
expect(
338+
screen.getByRole("heading", { name: "Sobre a vaga" }),
339+
).toBeInTheDocument();
340+
expect(container.querySelector("strong")).toHaveTextContent("React");
341+
expect(screen.getByRole("listitem")).toHaveTextContent("TypeScript");
342+
expect(screen.getByRole("link", { name: "Saiba mais" })).toHaveAttribute(
343+
"href",
344+
"https://example.com/details",
345+
);
346+
expect(container.querySelector("script")).not.toBeInTheDocument();
347+
expect(screen.queryByText(/alert\('xss'\)/i)).not.toBeInTheDocument();
348+
expect(screen.queryByText(/&lt;h3&gt;/i)).not.toBeInTheDocument();
319349
});
320350

321351
it("valida e salva uma vaga manual nova", () => {

0 commit comments

Comments
 (0)