From 56dc62a06b8e23741ba06f464728d47ac1551e00 Mon Sep 17 00:00:00 2001 From: Vertori <115083330+Vertori@users.noreply.github.com> Date: Mon, 24 Jun 2024 11:18:03 +0200 Subject: [PATCH 1/8] Created functions to load data --- lib/products.ts | 50 ++++++++++++++++++++++++++++++++++++----------- package-lock.json | 12 +++++++++--- package.json | 3 ++- types/index.ts | 29 +++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 15 deletions(-) create mode 100644 types/index.ts diff --git a/lib/products.ts b/lib/products.ts index 3fa7b16..06f9638 100644 --- a/lib/products.ts +++ b/lib/products.ts @@ -1,13 +1,41 @@ -export type Product = { - id: string; - name: string; - price: number; - currency: string; - quantity: number; - isAlcohol: boolean; +import fs from "fs/promises"; +import { parse } from "csv-parse/sync"; +import { CsvProduct, Product } from "@/types"; + +export const loadJsonProducts = async (path: string): Promise => { + try { + const jsonData = await fs.readFile(path, "utf8"); + return JSON.parse(jsonData); + } catch (err) { + throw new Error( + err instanceof Error + ? err.message + : "Failed to load JSON file, an unknown error happened." + ); + } }; -export function fetchProducts(page: number): Product[] { - // todo - return []; -} \ No newline at end of file +export const loadCsvProducts = async (path: string): Promise => { + try { + const csvData = await fs.readFile(path, "utf-8"); + const records = parse(csvData, { + columns: true, + skip_empty_lines: true, + }); + + return records.map((record: CsvProduct) => ({ + id: record.Id, + name: record.Name, + price: parseFloat(record.Price), + currency: record.Currency, + quantity: parseInt(record.Quantity, 10), + isAlcohol: record.IsAlcohol === "1", + })); + } catch (err) { + throw new Error( + err instanceof Error + ? err.message + : "Failed to load CSV file, an unknown error happened." + ); + } +}; diff --git a/package-lock.json b/package-lock.json index 4a0c747..c2746d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,21 @@ { - "name": "xdd", + "name": "summer-camp-2024", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "xdd", + "name": "summer-camp-2024", "version": "0.1.0", "dependencies": { + "csv-parse": "^5.5.6", "next": "14.2.4", "react": "^18", "react-dom": "^18" }, "devDependencies": { "@types/node": "^20", - "@types/react": "^18", + "@types/react": "^18.3.3", "@types/react-dom": "^18", "eslint": "^8", "eslint-config-next": "14.2.4", @@ -1917,6 +1918,11 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "dev": true }, + "node_modules/csv-parse": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.6.tgz", + "integrity": "sha512-uNpm30m/AGSkLxxy7d9yRXpJQFrZzVWLFBkS+6ngPcZkw/5k3L/jjFuj7tVnEpRn+QgmiXr21nDlhCiUK4ij2A==" + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", diff --git a/package.json b/package.json index 9ec25b1..76e2b66 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,14 @@ "test": "vitest run" }, "dependencies": { + "csv-parse": "^5.5.6", "next": "14.2.4", "react": "^18", "react-dom": "^18" }, "devDependencies": { "@types/node": "^20", - "@types/react": "^18", + "@types/react": "^18.3.3", "@types/react-dom": "^18", "eslint": "^8", "eslint-config-next": "14.2.4", diff --git a/types/index.ts b/types/index.ts new file mode 100644 index 0000000..732c8b4 --- /dev/null +++ b/types/index.ts @@ -0,0 +1,29 @@ +export type Product = { + id: string; + name: string; + price: number; + currency: string; + quantity: number; + isAlcohol: boolean; +}; + +export type CsvProduct = { + Id: string; + Name: string; + Price: string; + Currency: string; + Quantity: string; + IsAlcohol: string; +}; + +export type ApiResponse = { + paginatedProducts: Product[]; + startIndex: number; + endIndex: number; + pageCount: number; + totalProductsAmount: number; +}; + +export type ApiError = { + message: string; +}; From 995e198075cb7a4a855a99619f694bc2754c60b3 Mon Sep 17 00:00:00 2001 From: Vertori <115083330+Vertori@users.noreply.github.com> Date: Mon, 24 Jun 2024 11:23:19 +0200 Subject: [PATCH 2/8] Created function that merges and filters products --- lib/products.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/products.ts b/lib/products.ts index 06f9638..47b0788 100644 --- a/lib/products.ts +++ b/lib/products.ts @@ -1,6 +1,6 @@ import fs from "fs/promises"; import { parse } from "csv-parse/sync"; -import { CsvProduct, Product } from "@/types"; +import { ApiResponse, CsvProduct, Product } from "@/types"; export const loadJsonProducts = async (path: string): Promise => { try { @@ -39,3 +39,26 @@ export const loadCsvProducts = async (path: string): Promise => { ); } }; + +export const mergeProductsLists = async ( + jsonProductsPath: string, + csvProductsPath: string +): Promise => { + try { + // load products from json and csv files + const jsonProductsData = await loadJsonProducts(jsonProductsPath); + const csvProductsData = await loadCsvProducts(csvProductsPath); + + // return merged lists with only non-alcohol products + return [...jsonProductsData, ...csvProductsData].filter( + (product) => !product.isAlcohol + ); + } catch (err) { + throw new Error( + err instanceof Error + ? err.message + : "An unknown error happened while trying to merge products lists." + ); + } +}; + From 77fb350bd33f0bbb4419bf22821587b810639940 Mon Sep 17 00:00:00 2001 From: Vertori <115083330+Vertori@users.noreply.github.com> Date: Mon, 24 Jun 2024 11:41:59 +0200 Subject: [PATCH 3/8] Created products API endpoint --- app/api/products/route.ts | 71 ++++++++++++++++++++++++++++++++++----- lib/products.ts | 9 +++++ 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/app/api/products/route.ts b/app/api/products/route.ts index 511be22..599a361 100644 --- a/app/api/products/route.ts +++ b/app/api/products/route.ts @@ -1,9 +1,62 @@ -/** - * TODO: Prepare an endpoint to return a list of products - * The endpoint should return a pagination of 10 products per page - * The endpoint should accept a query parameter "page" to return the corresponding page - */ - -export async function GET() { - return Response.json([]); -} \ No newline at end of file +import { mergeProductsLists } from "@/lib/products"; +import { NextRequest, NextResponse } from "next/server"; +import path from "path"; + +export async function GET(req: NextRequest) { + try { + // get page number from path params + const page = req.nextUrl.searchParams.get("page"); + const pageNumber = page ? parseInt(page as string, 10) : 1; + + // get path of json and csv products files + const jsonProductsPath = path.join(process.cwd(), "products.json"); + const csvProductsPath = path.join(process.cwd(), "products.csv"); + + // merge json and csv products + const mergedProducts = await mergeProductsLists( + jsonProductsPath, + csvProductsPath + ); + + // sort merged products + mergedProducts.sort((product1, product2) => + product1.id.localeCompare(product2.id) + ); + + // set items per page + const ITEMS_PER_PAGE = 10; + + // count pagination pages amount + const pageCount = Math.ceil(mergedProducts.length / ITEMS_PER_PAGE); + + // calculate the starting and ending indexes for elements displayed on one page + const startIndex = (pageNumber - 1) * ITEMS_PER_PAGE; + let endIndex = startIndex + ITEMS_PER_PAGE; + + // correct the logic for the last page if is incomplete (receives less items than ITEMS_PER_PAGE) + if (endIndex > mergedProducts.length) { + endIndex = mergedProducts.length; + } + + // slice list to get only items needed for visited website path + const paginatedProducts = mergedProducts.slice(startIndex, endIndex); + + // count total products amount + const totalProductsAmount = mergedProducts.length; + + const respondeData = { + paginatedProducts, + startIndex, + endIndex, + pageCount, + totalProductsAmount, + }; + + return NextResponse.json(respondeData); + } catch (err) { + return NextResponse.json( + { message: "Error happened while trying to fetch products!" }, + { status: 500 } + ); + } +} diff --git a/lib/products.ts b/lib/products.ts index 47b0788..343b26c 100644 --- a/lib/products.ts +++ b/lib/products.ts @@ -62,3 +62,12 @@ export const mergeProductsLists = async ( } }; +export const getProducts = async (page: number): Promise => { + const response = await fetch( + `http://localhost:3000/api/products?page=${page}` + ); + if (!response.ok) { + throw new Error("Failed to fetch products."); + } + return response.json(); +}; From b4af6d7dff4d013a314fa4f8f9eedbc0d1552c1e Mon Sep 17 00:00:00 2001 From: Vertori <115083330+Vertori@users.noreply.github.com> Date: Mon, 24 Jun 2024 13:12:03 +0200 Subject: [PATCH 4/8] Modified tests --- lib/products.test.ts | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/products.test.ts b/lib/products.test.ts index 3e0202d..ae651a0 100644 --- a/lib/products.test.ts +++ b/lib/products.test.ts @@ -1,19 +1,22 @@ -import { expect, test } from "vitest" -import { fetchProducts } from "./products" +import { expect, test } from "vitest"; +import { getProducts } from "./products"; -test('alcohol is excluded', () => { - const products = fetchProducts(0); - expect(products.some(product => product.isAlcohol)).toBe(false); +test("alcohol is excluded", async () => { + const response = await getProducts(1); + const products = response.paginatedProducts; + expect(products.every((product) => !product.isAlcohol)).toBe(true); }); -test('page has 10 items', () => { - const products = fetchProducts(0); - expect(products).toHaveLength(10); +test("page has 10 items", async () => { + const response = await getProducts(1); + const products = response.paginatedProducts; + expect(products).toHaveLength(10); }); -test('products are sorted', () => { - const products = fetchProducts(0); - const ids = products.map(product => product.id); - const sortedIds = [...ids].sort((a, b) => a.localeCompare(b)); - expect(ids).toEqual(sortedIds); -}); \ No newline at end of file +test("products are sorted", async () => { + const response = await getProducts(1); + const products = response.paginatedProducts; + const ids = products.map((product) => product.id); + const sortedIds = [...ids].sort((a, b) => a.localeCompare(b)); + expect(ids).toEqual(sortedIds); +}); From bccbf734bf4078bba2c0be6e0bb0f78070603218 Mon Sep 17 00:00:00 2001 From: Vertori <115083330+Vertori@users.noreply.github.com> Date: Mon, 24 Jun 2024 13:58:02 +0200 Subject: [PATCH 5/8] Products page with displayed fetched data --- app/products/page.tsx | 193 ++++++++++++++++++++++++------------------ 1 file changed, 109 insertions(+), 84 deletions(-) diff --git a/app/products/page.tsx b/app/products/page.tsx index 65e0005..fe60dff 100644 --- a/app/products/page.tsx +++ b/app/products/page.tsx @@ -1,105 +1,130 @@ -import { Product } from "@/lib/products"; +import { getProducts } from "@/lib/products"; +import Link from "next/link"; +import { notFound } from "next/navigation"; -async function getProducts(): Promise { - const res = await fetch("http://localhost:3000/api/products"); - return res.json(); -} +export default async function Products({ + searchParams, +}: { + searchParams: { [key: string]: string | string[] | undefined }; +}) { + const currentPage = Number(searchParams?.page ?? 1); + const { + paginatedProducts, + startIndex, + endIndex, + pageCount, + totalProductsAmount, + } = await getProducts(currentPage); -export default async function Products() { - /* TODO: Create an endpoint that returns a list of products, and use that here. - */ - const products = await getProducts(); + // handling invalid page parameters (non-numeric or out of range) + if (isNaN(currentPage) || currentPage < 1 || currentPage > pageCount) { + notFound(); + } return (
-
-
-
- - - - - - - - - - - - - {products.map((product) => ( - - - - - - +
+
+
+
+
- ID - - Name - - Price - - Quantity - - Contains alcohol - - Edit -
- {product.id} - - {product.name} - - {product.price} {product.currency} - - {product.quantity} - - {product.isAlcohol ? "Yes" : "No"} -
+ + + + + + + + - ))} - -
+ ID + + Name + + Price + + Quantity + + Contains alcohol + + Edit +
- {/* TODO: Pagination */} + + + {paginatedProducts?.map((product) => ( + + + {product.id} + + + {product.name} + + + {product.price} {product.currency} + + + {product.quantity} + + + {product.isAlcohol ? "Yes" : "No"} + + + ))} + + +
+ {/* Pagination */}
From d865e745b6c8f8480c44b8450f2621f8f883a420 Mon Sep 17 00:00:00 2001 From: Vertori <115083330+Vertori@users.noreply.github.com> Date: Mon, 24 Jun 2024 16:31:21 +0200 Subject: [PATCH 6/8] Created custom not found page, modified homepage --- app/not-found.tsx | 19 +++++++++++++++++++ app/page.tsx | 10 ++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 app/not-found.tsx diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..2377c48 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,19 @@ +const CustomErrorPage = () => { + return ( +
+
+

+ 404 +

+

+ Oops! +

+

+ Page not found... +

+
+
+ ); +}; + +export default CustomErrorPage; diff --git a/app/page.tsx b/app/page.tsx index ec68f36..61a46f4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,13 +8,19 @@ export default function Home() {
-
+

Summer Camp 2024 Recruitment Task

+

by RafaƂ Fikus

- Click here to see a list of products + + Click here to see a list of products +
); From a086c50c2dc0ccbc809edc38e64f6256ab6aae50 Mon Sep 17 00:00:00 2001 From: Vertori <115083330+Vertori@users.noreply.github.com> Date: Mon, 24 Jun 2024 17:38:02 +0200 Subject: [PATCH 7/8] Moved products table and pagination to components --- app/products/page.tsx | 114 +++--------------------------- components/ProductsPagination.tsx | 56 +++++++++++++++ components/ProductsTable.tsx | 74 +++++++++++++++++++ 3 files changed, 141 insertions(+), 103 deletions(-) create mode 100644 components/ProductsPagination.tsx create mode 100644 components/ProductsTable.tsx diff --git a/app/products/page.tsx b/app/products/page.tsx index fe60dff..94b3716 100644 --- a/app/products/page.tsx +++ b/app/products/page.tsx @@ -1,5 +1,6 @@ +import ProductsPagination from "@/components/ProductsPagination"; +import ProductsTable from "@/components/productsTable"; import { getProducts } from "@/lib/products"; -import Link from "next/link"; import { notFound } from "next/navigation"; export default async function Products({ @@ -25,108 +26,15 @@ export default async function Products({
-
-
- - - - - - - - - - - - - {paginatedProducts?.map((product) => ( - - - - - - - - ))} - -
- ID - - Name - - Price - - Quantity - - Contains alcohol - - Edit -
- {product.id} - - {product.name} - - {product.price} {product.currency} - - {product.quantity} - - {product.isAlcohol ? "Yes" : "No"} -
-
- {/* Pagination */} - +
+ +
diff --git a/components/ProductsPagination.tsx b/components/ProductsPagination.tsx new file mode 100644 index 0000000..e461a0c --- /dev/null +++ b/components/ProductsPagination.tsx @@ -0,0 +1,56 @@ +import Link from "next/link"; + +interface ProductsPaginationProps { + currentPage: number; + startIndex: number; + endIndex: number; + pageCount: number; + totalProductsAmount: number; +} + +const ProductsPagination = ({ + currentPage, + startIndex, + endIndex, + pageCount, + totalProductsAmount, +}: ProductsPaginationProps) => { + return ( + + ); +}; + +export default ProductsPagination; diff --git a/components/ProductsTable.tsx b/components/ProductsTable.tsx new file mode 100644 index 0000000..d7c5113 --- /dev/null +++ b/components/ProductsTable.tsx @@ -0,0 +1,74 @@ +import { Product } from "@/types"; + +interface ProductsTableProps { + paginatedProducts: Product[]; +} + +const ProductsTable = ({ paginatedProducts }: ProductsTableProps) => { + return ( +
+ + + + + + + + + + + + + {paginatedProducts?.map((product) => ( + + + + + + + + ))} + +
+ ID + + Name + + Price + + Quantity + + Contains alcohol + + Edit +
+ {product.id} + + {product.name} + + {product.price} {product.currency} + + {product.quantity} + + {product.isAlcohol ? "Yes" : "No"} +
+
+ ); +}; + +export default ProductsTable; From 935ae0923a74173ff00dd97b3af8f65c3abadc5f Mon Sep 17 00:00:00 2001 From: Vertori <115083330+Vertori@users.noreply.github.com> Date: Tue, 25 Jun 2024 15:26:03 +0200 Subject: [PATCH 8/8] Fixed typo --- app/products/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/products/page.tsx b/app/products/page.tsx index 94b3716..ba5f2e3 100644 --- a/app/products/page.tsx +++ b/app/products/page.tsx @@ -1,5 +1,5 @@ import ProductsPagination from "@/components/ProductsPagination"; -import ProductsTable from "@/components/productsTable"; +import ProductsTable from "@/components/ProductsTable"; import { getProducts } from "@/lib/products"; import { notFound } from "next/navigation";