diff --git a/package-lock.json b/package-lock.json index 934540f..c60b2d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,12 @@ "@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", "react-hook-form": "7.45.1", + "swr": "^2.3.6", "ts-node": "^10.9.1", "typescript": "5.1.6" }, @@ -2454,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", @@ -2637,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": { @@ -2763,6 +2776,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 +6570,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 +6910,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..8afa1c5 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "@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", "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/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/App.tsx b/src/App.tsx index de815c2..2bb3386 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,30 @@ import "./App.css"; import Applications from "./Applications"; -import Header from "./Header"; +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.test.tsx b/src/Applications.test.tsx new file mode 100644 index 0000000..2210e8c --- /dev/null +++ b/src/Applications.test.tsx @@ -0,0 +1,51 @@ +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(/£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/Applications.tsx b/src/Applications.tsx index 8c9f646..c5027c8 100644 --- a/src/Applications.tsx +++ b/src/Applications.tsx @@ -1,14 +1,25 @@ -import React from "react"; +import React, { useMemo } from "react"; import SingleApplication from "./SingleApplication"; -import { getSingleApplicationFixture } from "./__fixtures__/applications.fixture"; import styles from "./Applications.module.css"; +import { Loading } from "./components/Loading/Loading"; +import { ApplicationDTO } from "./model/Applications"; -const Applications = () => { - const applications = getSingleApplicationFixture; - +const Applications = ({ + data, + isLoading, +}: { + data: ApplicationDTO[]; + isLoading: boolean; +}) => { return (
- + {isLoading ? ( + + ) : ( + data?.map((application) => ( + + )) + )}
); }; 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")}
); 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/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/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...
; +}; 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..68d2d1e --- /dev/null +++ b/src/network/applications.ts @@ -0,0 +1,25 @@ +import useSWRInfinite from "swr/infinite"; +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; + +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 useSWRInfinite(getKey, () => + 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";