Skip to content

Commit 6b6df4a

Browse files
authored
Pedro/melhorias (#199)
## Contexto Este PR melhora a experiência da listagem de vagas, especialmente em dispositivos móveis, além de adicionar informações e controles para facilitar a análise das oportunidades. <img width="1369" height="839" alt="image" src="https://github.com/user-attachments/assets/ddd31f39-3924-4a56-846a-df3bfba15b4e" /> <img width="547" height="874" alt="image" src="https://github.com/user-attachments/assets/feb26278-3a92-46ec-a697-5da8d4896b18" /> <img width="549" height="848" alt="image" src="https://github.com/user-attachments/assets/7e99d213-8609-4efe-95d2-46f406de07ad" /> <img width="775" height="833" alt="image" src="https://github.com/user-attachments/assets/df222d88-f331-4306-8fc0-6d0567b71ae6" /> ## Principais alterações ### Listagem de vagas - Adiciona a coluna **Publicada em**, utilizando a data de publicação da própria vaga. - Exibe **Não informado** quando a fonte não fornece essa data. - Adiciona ordenação nas colunas: - Fonte - Nível - Publicada em - Ajusta os cabeçalhos para sentence case, removendo o `uppercase`. - Altera o título da primeira coluna para **Cargo / empresa**. - Corrige a busca inicial para respeitar as preferências de modelo de trabalho do usuário. ### Responsividade - Adiciona uma tabbar inferior para navegação em dispositivos móveis. - Mantém a sidebar apenas em telas desktop. - Destaca automaticamente a aba correspondente à página atual. - Adiciona suporte à safe area de dispositivos móveis. - Compacta o header em telas menores. - Mantém Perfil, Ajuda e Sair disponíveis pelo menu do usuário. - Ajusta o grid de filtros para não ultrapassar a largura da tela: - 1 coluna no celular - 2 colunas em telas pequenas - 3 colunas em notebooks - Até 6 colunas em telas maiores ### Detalhes da vaga - Adiciona formatação para descrições recebidas como HTML codificado. - Preserva elementos como: - Títulos - Parágrafos - Listas - Negrito e itálico - Links externos - Remove scripts, imagens e outros elementos potencialmente inseguros. - Evita a repetição da descrição na seção de payload da vaga. - Mantém compatibilidade com descrições em texto simples. ## Testes realizados - [x] Testes unitários dos componentes de vagas - [x] Testes dos componentes de layout - [x] Testes do adaptador da API - [x] Validação de descrições HTML codificadas - [x] Validação contra conteúdo HTML inseguro - [x] ESLint - [x] TypeScript dos arquivos alterados - [x] Build de produção do frontend ## Como validar 1. Acessar a página de vagas em uma resolução mobile. 2. Confirmar que a sidebar foi substituída pela tabbar inferior. 3. Redimensionar a tela e verificar a quebra dos filtros em novas linhas. 4. Ordenar a tabela por Fonte, Nível e Publicada em. 5. Abrir uma vaga que possua descrição em HTML. 6. Confirmar que títulos, parágrafos, listas e links são apresentados formatados. 7. Confirmar que nenhuma tag HTML aparece como texto na descrição.
2 parents 605c188 + e609af7 commit 6b6df4a

14 files changed

Lines changed: 641 additions & 51 deletions

File tree

frontend/src/domains/new_dashboard/NewDashboardPage.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AddJobModal } from "./components/jobs/AddJobModal";
88
import { JobDetailModal } from "./components/jobs/JobDetailModal";
99
import { JobTab } from "./components/jobs/JobTab";
1010
import { Header } from "./components/layout/Header";
11+
import { MobileTabBar } from "./components/layout/MobileTabBar";
1112
import { Sidebar } from "./components/layout/Sidebar";
1213
import { MentoringTab } from "./components/mentoring/MentoringTab";
1314
import { ProfileTab } from "./components/profile/ProfileTab";
@@ -180,6 +181,20 @@ export default function NewDashboardPage() {
180181
saveUserProfile,
181182
saveSearchPreferences,
182183
} = useUserDashboardData(user, { onError: showToast });
184+
const preferredModelFilter = useMemo(
185+
() => getModelFilterFromJobTypes(searchPreferences.jobTypes),
186+
[searchPreferences.jobTypes],
187+
);
188+
const initialRecommendationSearch = useMemo(
189+
() =>
190+
isLoadingUserData
191+
? null
192+
: {
193+
keywords: [],
194+
filters: modelFilterToApiFilter(preferredModelFilter),
195+
},
196+
[isLoadingUserData, preferredModelFilter],
197+
);
183198
const {
184199
trackedJobs,
185200
recommendedJobs,
@@ -190,7 +205,10 @@ export default function NewDashboardPage() {
190205
changeJobStatus,
191206
changeJobNotesLocally,
192207
saveJobNotes,
193-
} = useDashboardJobs(user, { onError: showToast });
208+
} = useDashboardJobs(user, {
209+
onError: showToast,
210+
initialRecommendationSearch,
211+
});
194212

