Skip to content

Commit d22b144

Browse files
authored
feat: add Contacts page with pagination and loader functionality (#239)
1 parent 4147382 commit d22b144

5 files changed

Lines changed: 331 additions & 0 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import React from 'react';
2+
import { Link } from 'react-router';
3+
4+
type PaginationProps = {
5+
currentPage: number;
6+
totalPages: number;
7+
getPageHref: (page: number) => string;
8+
};
9+
10+
const buttonBaseStyle: React.CSSProperties = {
11+
minWidth: '38px',
12+
height: '38px',
13+
padding: '0 12px',
14+
borderRadius: '10px',
15+
border: '1px solid #cbd5e1',
16+
backgroundColor: '#ffffff',
17+
color: '#334155',
18+
fontSize: '14px',
19+
fontWeight: 600,
20+
cursor: 'pointer',
21+
};
22+
23+
const Pagination: React.FC<PaginationProps> = ({
24+
currentPage,
25+
totalPages,
26+
getPageHref,
27+
}) => {
28+
if (totalPages <= 1) {
29+
return null;
30+
}
31+
32+
const pages = Array.from({ length: totalPages }, (_, index) => index + 1);
33+
34+
return (
35+
<div
36+
style={{
37+
display: 'flex',
38+
justifyContent: 'space-between',
39+
alignItems: 'center',
40+
gap: '16px',
41+
marginTop: '22px',
42+
flexWrap: 'wrap',
43+
}}
44+
>
45+
<p style={{ margin: 0, fontSize: '14px', color: '#64748b' }}>
46+
Pagina {currentPage} de {totalPages}
47+
</p>
48+
49+
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
50+
<Link
51+
to={getPageHref(Math.max(currentPage - 1, 1))}
52+
aria-disabled={currentPage === 1}
53+
style={{
54+
...buttonBaseStyle,
55+
display: 'inline-flex',
56+
alignItems: 'center',
57+
justifyContent: 'center',
58+
textDecoration: 'none',
59+
opacity: currentPage === 1 ? 0.5 : 1,
60+
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
61+
pointerEvents: currentPage === 1 ? 'none' : 'auto',
62+
}}
63+
>
64+
Anterior
65+
</Link>
66+
67+
{pages.map((page) => (
68+
<Link
69+
key={page}
70+
to={getPageHref(page)}
71+
style={{
72+
...buttonBaseStyle,
73+
display: 'inline-flex',
74+
alignItems: 'center',
75+
justifyContent: 'center',
76+
textDecoration: 'none',
77+
backgroundColor: currentPage === page ? '#2563eb' : '#ffffff',
78+
borderColor: currentPage === page ? '#2563eb' : '#cbd5e1',
79+
color: currentPage === page ? '#ffffff' : '#334155',
80+
}}
81+
>
82+
{page}
83+
</Link>
84+
))}
85+
86+
<Link
87+
to={getPageHref(Math.min(currentPage + 1, totalPages))}
88+
aria-disabled={currentPage === totalPages}
89+
style={{
90+
...buttonBaseStyle,
91+
display: 'inline-flex',
92+
alignItems: 'center',
93+
justifyContent: 'center',
94+
textDecoration: 'none',
95+
opacity: currentPage === totalPages ? 0.5 : 1,
96+
cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
97+
pointerEvents: currentPage === totalPages ? 'none' : 'auto',
98+
}}
99+
>
100+
Siguiente
101+
</Link>
102+
</div>
103+
</div>
104+
);
105+
};
106+
107+
export default Pagination;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { LoaderFunctionArgs } from "react-router";
2+
3+
export type Contact = {
4+
id: number;
5+
name: string;
6+
email: string;
7+
};
8+
9+
type ContactResponse = {
10+
contacts: Contact[];
11+
currentPage: number;
12+
totalPages: number;
13+
}
14+
15+
export const contactsLoader = async ({ request }: LoaderFunctionArgs) => {
16+
const url = new URL(request.url);
17+
const pageParam = url.searchParams.get("page");
18+
console.log(pageParam)
19+
const response = await fetch("http://localhost:3000/v1/contacts");
20+
21+
if (!response.ok) {
22+
throw new Response("Failed to load contacts", {
23+
status: response.status,
24+
statusText: response.statusText,
25+
});
26+
}
27+
28+
const contactsResponse = (await response.json()) as ContactResponse;
29+
30+
return contactsResponse;
31+
};
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React from 'react';
2+
import { useLoaderData, } from 'react-router';
3+
import type { contactsLoader } from '../loaders/contactsLoader';
4+
import Pagination from '../components/Pagination';
5+
6+
const Contacts: React.FC = () => {
7+
const { contacts, currentPage, totalPages } = useLoaderData<typeof contactsLoader>();
8+
9+
10+
const getPageHref = (page: number) => `/contacts?page=${page}`;
11+
12+
return (
13+
<div
14+
style={{
15+
minHeight: '100vh',
16+
padding: '32px 20px',
17+
background: 'linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%)',
18+
}}
19+
>
20+
<div
21+
style={{
22+
maxWidth: '720px',
23+
margin: '0 auto',
24+
backgroundColor: '#ffffff',
25+
borderRadius: '16px',
26+
padding: '28px',
27+
boxShadow: '0 18px 45px rgba(15, 23, 42, 0.10)',
28+
border: '1px solid #e2e8f0',
29+
}}
30+
>
31+
<div style={{ marginBottom: '24px' }}>
32+
<p
33+
style={{
34+
margin: 0,
35+
fontSize: '12px',
36+
fontWeight: 700,
37+
letterSpacing: '0.08em',
38+
textTransform: 'uppercase',
39+
color: '#2563eb',
40+
}}
41+
>
42+
Agenda mock
43+
</p>
44+
<h1
45+
style={{
46+
margin: '8px 0 6px',
47+
fontSize: '32px',
48+
color: '#0f172a',
49+
}}
50+
>
51+
Contactos
52+
</h1>
53+
<p style={{ margin: 0, color: '#475569', fontSize: '15px' }}>
54+
Una lista simple de contactos con nombre y correo.
55+
</p>
56+
</div>
57+
58+
<div style={{ display: 'grid', gap: '14px' }}>
59+
{contacts.length === 0 ? (
60+
<div
61+
style={{
62+
padding: '22px',
63+
borderRadius: '12px',
64+
backgroundColor: '#f8fafc',
65+
border: '1px dashed #cbd5e1',
66+
color: '#64748b',
67+
textAlign: 'center',
68+
}}
69+
>
70+
No hay contactos disponibles.
71+
</div>
72+
) : (
73+
contacts.map((contact) => (
74+
<div
75+
key={contact.id}
76+
style={{
77+
display: 'flex',
78+
justifyContent: 'space-between',
79+
alignItems: 'center',
80+
gap: '16px',
81+
padding: '16px 18px',
82+
borderRadius: '12px',
83+
backgroundColor: '#f8fafc',
84+
border: '1px solid #e2e8f0',
85+
}}
86+
>
87+
<div>
88+
<p
89+
style={{
90+
margin: 0,
91+
fontSize: '17px',
92+
fontWeight: 600,
93+
color: '#0f172a',
94+
}}
95+
>
96+
{contact.name}
97+
</p>
98+
<p
99+
style={{
100+
margin: '6px 0 0',
101+
fontSize: '14px',
102+
color: '#64748b',
103+
}}
104+
>
105+
{contact.email}
106+
</p>
107+
</div>
108+
109+
<span
110+
style={{
111+
padding: '6px 10px',
112+
borderRadius: '999px',
113+
backgroundColor: '#dbeafe',
114+
color: '#1d4ed8',
115+
fontSize: '12px',
116+
fontWeight: 700,
117+
}}
118+
>
119+
Contacto
120+
</span>
121+
</div>
122+
))
123+
)}
124+
</div>
125+
126+
127+
128+
<Pagination
129+
currentPage={currentPage}
130+
totalPages={totalPages}
131+
getPageHref={getPageHref}
132+
/>
133+
</div>
134+
</div>
135+
);
136+
};
137+
138+
export default Contacts;

examples/twd-test-app/src/routes.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,19 @@ import MockComponent from "./pages/MockComponent";
88
import Responsive from "./pages/Responsive";
99
import BlurValidation from "./pages/BlurValidation";
1010
import ComboboxSelect from "./pages/ComboboxSelect";
11+
import Contacts from "./pages/Contacts";
12+
import { contactsLoader } from "./loaders/contactsLoader";
1113

1214
const router = createBrowserRouter([
1315
{
1416
path: "/",
1517
Component: App,
1618
},
19+
{
20+
path: "/contacts",
21+
loader: contactsLoader,
22+
Component: Contacts,
23+
},
1724
{
1825
path: "/assertions",
1926
Component: Assertions,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { twd, expect, screenDom, userEvent } from "../../../../src";
2+
import { describe, it, beforeEach } from "../../../../src/runner";
3+
4+
const contactsResponse = [
5+
{ id: 1, name: "Ana Garcia", email: "ana.garcia@example.com" },
6+
{ id: 2, name: "Luis Martinez", email: "luis.martinez@example.com" },
7+
{ id: 3, name: "Carmen Lopez", email: "carmen.lopez@example.com" },
8+
{ id: 4, name: "Javier Romero", email: "javier.romero@example.com" },
9+
];
10+
11+
describe("contactsPage", () => {
12+
beforeEach(() => {
13+
twd.clearRequestMockRules();
14+
});
15+
16+
it("renders the contacts list from the loader response", async () => {
17+
await twd.mockRequest("contactsList", {
18+
method: "GET",
19+
response: {
20+
contacts: contactsResponse,
21+
currentPage: 2,
22+
totalPages: 2,
23+
},
24+
url: "/v1/contacts",
25+
});
26+
27+
await twd.visit("/contacts");
28+
await twd.waitForRequest("contactsList");
29+
30+
const heading = screenDom.getByRole("heading", { name: /contactos/i });
31+
twd.should(heading, "be.visible");
32+
33+
for (const contact of contactsResponse.slice(0, 2)) {
34+
const name = screenDom.getByText(contact.name);
35+
const email = screenDom.getByText(contact.email);
36+
37+
twd.should(name, "be.visible");
38+
twd.should(email, "be.visible");
39+
}
40+
41+
const pageTwoLink = screenDom.getByRole("link", { name: "2" });
42+
await userEvent.click(pageTwoLink);
43+
44+
// TODO: there is a bug with twd assertion
45+
// await twd.url().should("contain.url", "/contacts?page=2");
46+
47+
});
48+
});

0 commit comments

Comments
 (0)