Skip to content

Commit ece394a

Browse files
committed
feat: implement pagination for job listings and enhance UI components
1 parent 89c1cd5 commit ece394a

8 files changed

Lines changed: 197 additions & 83 deletions

File tree

Vagas Full Setup 1.0.0.exe

-88.8 MB
Binary file not shown.

backend/src/server.js

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ import express from "express";
33
import { existsSync } from "fs";
44
import path from "path";
55
import { createJobsApiApp } from "./jobsApiApp.js";
6-
import swaggerUi from "swagger-ui-express";
7-
import swaggerSpec from "./swagger.js";
86

97
const PORT = Number(process.env.PORT || 3001);
108

@@ -16,28 +14,45 @@ const outputDir = process.env.ELECTRON_OUTPUT_DIR
1614

1715
const app = createJobsApiApp({ outputDir });
1816

19-
// ── Electron static-file serving ───────────────────────────────────────────
20-
// When ELECTRON_STATIC_DIR is set, serve the React production build so that
21-
// the Electron window can load everything from http://localhost:<PORT>.
22-
const staticDir = process.env.ELECTRON_STATIC_DIR;
23-
if (staticDir && existsSync(staticDir)) {
24-
app.use(express.static(staticDir));
25-
26-
// SPA catch-all: return index.html for any non-API route so that React
27-
// Router (if used in future) works correctly.
28-
app.use((req, res) => {
29-
if (req.path.startsWith("/api")) {
30-
return res.status(404).json({ error: "Rota não encontrada." });
31-
}
32-
res.sendFile(path.join(staticDir, "index.html"));
33-
});
17+
async function registerSwaggerDocs() {
18+
try {
19+
const [{ default: swaggerUi }, { default: swaggerSpec }] = await Promise.all([
20+
import("swagger-ui-express"),
21+
import("./swagger.js"),
22+
]);
23+
24+
app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
25+
} catch (error) {
26+
// eslint-disable-next-line no-console
27+
console.warn("Swagger desabilitado:", error instanceof Error ? error.message : error);
28+
}
3429
}
35-
//Swagger documentacao
36-
app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
3730

38-
app.listen(PORT, () => {
39-
// eslint-disable-next-line no-console
40-
console.log(`API de vagas rodando em http://localhost:${PORT}`);
41-
console.log(`Documentação da API em http://localhost:${PORT}/docs`);
31+
async function startServer() {
32+
await registerSwaggerDocs();
33+
34+
// ── Electron static-file serving ─────────────────────────────────────────
35+
// When ELECTRON_STATIC_DIR is set, serve the React production build so that
36+
// the Electron window can load everything from http://localhost:<PORT>.
37+
const staticDir = process.env.ELECTRON_STATIC_DIR;
38+
if (staticDir && existsSync(staticDir)) {
39+
app.use(express.static(staticDir));
40+
41+
// SPA catch-all: return index.html for any non-API route so that React
42+
// Router (if used in future) works correctly.
43+
app.use((req, res) => {
44+
if (req.path.startsWith("/api")) {
45+
return res.status(404).json({ error: "Rota não encontrada." });
46+
}
47+
res.sendFile(path.join(staticDir, "index.html"));
48+
});
49+
}
50+
51+
app.listen(PORT, () => {
52+
// eslint-disable-next-line no-console
53+
console.log(`API de vagas rodando em http://localhost:${PORT}`);
54+
console.log(`Documentação da API em http://localhost:${PORT}/docs`);
55+
});
56+
}
4257

43-
});
58+
void startServer();

frontend/src/components/JobsTableCard.tsx

Lines changed: 105 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { Badge } from "@/components/ui/badge";
2-
import { Button } from "@/components/ui/button";
32
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
43
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
54
import { cn } from "@/lib/utils";
65
import type { Job, JobsMeta } from "@/types/jobs";
76
import { useState } from "react";
8-
import { FiCheckSquare, FiSquare } from "react-icons/fi";
7+
import { FiCheckSquare, FiChevronLeft, FiChevronRight, FiSquare } from "react-icons/fi";
98

109
interface JobsTableCardProps {
1110
meta: JobsMeta;
@@ -29,7 +28,27 @@ type StatusToggleButtonProps = {
2928
onClick: () => void;
3029
};
3130

32-
const PAGE_SIZE_OPTIONS = [15, 25, 50, 100];
31+
type PaginationItem = number | string;
32+
33+
function getResultsLabel(total: number) {
34+
return `Resultados: ${total} ${total === 1 ? "vaga encontrada" : "vagas encontradas"}`;
35+
}
36+
37+
function getPaginationItems(currentPage: number, totalPages: number): PaginationItem[] {
38+
if (totalPages <= 5) {
39+
return Array.from({ length: totalPages }, (_, index) => index + 1);
40+
}
41+
42+
if (currentPage <= 3) {
43+
return [1, 2, 3, 4, 5];
44+
}
45+
46+
if (currentPage >= totalPages - 2) {
47+
return Array.from({ length: 5 }, (_, index) => totalPages - 4 + index);
48+
}
49+
50+
return [1, "ellipsis-start", currentPage - 1, currentPage, currentPage + 1, "ellipsis-end", totalPages];
51+
}
3352

3453
function getJobId(job: Job, index: number) {
3554
return job.link?.trim() || [job.titulo, job.empresa, job.local, String(index)].filter(Boolean).join("|");
@@ -79,14 +98,14 @@ export function JobsTableCard({
7998
}: JobsTableCardProps) {
8099
const [spamMarks, setSpamMarks] = useState<Record<string, boolean>>({});
81100
const [readMarks, setReadMarks] = useState<Record<string, boolean>>({});
101+
const resultsLabel = getResultsLabel(filteredJobs.length);
102+
const paginationItems = getPaginationItems(currentPage, totalPages);
82103

83104
return (
84105
<Card className="mb-4 border-border/70 bg-card/90 backdrop-blur dark:bg-card/95">
85106
<CardHeader>
86107
<CardTitle className="text-lg">Vagas Encontradas</CardTitle>
87-
<CardDescription>
88-
Atualizado em {formatDate(meta.modifiedAt)}. Mostrando {filteredJobs.length} de {jobs.length} vagas.
89-
</CardDescription>
108+
<CardDescription>Lista paginada com os resultados mais recentes das buscas.</CardDescription>
90109
</CardHeader>
91110
<CardContent>
92111
{error ? (
@@ -95,53 +114,14 @@ export function JobsTableCard({
95114
</div>
96115
) : null}
97116

98-
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
99-
<p className="text-sm text-muted-foreground">
100-
Pagina {currentPage} de {totalPages}
101-
</p>
102-
<div className="flex items-center gap-2">
103-
<label className="text-sm text-muted-foreground" htmlFor="page-size">
104-
Itens por pagina
105-
</label>
106-
<select
107-
id="page-size"
108-
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
109-
value={pageSize}
110-
onChange={(event) => onPageSizeChange(Number(event.target.value))}
111-
>
112-
{PAGE_SIZE_OPTIONS.map((option) => (
113-
<option key={option} value={option}>
114-
{option}
115-
</option>
116-
))}
117-
</select>
118-
<Button
119-
variant="outline"
120-
size="sm"
121-
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
122-
disabled={currentPage <= 1}
123-
>
124-
Anterior
125-
</Button>
126-
<Button
127-
variant="outline"
128-
size="sm"
129-
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
130-
disabled={currentPage >= totalPages}
131-
>
132-
Proxima
133-
</Button>
134-
</div>
135-
</div>
136-
137117
<Table>
138118
<TableHeader>
139119
<TableRow>
140-
<TableHead>Titulo</TableHead>
120+
<TableHead>Título</TableHead>
141121
<TableHead>Empresa</TableHead>
142122
<TableHead>Local</TableHead>
143123
<TableHead>Link</TableHead>
144-
<TableHead>Palavras chaves</TableHead>
124+
<TableHead>Palavras-chave</TableHead>
145125
<TableHead className="text-center text-[11px] uppercase tracking-[0.16em]">Spam</TableHead>
146126
<TableHead className="text-center text-[11px] uppercase tracking-[0.16em]">Lido</TableHead>
147127
<TableHead>Fonte</TableHead>
@@ -225,6 +205,84 @@ export function JobsTableCard({
225205
) : null}
226206
</TableBody>
227207
</Table>
208+
209+
<div className="mt-4 border-t border-border/60 pt-4">
210+
<p className="text-sm text-muted-foreground">
211+
<span className="font-medium text-foreground">{resultsLabel}</span>
212+
<span> (Atualizado em {formatDate(meta.modifiedAt)})</span>
213+
</p>
214+
215+
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
216+
<label
217+
htmlFor="page-size"
218+
className="flex w-fit items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-sm shadow-sm"
219+
>
220+
<span className="text-muted-foreground">Itens por página</span>
221+
<input
222+
id="page-size"
223+
aria-label="Itens por página"
224+
type="number"
225+
min={1}
226+
max={10}
227+
step={1}
228+
className="w-14 rounded-md bg-transparent text-center font-semibold text-foreground outline-none"
229+
value={pageSize}
230+
onChange={(event) => onPageSizeChange(Number(event.target.value))}
231+
/>
232+
</label>
233+
234+
<nav className="flex items-center gap-1 self-end md:self-auto" aria-label="Paginação">
235+
<button
236+
type="button"
237+
aria-label="Pagina anterior"
238+
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
239+
disabled={currentPage === 1}
240+
className="flex h-9 min-w-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
241+
>
242+
<FiChevronLeft className="h-4 w-4" />
243+
</button>
244+
245+
{paginationItems.map((item, index) => {
246+
if (typeof item !== "number") {
247+
return (
248+
<span key={`${item}-${index}`} className="px-2 text-sm text-muted-foreground" aria-hidden="true">
249+
250+
</span>
251+
);
252+
}
253+
254+
const isCurrent = item === currentPage;
255+
256+
return (
257+
<button
258+
key={item}
259+
type="button"
260+
aria-current={isCurrent ? "page" : undefined}
261+
onClick={() => onPageChange(item)}
262+
className={cn(
263+
"flex h-9 min-w-9 items-center justify-center rounded-full px-3 text-sm font-medium transition-colors",
264+
isCurrent
265+
? "bg-[#0c6b35] text-white shadow-sm"
266+
: "text-muted-foreground hover:bg-muted hover:text-foreground",
267+
)}
268+
>
269+
{item}
270+
</button>
271+
);
272+
})}
273+
274+
<button
275+
type="button"
276+
aria-label="Pagina seguinte"
277+
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
278+
disabled={currentPage === totalPages}
279+
className="flex h-9 min-w-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
280+
>
281+
<FiChevronRight className="h-4 w-4" />
282+
</button>
283+
</nav>
284+
</div>
285+
</div>
228286
</CardContent>
229287
</Card>
230288
);

frontend/src/hooks/useJobsPagination.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
1-
import { useMemo, useState } from "react";
21
import type { Job } from "@/types/jobs";
2+
import { useMemo, useState } from "react";
33

44
interface UseJobsPaginationParams {
55
filteredJobs: Job[];
66
initialPageSize?: number;
77
}
88

9+
function clampPageSize(value: number) {
10+
if (!Number.isFinite(value)) {
11+
return 1;
12+
}
13+
14+
return Math.min(10, Math.max(1, Math.trunc(value)));
15+
}
16+
917
export function useJobsPagination({
1018
filteredJobs,
11-
initialPageSize = 25,
19+
initialPageSize = 4,
1220
}: UseJobsPaginationParams) {
1321
const [page, setPage] = useState(1);
14-
const [pageSize, setPageSize] = useState(initialPageSize);
22+
const [pageSize, setPageSizeState] = useState(clampPageSize(initialPageSize));
1523

1624
const totalPages = useMemo(() => {
1725
return Math.max(1, Math.ceil(filteredJobs.length / pageSize));
@@ -31,6 +39,13 @@ export function useJobsPagination({
3139
});
3240
}
3341

42+
function setPageSize(value: number | ((previous: number) => number)) {
43+
setPageSizeState((previous) => {
44+
const next = typeof value === "function" ? value(previous) : value;
45+
return clampPageSize(next);
46+
});
47+
}
48+
3449
function resetPagination() {
3550
setPage(1);
3651
}

frontend/tests/unit/components/JobsTableCard.test.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,28 +29,39 @@ const baseProps = {
2929
error: "",
3030
formatDate: () => "agora",
3131
currentPage: 1,
32-
totalPages: 2,
33-
pageSize: 25,
32+
totalPages: 5,
33+
pageSize: 4,
3434
onPageChange: vi.fn(),
3535
onPageSizeChange: vi.fn(),
3636
};
3737

3838
describe("JobsTableCard", () => {
39-
it("renderiza tabela com palavras-chave em tags e colunas de spam e lido", () => {
39+
it("renderiza tabela com resumo, palavras-chave e colunas de spam e lido", () => {
4040
render(<JobsTableCard {...baseProps} />);
4141

4242
expect(screen.getByText("Vagas Encontradas")).toBeInTheDocument();
43+
expect(screen.getByText(/Resultados: 1 vaga encontrada/i)).toBeInTheDocument();
4344
expect(screen.getByText("Abrir vaga")).toBeInTheDocument();
4445
expect(screen.getByText("JavaScript")).toBeInTheDocument();
4546
expect(screen.getByText("UX-UI")).toBeInTheDocument();
4647
expect(screen.getByText(/spam/i)).toBeInTheDocument();
4748
expect(screen.getByText(/lido/i)).toBeInTheDocument();
4849
});
4950

50-
it("aciona paginacao", () => {
51+
it("aciona paginacao numerica", () => {
5152
render(<JobsTableCard {...baseProps} />);
52-
fireEvent.click(screen.getByRole("button", { name: "Proxima" }));
53-
expect(baseProps.onPageChange).toHaveBeenCalled();
53+
fireEvent.click(screen.getByRole("button", { name: "2" }));
54+
expect(baseProps.onPageChange).toHaveBeenCalledWith(2);
55+
});
56+
57+
it("exibe setas para voltar e avancar entre paginas", () => {
58+
render(<JobsTableCard {...baseProps} currentPage={3} />);
59+
60+
fireEvent.click(screen.getByRole("button", { name: /pagina anterior/i }));
61+
fireEvent.click(screen.getByRole("button", { name: /pagina seguinte/i }));
62+
63+
expect(baseProps.onPageChange).toHaveBeenCalledWith(2);
64+
expect(baseProps.onPageChange).toHaveBeenCalledWith(4);
5465
});
5566

5667
it("permite marcar a vaga como spam e lida", () => {

frontend/tests/unit/utils/useJobsPagination.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useJobsPagination } from "@/hooks/useJobsPagination";
2-
import { renderHook } from "@testing-library/react";
2+
import { act, renderHook } from "@testing-library/react";
33
import { describe, expect, it } from "vitest";
44

55
const JOBS = Array.from({ length: 5 }).map((_, index) => ({ titulo: `Job ${index}` }));
@@ -13,7 +13,16 @@ describe("useJobsPagination", () => {
1313

1414
it("nao permite pagina menor que 1", () => {
1515
const { result } = renderHook(() => useJobsPagination({ filteredJobs: JOBS, initialPageSize: 2 }));
16-
result.current.setCurrentPage(0);
16+
act(() => result.current.setCurrentPage(0));
1717
expect(result.current.currentPage).toBe(1);
1818
});
19+
20+
it("limita itens por pagina entre 1 e 10", () => {
21+
const { result } = renderHook(() => useJobsPagination({ filteredJobs: JOBS, initialPageSize: 20 }));
22+
23+
expect(result.current.pageSize).toBe(10);
24+
25+
act(() => result.current.setPageSize(-3));
26+
expect(result.current.pageSize).toBe(1);
27+
});
1928
});

0 commit comments

Comments
 (0)