195213
const matchedTrackedJobs = useMemo(
196214
() =>
@@ -213,10 +231,6 @@ export default function NewDashboardPage() {
213231
),
214232
[filterType, matchedRecommendedJobs],
215233
);
216-
const preferredModelFilter = useMemo(
217-
() => getModelFilterFromJobTypes(searchPreferences.jobTypes),
218-
[searchPreferences.jobTypes],
219-
);
220234
const showPreferenceNotice =
221235
!hasUserChangedJobFilters &&
222236
preferredModelFilter !== "Todos" &&
@@ -527,11 +541,13 @@ export default function NewDashboardPage() {
527541
<div className="flex min-w-0 flex-1 flex-col">
528542
<Header userProfile={userProfile} />
529543

530-
<main className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden bg-background">
544+
<main className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden bg-background pb-[calc(4.5rem+env(safe-area-inset-bottom))] lg:pb-0">
531545
{renderContent()}
532546
</main>
533547
</div>
534548

549+
<MobileTabBar />
550+
535551
{selectedJob ? (
536552
<JobDetailModal
537553
job={selectedJob}
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/src/domains/new_dashboard/components/jobs/JobFilter.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ export function JobFilter({
3939
setMatchSort,
4040
}: JobFilterProps) {
4141
return (
42-
<div className="grid gap-4 rounded-2xl border border-border bg-card p-4 md:grid-cols-[minmax(280px,1fr)_168px_168px_180px_180px_180px]">
43-
<label className="relative block">
42+
<div className="grid min-w-0 grid-cols-1 gap-4 rounded-2xl border border-border bg-card p-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-[minmax(280px,1fr)_repeat(5,minmax(0,180px))]">
43+
<label className="relative block min-w-0">
4444
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
4545
<input
4646
value={searchQuery}
@@ -55,7 +55,7 @@ export function JobFilter({
5555
onChange={(event) =>
5656
setFilterType(event.target.value as JobModelFilter)
5757
}
58-
className="h-11 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring"
58+
className="h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring"
5959
>
6060
<option value="Todos">Modelo (Todos)</option>
6161
{jobModelFilterOptions
@@ -70,7 +70,7 @@ export function JobFilter({
7070
<select
7171
value={filterLevel}
7272
onChange={(event) => setFilterLevel(event.target.value)}
73-
className="h-11 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring"
73+
className="h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring"
7474
>
7575
<option value="Todos">Sênioridade (Todos)</option>
7676
{jobLevels.map((level) => (
@@ -83,7 +83,7 @@ export function JobFilter({
8383
onChange={(event) =>
8484
setContinentFilter(event.target.value as ContinentFilter)
8585
}
86-
className="h-11 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring"
86+
className="h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring"
8787
>
8888
{continentOptions.map((continent) => (
8989
<option key={continent} value={continent}>
@@ -97,7 +97,7 @@ export function JobFilter({
9797
onChange={(event) =>
9898
setCountryFilter(event.target.value as CountryFilter)
9999
}
100-
className="h-11 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring"
100+
className="h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring"
101101
>
102102
{countryOptions.map((country) => (
103103
<option key={country} value={country}>
@@ -109,7 +109,7 @@ export function JobFilter({
109109
<select
110110
value={matchSort}
111111
onChange={(event) => setMatchSort(event.target.value as MatchSort)}
112-
className="h-11 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring"
112+
className="h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring"
113113
>
114114
<option value="default">Match (padrão)</option>
115115
<option value="desc">Maior match</option>

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ export function JobRow({ job, onOpen, onStatusChange }: JobRowProps) {
6363
<td className="px-4 py-5 align-middle text-sm text-muted-foreground">
6464
{job.level}
6565
</td>
66+
<td className="whitespace-nowrap px-4 py-5 align-middle text-sm text-muted-foreground">
67+
{job.posted}
68+
</td>
6669
<td className="px-4 py-5 align-middle">
6770
<span
6871
className="rounded-full bg-emerald-100 px-2 py-1 text-[11px] font-bold text-emerald-700"

0 commit comments

Comments
 (0)