Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion COMMENTS.md
Original file line number Diff line number Diff line change
@@ -1 +1,31 @@
<!-- Project Comments Go Here -->
# Comments

## Implementation notes

### Data fetching & pagination

- Replaced the hard-coded fixture with real API calls to `/api/applications`.
- Implemented pagination using `?_page` and `?_limit=5` as required.
- The "Load more" button fetches the next page and appends results to the existing list.
- Used the `Link` response header (`rel="next"`) to determine whether more pages exist and conditionally render the button.

### Architecture

- Extracted API logic into `src/api/applications.ts` to keep components focused on UI and make the fetch logic easier to test.
- Added a shared `Application` TypeScript type for stronger typing across components.
- Kept `SingleApplication` as a presentational component only.

### UX considerations

- Loading state shown during fetches.
- Button disabled while loading to prevent duplicate requests.
- Basic error message shown if requests fail.
- Added responsive layout for mobile screens.

### Testing

- Added Vitest + React Testing Library tests for:
- API helper (`fetchApplications`)
- Initial render
- Pagination behaviour (append + hide button when no next page)
- Error state
Empty file removed src/Aplications.module.css
Empty file.
34 changes: 0 additions & 34 deletions src/App.css

This file was deleted.

15 changes: 11 additions & 4 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { render, screen } from '@testing-library/react';
import App from './App';
import { render, screen } from "@testing-library/react";
import { vi } from "vitest";
import App from "./App";

vi.mock("./api/applications", () => ({
fetchApplications: vi.fn().mockResolvedValue({
data: [],
hasNext: false,
}),
}));

test('renders "Application Portal" title', () => {
render(<App />);
const linkElement = screen.getByText(/Application portal/i);
expect(linkElement).toBeInTheDocument();
expect(screen.getByText(/application portal/i)).toBeInTheDocument();
});
5 changes: 3 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import "./App.css";
import Applications from "./Applications";
import Header from "./Header";

function App() {
return (
<div className="App">
<Header />
<Applications />
<main>
<Applications />
</main>
</div>
);
}
Expand Down
51 changes: 50 additions & 1 deletion src/Applications.module.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,54 @@
.Applications {
width: 100%;
max-width: 1200px;
max-width: 1152px;
margin: 0 auto;
display: flex;
flex-direction: column;
}

.list {
list-style: none;
margin: 0;
padding: 0;

display: flex;
flex-direction: column;
gap: 24px;
}

.listItem {
margin: 0;
}

.loadMore {
display: flex;
justify-content: center;
padding: 24px 0 48px;
}

.loadMoreButton {
width: 200px;
min-height: 39px;
box-shadow: 0px 15px 25px rgba(170, 190, 209, 0.2);
}

/* ================= MOBILE ================= */

@media (max-width: 768px) {
.Applications {
padding: 0 16px;
}

.list {
gap: 16px;
}

.loadMore {
padding: 16px 0 32px;
}

.loadMoreButton {
width: 100%;
max-width: 360px;
}
}
63 changes: 63 additions & 0 deletions src/Applications.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Applications from "./Applications";

vi.mock("./api/applications", () => ({
fetchApplications: vi.fn(),
}));

import { fetchApplications } from "./api/applications";

const makeApp = (id: number) => ({
id,
loan_amount: 1000 + id,
first_name: "First",
last_name: `User${id}`,
company: `Company ${id}`,
email: `user${id}@example.com`,
date_created: "2022-01-27T15:37:16.891Z",
expiry_date: "2025-05-12T07:10:53.489Z",
});

describe("<Applications />", () => {
it("loads first page then appends next page on Load more, and hides button when no next page", async () => {
(fetchApplications as any)
.mockResolvedValueOnce({
data: [1, 2, 3, 4, 5].map(makeApp),
hasNext: true,
})
.mockResolvedValueOnce({
data: [6, 7, 8, 9, 10].map(makeApp),
hasNext: false,
});

const user = userEvent.setup();
render(<Applications />);

expect(await screen.findByText("Company 1")).toBeInTheDocument();
expect(screen.getByText("Company 5")).toBeInTheDocument();

expect(fetchApplications).toHaveBeenNthCalledWith(1, 1);

await user.click(screen.getByRole("button", { name: /load more/i }));

expect(await screen.findByText("Company 10")).toBeInTheDocument();
expect(fetchApplications).toHaveBeenNthCalledWith(2, 2);

expect(
screen.queryByRole("button", { name: /load more/i })
).not.toBeInTheDocument();
});

it("shows an error message when the initial load fails", async () => {
(fetchApplications as any).mockRejectedValueOnce(new Error("boom"));

render(<Applications />);

expect(
await screen.findByRole("alert")
).toHaveTextContent(/couldn’t load applications/i);
});
});
71 changes: 67 additions & 4 deletions src/Applications.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,77 @@
import React from "react";
import React, { useEffect, useState } from "react";
import SingleApplication from "./SingleApplication";
import { getSingleApplicationFixture } from "./__fixtures__/applications.fixture";
import { Button } from "./ui/Button/Button";
import { fetchApplications, type Application } from "./api/applications";
import styles from "./Applications.module.css";

const Applications = () => {
const applications = getSingleApplicationFixture;
const [applications, setApplications] = useState<Application[]>([]);
const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
loadPage(1);
}, []);

async function loadPage(pageNumber: number) {
setLoading(true);
setError(null);

try {
const { data, hasNext } = await fetchApplications(pageNumber);

setApplications((prev) =>
pageNumber === 1 ? data : [...prev, ...data]
);

setHasNext(hasNext);
setPage(pageNumber);
} catch {
setError("Sorry, we couldn’t load applications. Please try again.");
} finally {
setLoading(false);
}
}

function handleLoadMore() {
loadPage(page + 1);
}

const isInitialLoading = loading && applications.length === 0;

return (
<div className={styles.Applications}>
<SingleApplication application={applications[0]} />
{error && (
<p role="alert" className={styles.error}>
{error}
</p>
)}

{isInitialLoading ? (
<p>Loading applications…</p>
) : (
<ul className={styles.list} aria-label="Applications">
{applications.map((app) => (
<li key={app.id} className={styles.listItem}>
<SingleApplication application={app} />
</li>
))}
</ul>
)}

{hasNext && (
<div className={styles.loadMore}>
<Button
className={styles.loadMoreButton}
onClick={handleLoadMore}
disabled={loading}
>
{loading ? "Loading..." : "Load more"}
</Button>
</div>
)}
</div>
);
};
Expand Down
41 changes: 36 additions & 5 deletions src/Header.module.css
Original file line number Diff line number Diff line change
@@ -1,14 +1,45 @@
.Header {
padding: 20px 0;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;

padding-top: 48px;
text-align: center;
}

.logo {
width: 198px;
height: 64px;
}


.Header h1 {
color: #143b6b;
font-weight: 800;
margin-top: 24px;
font-size: 32px;
line-height: 120%;
font-weight: 700;
color: var(--color-denim-15);
}

.logo path {
fill: #fb534a;
fill: var(--color-coral-65);
}

/* ================= MOBILE ================= */

@media (max-width: 768px) {
.Header {
padding-top: 32px;
padding-bottom: 24px;
}

.logo {
width: 150px;
height: auto;
}

.Header h1 {
font-size: 26px;
margin-top: 16px;
}
}
Loading