From a79578a36ebd3311fa09ef8120c60d6fdc7ecc46 Mon Sep 17 00:00:00 2001 From: Ishant Solanki Date: Mon, 3 Nov 2025 21:29:09 +0000 Subject: [PATCH 1/5] Call endpoint use swr --- package-lock.json | 29 +++++++++++++++++++++++++++++ package.json | 1 + src/Aplications.module.css | 0 src/Applications.tsx | 9 +++++---- src/model/Applications.ts | 10 ++++++++++ src/network/applications.ts | 21 +++++++++++++++++++++ src/network/common.ts | 1 + 7 files changed, 67 insertions(+), 4 deletions(-) delete mode 100644 src/Aplications.module.css create mode 100644 src/model/Applications.ts create mode 100644 src/network/applications.ts create mode 100644 src/network/common.ts diff --git a/package-lock.json b/package-lock.json index 934540f..2ca03a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "7.45.1", + "swr": "^2.3.6", "ts-node": "^10.9.1", "typescript": "5.1.6" }, @@ -2763,6 +2764,14 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -6549,6 +6558,18 @@ "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", "dev": true }, + "node_modules/swr": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", + "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -6877,6 +6898,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index c3493bc..24df785 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "7.45.1", + "swr": "^2.3.6", "ts-node": "^10.9.1", "typescript": "5.1.6" }, diff --git a/src/Aplications.module.css b/src/Aplications.module.css deleted file mode 100644 index e69de29..0000000 diff --git a/src/Applications.tsx b/src/Applications.tsx index 8c9f646..f97f886 100644 --- a/src/Applications.tsx +++ b/src/Applications.tsx @@ -1,14 +1,15 @@ import React from "react"; import SingleApplication from "./SingleApplication"; -import { getSingleApplicationFixture } from "./__fixtures__/applications.fixture"; import styles from "./Applications.module.css"; +import { useApplications } from "./network/applications"; const Applications = () => { - const applications = getSingleApplicationFixture; - + const { data } = useApplications(1, 10); return (
- + {data?.map((application) => ( + + ))}
); }; diff --git a/src/model/Applications.ts b/src/model/Applications.ts new file mode 100644 index 0000000..54fd49b --- /dev/null +++ b/src/model/Applications.ts @@ -0,0 +1,10 @@ +export interface ApplicationDTO { + guid: string; + loan_amount: number; + first_name: string; + last_name: string; + company: string; + email: string; + date_created: string; + expiry_date: string; +} diff --git a/src/network/applications.ts b/src/network/applications.ts new file mode 100644 index 0000000..b76dd9c --- /dev/null +++ b/src/network/applications.ts @@ -0,0 +1,21 @@ +import swr from "swr"; +import { ApplicationDTO } from "../model/Applications"; +import { BASE_URL } from "./common"; + +export const getApplications = ({ + page, + limit, +}: { + page: number; + limit: number; +}) => + fetch(`${BASE_URL}/applications?page=${page}&limit=${limit}`).then((res) => + res.json() + ) as Promise; + +export const useApplications = (page: number, limit: number) => { + return swr( + `${BASE_URL}/applications?page=${page}&limit=${limit}`, + () => getApplications({ page, limit }) + ); +}; diff --git a/src/network/common.ts b/src/network/common.ts new file mode 100644 index 0000000..18a479f --- /dev/null +++ b/src/network/common.ts @@ -0,0 +1 @@ +export const BASE_URL = "http://localhost:3001/api"; From 1bd1865bae443e0c5c07d8d4058d8659fe0fe288 Mon Sep 17 00:00:00 2001 From: Ishant Solanki Date: Mon, 3 Nov 2025 21:40:12 +0000 Subject: [PATCH 2/5] Move files to components and assets folders --- src/App.tsx | 2 +- src/Applications.tsx | 13 +++++++++---- src/{ => assets}/logo.svg.tsx | 0 src/{ => components/Header}/Header.module.css | 0 src/{ => components/Header}/Header.tsx | 2 +- src/components/Header/index.ts | 1 + src/components/Loading/Loading.module.css | 9 +++++++++ src/components/Loading/Loading.tsx | 5 +++++ 8 files changed, 26 insertions(+), 6 deletions(-) rename src/{ => assets}/logo.svg.tsx (100%) rename src/{ => components/Header}/Header.module.css (100%) rename src/{ => components/Header}/Header.tsx (85%) create mode 100644 src/components/Header/index.ts create mode 100644 src/components/Loading/Loading.module.css create mode 100644 src/components/Loading/Loading.tsx diff --git a/src/App.tsx b/src/App.tsx index de815c2..64438c5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import "./App.css"; import Applications from "./Applications"; -import Header from "./Header"; +import Header from "./components/Header"; function App() { return ( diff --git a/src/Applications.tsx b/src/Applications.tsx index f97f886..1f21756 100644 --- a/src/Applications.tsx +++ b/src/Applications.tsx @@ -2,14 +2,19 @@ import React from "react"; import SingleApplication from "./SingleApplication"; import styles from "./Applications.module.css"; import { useApplications } from "./network/applications"; +import { Loading } from "./components/Loading/Loading"; const Applications = () => { - const { data } = useApplications(1, 10); + const { data, isLoading } = useApplications(1, 10); return (
- {data?.map((application) => ( - - ))} + {isLoading ? ( + + ) : ( + data?.map((application) => ( + + )) + )}
); }; diff --git a/src/logo.svg.tsx b/src/assets/logo.svg.tsx similarity index 100% rename from src/logo.svg.tsx rename to src/assets/logo.svg.tsx diff --git a/src/Header.module.css b/src/components/Header/Header.module.css similarity index 100% rename from src/Header.module.css rename to src/components/Header/Header.module.css diff --git a/src/Header.tsx b/src/components/Header/Header.tsx similarity index 85% rename from src/Header.tsx rename to src/components/Header/Header.tsx index 206a084..62e619f 100644 --- a/src/Header.tsx +++ b/src/components/Header/Header.tsx @@ -1,5 +1,5 @@ import React from "react"; -import LogoSvg from "./logo.svg"; +import LogoSvg from "../../assets/logo.svg"; import styles from "./Header.module.css"; const Header = () => { diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 0000000..2764567 --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export { default } from "./Header"; diff --git a/src/components/Loading/Loading.module.css b/src/components/Loading/Loading.module.css new file mode 100644 index 0000000..4cf269b --- /dev/null +++ b/src/components/Loading/Loading.module.css @@ -0,0 +1,9 @@ +.LoadingContainer { + background-color: white; + box-shadow: 0px 5px 16px 0px rgba(173, 200, 215, 0.25); + border-radius: 10px; + margin-bottom: 1rem; + padding: 1rem; + text-align: center; + font-weight: bold; +} diff --git a/src/components/Loading/Loading.tsx b/src/components/Loading/Loading.tsx new file mode 100644 index 0000000..3a78aa3 --- /dev/null +++ b/src/components/Loading/Loading.tsx @@ -0,0 +1,5 @@ +import styles from "./Loading.module.css"; + +export const Loading = () => { + return
Loading...
; +}; From 0a9afe7644b022f2384953174cd6b6962318f33e Mon Sep 17 00:00:00 2001 From: Ishant Solanki Date: Mon, 3 Nov 2025 22:30:38 +0000 Subject: [PATCH 3/5] Add pagination --- src/App.tsx | 20 +++++++++++++++++++- src/Applications.tsx | 13 +++++++++---- src/components/Footer/Footer.module.css | 14 ++++++++++++++ src/components/Footer/Footer.tsx | 23 +++++++++++++++++++++++ src/components/Footer/index.ts | 1 + src/network/applications.ts | 14 +++++++++----- 6 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 src/components/Footer/Footer.module.css create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Footer/index.ts diff --git a/src/App.tsx b/src/App.tsx index 64438c5..2bb3386 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,30 @@ import "./App.css"; import Applications from "./Applications"; import Header from "./components/Header"; +import Footer from "./components/Footer"; +import { useMemo, useState } from "react"; +import { useApplications } from "./network/applications"; + +const PAGE_SIZE = 5; function App() { + const [currentPage, setCurrentPage] = useState(0); + const { data, isLoading, isValidating, size, setSize } = useApplications( + currentPage, + PAGE_SIZE + ); + const flatData = useMemo(() => data?.flatMap((page) => page), [data]); return (
- + +
{ + setSize(size + 1); + setCurrentPage((prev) => prev + 1); + }} + />
); } diff --git a/src/Applications.tsx b/src/Applications.tsx index 1f21756..c5027c8 100644 --- a/src/Applications.tsx +++ b/src/Applications.tsx @@ -1,11 +1,16 @@ -import React from "react"; +import React, { useMemo } from "react"; import SingleApplication from "./SingleApplication"; import styles from "./Applications.module.css"; -import { useApplications } from "./network/applications"; import { Loading } from "./components/Loading/Loading"; +import { ApplicationDTO } from "./model/Applications"; -const Applications = () => { - const { data, isLoading } = useApplications(1, 10); +const Applications = ({ + data, + isLoading, +}: { + data: ApplicationDTO[]; + isLoading: boolean; +}) => { return (
{isLoading ? ( diff --git a/src/components/Footer/Footer.module.css b/src/components/Footer/Footer.module.css new file mode 100644 index 0000000..edc1ced --- /dev/null +++ b/src/components/Footer/Footer.module.css @@ -0,0 +1,14 @@ +.FooterContainer { + display: flex; + justify-content: center; + + .LoadMoreButton { + background-color: #0c2340; + color: white; + border-radius: 8px; + padding: 8px 24px; + border: 1px solid #0c2340; + font-weight: bold; + cursor: pointer; + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000..7bbe218 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,23 @@ +import styles from "./Footer.module.css"; + +const Footer = ({ + onLoadMoreClick, + isLoading, +}: { + onLoadMoreClick: () => void; + isLoading: boolean; +}) => { + return ( +
+ +
+ ); +}; + +export default Footer; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 0000000..3738288 --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export { default } from "./Footer"; diff --git a/src/network/applications.ts b/src/network/applications.ts index b76dd9c..68d2d1e 100644 --- a/src/network/applications.ts +++ b/src/network/applications.ts @@ -1,4 +1,4 @@ -import swr from "swr"; +import useSWRInfinite from "swr/infinite"; import { ApplicationDTO } from "../model/Applications"; import { BASE_URL } from "./common"; @@ -9,13 +9,17 @@ export const getApplications = ({ page: number; limit: number; }) => - fetch(`${BASE_URL}/applications?page=${page}&limit=${limit}`).then((res) => + fetch(`${BASE_URL}/applications?_page=${page}&_limit=${limit}`).then((res) => res.json() ) as Promise; +const getKey = (pageIndex: number, previousPageData: ApplicationDTO[]) => { + if (previousPageData && !previousPageData.length) return null; // reached the end + return `${BASE_URL}/applications?_page=${pageIndex}`; // SWR key +}; + export const useApplications = (page: number, limit: number) => { - return swr( - `${BASE_URL}/applications?page=${page}&limit=${limit}`, - () => getApplications({ page, limit }) + return useSWRInfinite(getKey, () => + getApplications({ page, limit }) ); }; From 5a7136e0083000d8dd6f6d8750a71fab418516e9 Mon Sep 17 00:00:00 2001 From: Ishant Solanki Date: Mon, 3 Nov 2025 22:45:33 +0000 Subject: [PATCH 4/5] Add tests --- src/App.test.tsx | 10 ++++++++-- src/Applications.test.tsx | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 src/Applications.test.tsx diff --git a/src/App.test.tsx b/src/App.test.tsx index 4db6ab8..f97c93c 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,8 +1,14 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; +import { render, screen } from "@testing-library/react"; +import App from "./App"; test('renders "Application Portal" title', () => { render(); const linkElement = screen.getByText(/Application portal/i); expect(linkElement).toBeInTheDocument(); }); + +test('renders "Load more" button', () => { + render(); + const loadMoreButton = screen.getByRole("button", { name: /loading\.\.\./i }); + expect(loadMoreButton).toBeInTheDocument(); +}); diff --git a/src/Applications.test.tsx b/src/Applications.test.tsx new file mode 100644 index 0000000..96cdaa1 --- /dev/null +++ b/src/Applications.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import Applications from "./Applications"; +import { getSingleApplicationFixture } from "./__fixtures__/applications.fixture"; + +test("renders loading state when isLoading is true", () => { + render(); + const loadingElement = screen.getByText(/loading/i); + expect(loadingElement).toBeInTheDocument(); +}); + +test("renders application items when data is provided", () => { + const mockData = getSingleApplicationFixture; + render(); + + screen.logTestingPlaygroundURL(); + const company = screen.getByText(/company/i); + const companyName = screen.getByText("Qnekt"); + + expect(companyName).toBeInTheDocument(); + expect(company).toBeInTheDocument(); + + const user = screen.getByText(/name/i); + const userName = screen.getByText("Miles Espinoza"); + + expect(user).toBeInTheDocument(); + expect(userName).toBeInTheDocument(); + + const email = screen.getByText(/email/i); + const emailName = screen.getByText(/milesespinoza@qnekt\.com/i); + + expect(email).toBeInTheDocument(); + expect(emailName).toBeInTheDocument(); + + const loan = screen.getByText(/loan amount/i); + const loanName = screen.getByText(/37597/i); + + expect(loan).toBeInTheDocument(); + expect(loanName).toBeInTheDocument(); +}); From bb4fa8f54a0a5b280b1e31f53ea397ec2f82f10e Mon Sep 17 00:00:00 2001 From: Ishant Solanki Date: Mon, 3 Nov 2025 22:55:26 +0000 Subject: [PATCH 5/5] Format data with date-fns --- package-lock.json | 36 +++++++++++++++++++++----------- package.json | 1 + src/Applications.test.tsx | 14 ++++++++++++- src/SingleApplication.module.css | 6 +++++- src/SingleApplication.tsx | 14 +++++++++---- 5 files changed, 53 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ca03a1..c60b2d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@testing-library/react": "14.0.0", "@testing-library/user-event": "14.4.3", "classnames": "2.3.2", + "date-fns": "^4.1.0", "json-server": "0.17.3", "react": "18.2.0", "react-dom": "18.2.0", @@ -2455,6 +2456,23 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/concurrently/node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/concurrently/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -2638,19 +2656,13 @@ } }, "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, "node_modules/date-time": { diff --git a/package.json b/package.json index 24df785..8afa1c5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@testing-library/react": "14.0.0", "@testing-library/user-event": "14.4.3", "classnames": "2.3.2", + "date-fns": "^4.1.0", "json-server": "0.17.3", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/src/Applications.test.tsx b/src/Applications.test.tsx index 96cdaa1..2210e8c 100644 --- a/src/Applications.test.tsx +++ b/src/Applications.test.tsx @@ -32,8 +32,20 @@ test("renders application items when data is provided", () => { expect(emailName).toBeInTheDocument(); const loan = screen.getByText(/loan amount/i); - const loanName = screen.getByText(/37597/i); + const loanName = screen.getByText(/£37,597/i); expect(loan).toBeInTheDocument(); expect(loanName).toBeInTheDocument(); + + const applicationDate = screen.getByText(/application date/i); + const applicationDateValue = screen.getByText(/10-08-2021/i); + + expect(applicationDate).toBeInTheDocument(); + expect(applicationDateValue).toBeInTheDocument(); + + const expiryDate = screen.getByText(/expiry date/i); + const expiryDateValue = screen.getByText(/02-12-2021/i); + + expect(expiryDate).toBeInTheDocument(); + expect(expiryDateValue).toBeInTheDocument(); }); diff --git a/src/SingleApplication.module.css b/src/SingleApplication.module.css index 5e8ceb3..d673de2 100644 --- a/src/SingleApplication.module.css +++ b/src/SingleApplication.module.css @@ -11,7 +11,7 @@ .cell { text-align: left; - border-right: 1px solid #eee; + font-weight: 600; padding: 0 15px; } @@ -21,3 +21,7 @@ color: #999; margin-bottom: 5px; } + +.highlight { + color: #143b6b; +} diff --git a/src/SingleApplication.tsx b/src/SingleApplication.tsx index e664745..28f01d7 100644 --- a/src/SingleApplication.tsx +++ b/src/SingleApplication.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { format } from "date-fns"; import styles from "./SingleApplication.module.css"; const SingleApplication = ({ application }) => { @@ -14,19 +15,24 @@ const SingleApplication = ({ application }) => {
Email - {application.email} + {application.email}
Loan Amount - {application.loan_amount} + {(application.loan_amount || 0).toLocaleString("en-GB", { + style: "currency", + currency: "GBP", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })}
Application Date - {application.date_created} + {format(new Date(application.date_created), "dd-MM-yyyy")}
Expiry date - {application.expiry_date} + {format(new Date(application.expiry_date), "dd-MM-yyyy")}
);