From 9768dec16c705f7086ff13b7f5c711d32b45a694 Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 13:31:03 +0100 Subject: [PATCH 01/31] feat: add TypeScript definition file for crew types - Create `crew.d.ts` to define types related to crew members. --- types/crew.d.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 types/crew.d.ts diff --git a/types/crew.d.ts b/types/crew.d.ts new file mode 100644 index 0000000..98479b1 --- /dev/null +++ b/types/crew.d.ts @@ -0,0 +1,21 @@ +type CrewMember = { + fullName: string; + nationality: string; + age: number; + profession: string; +}; + +type JsonCrewMember = { + firstName: string; + lastName: string; + nationality: string; + age: number; + profession: string; +}; + +type YamlCrewMember = { + name: string; + nationality: string; + years_old: number; + occupation: string; +}; From 7bbd1971db232aa4876b646c2acc08d90865eea5 Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 13:33:27 +0100 Subject: [PATCH 02/31] chore: add .idea directory to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8f322f0..9e8dccd 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.idea From f20b715b152fa98dab72a3d05132bf59b7f6533f Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 14:12:19 +0100 Subject: [PATCH 03/31] feat: implement readJsonFile and readYamlFile for static data parsing - The readJsonFile function reads, validates, and parses data from JSON files. - The readYamlFile function handles reading, validating, and parsing data from YAML files. --- lib/utils/read-files.ts | 21 +++++++++++++++++++++ package.json | 10 ++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 lib/utils/read-files.ts diff --git a/lib/utils/read-files.ts b/lib/utils/read-files.ts new file mode 100644 index 0000000..2e6adf8 --- /dev/null +++ b/lib/utils/read-files.ts @@ -0,0 +1,21 @@ +import { readFile } from "fs/promises"; +import { join, extname, normalize } from "path"; +import { parse as yamlParse } from "yaml"; + +const createSafePath = (filePath: string) => normalize(join(process.cwd(), filePath)); + +export const readJsonFile = async (filePath: string): Promise => { + const fullPath = createSafePath(filePath); + if (extname(fullPath) !== ".json") throw new Error("Invalid file type must be a json file"); + + const data = await readFile(fullPath, "utf-8"); + return JSON.parse(data); +}; + +export const readYamlFile = async (filePath: string): Promise => { + const fullPath = createSafePath(filePath); + if (extname(fullPath) !== ".yaml") throw new Error("Invalid file type must be a yaml file"); + + const data = await readFile(fullPath, "utf-8"); + return yamlParse(data); +}; diff --git a/package.json b/package.json index dd54a2f..fe179be 100644 --- a/package.json +++ b/package.json @@ -9,19 +9,21 @@ "lint": "next lint" }, "dependencies": { + "next": "13.5.6", "react": "^18", "react-dom": "^18", - "next": "13.5.6" + "yaml": "^2.3.4", + "zod": "^3.22.4" }, "devDependencies": { - "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10", + "eslint": "^8", + "eslint-config-next": "13.5.6", "postcss": "^8", "tailwindcss": "^3", - "eslint": "^8", - "eslint-config-next": "13.5.6" + "typescript": "^5" } } From 6de2c8f16f70e830432089e590eee6b34e3faca8 Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 14:21:09 +0100 Subject: [PATCH 04/31] feat: add schemas for crew member data validation --- lib/schemas/json-result-schema.ts | 16 ++++++++++++++++ lib/schemas/yaml-result-schema.ts | 14 ++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 lib/schemas/json-result-schema.ts create mode 100644 lib/schemas/yaml-result-schema.ts diff --git a/lib/schemas/json-result-schema.ts b/lib/schemas/json-result-schema.ts new file mode 100644 index 0000000..84f4d01 --- /dev/null +++ b/lib/schemas/json-result-schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export type JsonCrewMember = z.infer; +export type JsonResult = z.infer; + + +const jsonCrewMemberSchema = z.object({ + firstName: z.string(), + lastName: z.string(), + nationality: z.string(), + age: z.number().int().positive(), + profession: z.string(), +}); + +export const jsonResultSchema = z.array(jsonCrewMemberSchema); + diff --git a/lib/schemas/yaml-result-schema.ts b/lib/schemas/yaml-result-schema.ts new file mode 100644 index 0000000..b38edb1 --- /dev/null +++ b/lib/schemas/yaml-result-schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export type YamlCrewMember = z.infer; +export type YamlResult = z.infer; + +const yamlCrewMemberSchema = z.object({ + name: z.string(), + occupation: z.string(), + years_old: z.number().int().positive(), + nationality: z.string(), +}); + +export const yamlResultSchema = z.array(yamlCrewMemberSchema); + From 89beb41e2dc7e2b1297d5f977020d2d400f0de63 Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 15:23:19 +0100 Subject: [PATCH 05/31] refactor: centralize data file paths in constants.ts - Create constants.ts to manage all data file paths. --- config/constants.ts | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 config/constants.ts diff --git a/config/constants.ts b/config/constants.ts new file mode 100644 index 0000000..8932604 --- /dev/null +++ b/config/constants.ts @@ -0,0 +1,2 @@ +export const JSON_FILE_PATH = "/crew.json"; +export const YAML_FILE_PATH = "/crew.yaml"; From 54e56775d9eafec0c504d2cc319573ee467776a5 Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 15:24:18 +0100 Subject: [PATCH 06/31] refactor: update and streamline crew types - Revise crew type definitions for clarity and precision. - Remove redundant and unused crew type declarations. --- types/crew.d.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/types/crew.d.ts b/types/crew.d.ts index 98479b1..14c333c 100644 --- a/types/crew.d.ts +++ b/types/crew.d.ts @@ -4,18 +4,3 @@ type CrewMember = { age: number; profession: string; }; - -type JsonCrewMember = { - firstName: string; - lastName: string; - nationality: string; - age: number; - profession: string; -}; - -type YamlCrewMember = { - name: string; - nationality: string; - years_old: number; - occupation: string; -}; From bc67c853ed6f5dd70cc63d9dea63aa4704320171 Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 15:25:28 +0100 Subject: [PATCH 07/31] feat: add mapJsonMemberToValidCrewMember and mapYamlMemberToValidCrewMember methods - Implement mapJsonMemberToValidCrewMember to convert JSON data to a validated crew member structure. - Create mapYamlMemberToValidCrewMember for converting YAML data into a valid crew member format. --- lib/utils/crew-mappers.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 lib/utils/crew-mappers.ts diff --git a/lib/utils/crew-mappers.ts b/lib/utils/crew-mappers.ts new file mode 100644 index 0000000..919661e --- /dev/null +++ b/lib/utils/crew-mappers.ts @@ -0,0 +1,33 @@ +import type { JsonResult } from "@/lib/schemas/json-result-schema"; +import { YamlResult } from "@/lib/schemas/yaml-result-schema"; + +const validateAge = (age: number) => age >= 30 && age <= 40; + +export const mapJsonMemberToValidCrewMember = ( + data: JsonResult, +): CrewMember[] => { + return data + .map( + ({ firstName, lastName, ...rest }): CrewMember => ({ + fullName: `${firstName} ${lastName}`, + ...rest, + }), + ) + .filter(({ age }) => validateAge(age)); +}; + +export const mapYamlMemberToValidCrewMember = ( + data: YamlResult, +): CrewMember[] => { + return data + .map((member): CrewMember => { + const { name, years_old, occupation, nationality } = member; + return { + fullName: name, + age: years_old, + profession: occupation, + nationality, + }; + }) + .filter(({ age }) => validateAge(age)); +}; From 71ab0e5dfd65416752bc51745100d08caa61752e Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 15:27:40 +0100 Subject: [PATCH 08/31] feat: implement getCrewMembers to process crew data - Develop getCrewMembers function to read crew data from specified files. - Integrate data validation within getCrewMembers to ensure data integrity. - Transform validated data into standardized object format for further use. --- lib/crew.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/lib/crew.ts b/lib/crew.ts index b49024d..3c8980d 100644 --- a/lib/crew.ts +++ b/lib/crew.ts @@ -2,3 +2,31 @@ * @todo Prepare a method to return a list of crew members * @description The list should only include crew members aged 30 to 40 */ +import { readJsonFile, readYamlFile } from "@/lib/utils/read-files"; +import { JSON_FILE_PATH, YAML_FILE_PATH } from "@/config/constants"; +import { jsonResultSchema } from "@/lib/schemas/json-result-schema"; +import { yamlResultSchema } from "@/lib/schemas/yaml-result-schema"; +import { + mapJsonMemberToValidCrewMember, + mapYamlMemberToValidCrewMember, +} from "@/lib/utils/crew-mappers"; + +export const getCrewMembers = async () => { + const [jsonMember, yamlMembers] = await Promise.all([ + readJsonFile(JSON_FILE_PATH), + readYamlFile(YAML_FILE_PATH), + ]); + + const [parsedJsonMembers, parsedYamlMembers] = await Promise.all([ + jsonResultSchema.parseAsync(jsonMember), + yamlResultSchema.parseAsync(yamlMembers), + ]); + + // I could use inlined array but I prefer to use a variable to make it more readable + const validSortedMembers = [ + ...mapJsonMemberToValidCrewMember(parsedJsonMembers), + ...mapYamlMemberToValidCrewMember(parsedYamlMembers), + ].toSorted((a, b) => a.fullName.localeCompare(b.fullName)); + + return validSortedMembers; +}; From 5074aef47f4bcc894a475d3cc4cdc7c8da56dd9a Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 22:14:34 +0100 Subject: [PATCH 09/31] feat(validation): add pages for handling form validation errors --- pages/404.tsx | 14 ++++++++++++++ pages/_error.tsx | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 pages/404.tsx create mode 100644 pages/_error.tsx diff --git a/pages/404.tsx b/pages/404.tsx new file mode 100644 index 0000000..343810e --- /dev/null +++ b/pages/404.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; + +export default function Custom404() { + return ( +
+
+

404 - Page Not Found

+
+
+ Go home +
+
+ ); +} diff --git a/pages/_error.tsx b/pages/_error.tsx new file mode 100644 index 0000000..b611d3b --- /dev/null +++ b/pages/_error.tsx @@ -0,0 +1,32 @@ +import { NextPage, NextPageContext } from "next"; +import Link from "next/link"; + +interface Props { + statusCode?: number; + message?: string; +} + +const Error: NextPage = ({ statusCode , message }) => { + return ( +
+
+

+ {statusCode + ? `An error ${statusCode} occurred on server` + : "An error occurred on client"} +

+ {message &&

{message}

} +
+
+ Go home +
+
+ ); +}; + +Error.getInitialProps = ({ res, err }: NextPageContext) => { + const statusCode = res ? res.statusCode : err ? err.statusCode : 404; + return { statusCode , message: err?.message}; +}; + +export default Error; From a553a3a15e874021f080a403e44e1a60c2cb761e Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 22:16:18 +0100 Subject: [PATCH 10/31] feat(components): create new pagination component --- components/icons/LeftIcon.tsx | 17 ++++++++++ components/icons/RightIcon.tsx | 17 ++++++++++ components/pagination/Pagination.tsx | 37 ++++++++++++++++++++++ components/pagination/PaginationButton.tsx | 21 ++++++++++++ types/pagination.d.ts | 10 ++++++ 5 files changed, 102 insertions(+) create mode 100644 components/icons/LeftIcon.tsx create mode 100644 components/icons/RightIcon.tsx create mode 100644 components/pagination/Pagination.tsx create mode 100644 components/pagination/PaginationButton.tsx create mode 100644 types/pagination.d.ts diff --git a/components/icons/LeftIcon.tsx b/components/icons/LeftIcon.tsx new file mode 100644 index 0000000..f37b0b3 --- /dev/null +++ b/components/icons/LeftIcon.tsx @@ -0,0 +1,17 @@ +import { SVGProps } from "react"; + +export const LeftIcon = (props: SVGProps) => ( + +); diff --git a/components/icons/RightIcon.tsx b/components/icons/RightIcon.tsx new file mode 100644 index 0000000..a97722a --- /dev/null +++ b/components/icons/RightIcon.tsx @@ -0,0 +1,17 @@ +import { SVGProps } from "react"; + +export const RightIcon = (props: SVGProps) => ( + +); diff --git a/components/pagination/Pagination.tsx b/components/pagination/Pagination.tsx new file mode 100644 index 0000000..182ca6f --- /dev/null +++ b/components/pagination/Pagination.tsx @@ -0,0 +1,37 @@ +import { PaginationButton } from "@/components/pagination/PaginationButton"; +import { RightIcon } from "@/components/icons/RightIcon"; +import { LeftIcon } from "@/components/icons/LeftIcon"; +import { useRouter } from "next/router"; + +export const Pagination = ({ + nextPage, + previousPage, + lastPage, + currentPage, +}: Pagination) => { + const { push } = useRouter(); + const handleClick = (goTo: number | null) => { + if (!goTo) return; + push(`/task/${goTo}`); + }; + return ( + + ); +}; diff --git a/components/pagination/PaginationButton.tsx b/components/pagination/PaginationButton.tsx new file mode 100644 index 0000000..8222153 --- /dev/null +++ b/components/pagination/PaginationButton.tsx @@ -0,0 +1,21 @@ +import { ButtonHTMLAttributes } from "react"; + +interface PaginationButtonProps + extends ButtonHTMLAttributes { + srText?: string; +} + +export const PaginationButton = ({ + srText, + children, + ...rest +}: PaginationButtonProps) => ( + +); diff --git a/types/pagination.d.ts b/types/pagination.d.ts new file mode 100644 index 0000000..c871630 --- /dev/null +++ b/types/pagination.d.ts @@ -0,0 +1,10 @@ +type Pagination = { + currentPage: number; + totalPages: number; + itemsPerPage: number; + totalItems: number; + lastPage: number; + + previousPage: number | null; + nextPage: number | null; +}; From 0ef27f95922b9dc17f8d419024a8edd6bce324ce Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 22:17:54 +0100 Subject: [PATCH 11/31] feat(crew-routing): create paginated route for crew listings --- config/constants.ts | 3 ++ lib/crew.ts | 2 +- lib/utils/get-paginated-data.ts | 15 ++++++++++ lib/utils/parse-to-number.ts | 5 ++++ pages/api/crew.ts | 49 +++++++++++++++++++++++++++++++-- types/crew.d.ts | 5 ++++ 6 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 lib/utils/get-paginated-data.ts create mode 100644 lib/utils/parse-to-number.ts diff --git a/config/constants.ts b/config/constants.ts index 8932604..06e1789 100644 --- a/config/constants.ts +++ b/config/constants.ts @@ -1,2 +1,5 @@ export const JSON_FILE_PATH = "/crew.json"; export const YAML_FILE_PATH = "/crew.yaml"; + +export const DEFAULT_TAKE_ITEMS = 8; +export const APP_DOMAIN = 'http://localhost:3000'; diff --git a/lib/crew.ts b/lib/crew.ts index 3c8980d..1d274b3 100644 --- a/lib/crew.ts +++ b/lib/crew.ts @@ -22,7 +22,7 @@ export const getCrewMembers = async () => { yamlResultSchema.parseAsync(yamlMembers), ]); - // I could use inlined array but I prefer to use a variable to make it more readable + // I could use inlined array, but I prefer to use a variable to make it more readable const validSortedMembers = [ ...mapJsonMemberToValidCrewMember(parsedJsonMembers), ...mapYamlMemberToValidCrewMember(parsedYamlMembers), diff --git a/lib/utils/get-paginated-data.ts b/lib/utils/get-paginated-data.ts new file mode 100644 index 0000000..1d3c89b --- /dev/null +++ b/lib/utils/get-paginated-data.ts @@ -0,0 +1,15 @@ +type PaginatedData = { + data: T[]; + page: number; + take: number; +}; + +export const getPaginatedData = async ({ + take, + page, + data, +}: PaginatedData) => { + const start = (page - 1) * take; + const end = start + take; + return [...data].slice(start, end); +}; diff --git a/lib/utils/parse-to-number.ts b/lib/utils/parse-to-number.ts new file mode 100644 index 0000000..ec75c89 --- /dev/null +++ b/lib/utils/parse-to-number.ts @@ -0,0 +1,5 @@ +export const parseToNumber = (value:unknown) : number | null => { + const parsedValue = Number(value); + if (isNaN(parsedValue)) return null; + return parsedValue; +}; diff --git a/pages/api/crew.ts b/pages/api/crew.ts index 0f56e1f..5331101 100644 --- a/pages/api/crew.ts +++ b/pages/api/crew.ts @@ -1,10 +1,55 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { getCrewMembers } from "@/lib/crew"; +import { getPaginatedData } from "@/lib/utils/get-paginated-data"; +import { DEFAULT_TAKE_ITEMS } from "@/config/constants"; +import { parseToNumber } from "@/lib/utils/parse-to-number"; /** * @todo Prepare an endpoint to return a list of crew members * @description The endpoint should return a pagination of 8 users per page. The endpoint should accept a query parameter "page" to return the corresponding page. */ -export default function handler(req: NextApiRequest, res: NextApiResponse) { - res.status(200).json([]); +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== "GET") + return res.status(405).json({ message: "Method not allowed" }); + + const allMembers = await getCrewMembers(); + + const { page, take } = req.query; + + const currentPage = parseToNumber(page) || 1; + const howMuchItems = parseToNumber(take) || DEFAULT_TAKE_ITEMS; + + const totalPages = Math.ceil(allMembers.length / howMuchItems); + + if (currentPage > totalPages) { + return res.status(404).json({ + message: "Page is out of range", + }); + } + + const nextPage = currentPage + 1 <= totalPages ? currentPage + 1 : null; + const previousPage = currentPage - 1 > 0 ? currentPage - 1 : null; + + const paginatedData = await getPaginatedData({ + data: allMembers, + page: currentPage, + take: howMuchItems, + }); + + res.status(200).json({ + data: paginatedData, + pagination: { + currentPage, + totalPages, + totalItems: allMembers.length, + itemsPerPage: howMuchItems, + previousPage, + nextPage, + lastPage: totalPages, + } as Pagination, + }); } diff --git a/types/crew.d.ts b/types/crew.d.ts index 14c333c..c617e86 100644 --- a/types/crew.d.ts +++ b/types/crew.d.ts @@ -4,3 +4,8 @@ type CrewMember = { age: number; profession: string; }; + +type CrewResponse = { + data: CrewMember[]; + pagination: Pagination; +}; From 619eee3c582572cda024257aa92920011985334f Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 22:19:15 +0100 Subject: [PATCH 12/31] feat(crew-fetch): implement fetch method for crew data retrieval --- lib/api/fetch-api-data.ts | 31 +++++++++++++++++++++++++++++++ lib/api/get-crew.ts | 25 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 lib/api/fetch-api-data.ts create mode 100644 lib/api/get-crew.ts diff --git a/lib/api/fetch-api-data.ts b/lib/api/fetch-api-data.ts new file mode 100644 index 0000000..4016656 --- /dev/null +++ b/lib/api/fetch-api-data.ts @@ -0,0 +1,31 @@ +import {APP_DOMAIN} from "@/config/constants"; + +interface FetchOptions extends Omit { + path: string; + query?: Record; + method?: + | "GET" + | "POST" + | "PUT" + | "DELETE" + | "PATCH" +} + +export const fetchApiData = async({ + path, + headers, + query, + method = "GET", + ...fetchOptions +}: FetchOptions) => { + const queryStr = new URLSearchParams(query).toString(); + const url = `${path}${queryStr ? `?${queryStr}` : ""}`; + return fetch(`${APP_DOMAIN}${url}`, { + method, + headers: { + "Content-Type": "application/json", + ...headers, + }, + ...fetchOptions, + }); +}; diff --git a/lib/api/get-crew.ts b/lib/api/get-crew.ts new file mode 100644 index 0000000..f9727b2 --- /dev/null +++ b/lib/api/get-crew.ts @@ -0,0 +1,25 @@ +import { DEFAULT_TAKE_ITEMS } from "@/config/constants"; +import { fetchApiData } from "@/lib/api/fetch-api-data"; + +interface GetCrewPayload { + page?: number; + take?: number; +} + +export const getCrew = async ({ + page = 1, + take = DEFAULT_TAKE_ITEMS, +}: GetCrewPayload) => { + if (page <= 0 || isNaN(page) || isNaN(take)) return; + const res = await fetchApiData({ + path: "/api/crew", + query: { + take, + page, + }, + }); + const result = await res.json(); + + if (res.ok) return result as CrewResponse; + throw Error(result.message || "Something went wrong try again later.") +}; From 76d2d5965f15188f9600760ed62434936e767f06 Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 22:20:34 +0100 Subject: [PATCH 13/31] feat(CrewList): add CrewList component for displaying crew members --- components/crew-list/CrewCard.tsx | 27 +++++++++++++++++++++++++++ components/crew-list/CrewList.tsx | 15 +++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 components/crew-list/CrewCard.tsx create mode 100644 components/crew-list/CrewList.tsx diff --git a/components/crew-list/CrewCard.tsx b/components/crew-list/CrewCard.tsx new file mode 100644 index 0000000..5bc8f9b --- /dev/null +++ b/components/crew-list/CrewCard.tsx @@ -0,0 +1,27 @@ +interface CrewParagraphProps { + desc: string; + value: string; +} + +const CrewParagraph = ({ value, desc }: CrewParagraphProps) => ( +

+ {desc} + {value} +

+); + +export const CrewCard = ({ + fullName, + age, + nationality, + profession, +}: CrewMember) => { + return ( +
+ + + + +
+ ); +}; diff --git a/components/crew-list/CrewList.tsx b/components/crew-list/CrewList.tsx new file mode 100644 index 0000000..169daa2 --- /dev/null +++ b/components/crew-list/CrewList.tsx @@ -0,0 +1,15 @@ +import { CrewCard } from "@/components/crew-list/CrewCard"; + +interface CrewListProps { + crew: CrewMember[]; +} + +export const CrewList = ({ crew }: CrewListProps) => { + return ( +
+ {crew.map((member, i) => ( + + ))} +
+ ); +}; From 03c40b58c52b341e5ccca613aa9f27e8c83b160e Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 22:22:47 +0100 Subject: [PATCH 14/31] feat(crew-SSR): implement pagination for crew list using SSR --- pages/task/[page].tsx | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/pages/task/[page].tsx b/pages/task/[page].tsx index 86f1097..adb26cd 100644 --- a/pages/task/[page].tsx +++ b/pages/task/[page].tsx @@ -3,6 +3,26 @@ * @description Use tanstack/react-query or swr to fetch data from the endpoint. Prepare pagination. */ -export default function Task() { - return
Task
; +import { Pagination } from "@/components/pagination/Pagination"; +import { CrewList } from "@/components/crew-list/CrewList"; +import { getCrew } from "@/lib/api/get-crew"; +import { parseToNumber } from "@/lib/utils/parse-to-number"; +import { NextPageContext } from "next"; + +export const getServerSideProps = async ({ + query: { page }, +}: NextPageContext) => { + const parsedPage = parseToNumber(page); + if (!parsedPage) return { notFound: true }; + const data = await getCrew({ page: parsedPage }); + return { props: data }; +}; + +export default function Task({ data, pagination }: CrewResponse) { + return ( +
+ + +
+ ); } From cece3c5db46a50f78b1cf7462600ce8cf638fc75 Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 23:31:01 +0100 Subject: [PATCH 15/31] style: update min-h styles for improved layout consistency --- pages/404.tsx | 2 +- pages/_app.tsx | 14 +++++++++++--- pages/_error.tsx | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/pages/404.tsx b/pages/404.tsx index 343810e..c01600d 100644 --- a/pages/404.tsx +++ b/pages/404.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; export default function Custom404() { return ( -
+

404 - Page Not Found

diff --git a/pages/_app.tsx b/pages/_app.tsx index 021681f..500d417 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,6 +1,14 @@ -import '@/styles/globals.css' -import type { AppProps } from 'next/app' +import "@/styles/globals.css"; +import type { AppProps } from "next/app"; +import { QueryClient } from "@tanstack/query-core"; +import { QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient(); export default function App({ Component, pageProps }: AppProps) { - return + return ( + + + + ); } diff --git a/pages/_error.tsx b/pages/_error.tsx index b611d3b..3843a24 100644 --- a/pages/_error.tsx +++ b/pages/_error.tsx @@ -8,7 +8,7 @@ interface Props { const Error: NextPage = ({ statusCode , message }) => { return ( -
+

{statusCode From d587b7b66d4f23d2e2f2564a42f952a8c080b7a4 Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 23:31:55 +0100 Subject: [PATCH 16/31] feat: implement react-query for data fetching --- components/pagination/PaginationButton.tsx | 11 +++++++++-- package.json | 2 ++ pages/index.tsx | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/components/pagination/PaginationButton.tsx b/components/pagination/PaginationButton.tsx index 8222153..8428384 100644 --- a/components/pagination/PaginationButton.tsx +++ b/components/pagination/PaginationButton.tsx @@ -1,4 +1,5 @@ import { ButtonHTMLAttributes } from "react"; +import { twMerge } from "tailwind-merge"; interface PaginationButtonProps extends ButtonHTMLAttributes { @@ -8,11 +9,17 @@ interface PaginationButtonProps export const PaginationButton = ({ srText, children, - ...rest + className, + ...rest }: PaginationButtonProps) => ( ); diff --git a/config/constants.ts b/config/constants.ts index 60b0a5e..9f8bcef 100644 --- a/config/constants.ts +++ b/config/constants.ts @@ -1,4 +1,4 @@ -export const JSON_FILE_PATH = "/crew.json"; -export const YAML_FILE_PATH = "/crew.yaml"; +export const JSON_FILE_PATH = '/crew.json'; +export const YAML_FILE_PATH = '/crew.yaml'; export const DEFAULT_TAKE_ITEMS = 8; diff --git a/lib/api/fetch-api-data.ts b/lib/api/fetch-api-data.ts index 946c30e..2b75cf6 100644 --- a/lib/api/fetch-api-data.ts +++ b/lib/api/fetch-api-data.ts @@ -1,22 +1,22 @@ -interface FetchOptions extends Omit { +interface FetchOptions extends Omit { path: string; query?: Record; - method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; } export const fetchApiData = async ({ path, headers, query, - method = "GET", + method = 'GET', ...fetchOptions }: FetchOptions) => { const queryStr = new URLSearchParams(query).toString(); - const url = `${path}${queryStr ? `?${queryStr}` : ""}`; + const url = `${path}${queryStr ? `?${queryStr}` : ''}`; return fetch(url, { method, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...headers, }, ...fetchOptions, diff --git a/lib/api/get-crew.ts b/lib/api/get-crew.ts index f9727b2..9841d0c 100644 --- a/lib/api/get-crew.ts +++ b/lib/api/get-crew.ts @@ -1,5 +1,5 @@ -import { DEFAULT_TAKE_ITEMS } from "@/config/constants"; -import { fetchApiData } from "@/lib/api/fetch-api-data"; +import { DEFAULT_TAKE_ITEMS } from '@/config/constants'; +import { fetchApiData } from '@/lib/api/fetch-api-data'; interface GetCrewPayload { page?: number; @@ -12,7 +12,7 @@ export const getCrew = async ({ }: GetCrewPayload) => { if (page <= 0 || isNaN(page) || isNaN(take)) return; const res = await fetchApiData({ - path: "/api/crew", + path: '/api/crew', query: { take, page, @@ -21,5 +21,5 @@ export const getCrew = async ({ const result = await res.json(); if (res.ok) return result as CrewResponse; - throw Error(result.message || "Something went wrong try again later.") + throw Error(result.message || 'Something went wrong try again later.'); }; diff --git a/lib/crew.ts b/lib/crew.ts index 1d274b3..175bd0f 100644 --- a/lib/crew.ts +++ b/lib/crew.ts @@ -2,14 +2,14 @@ * @todo Prepare a method to return a list of crew members * @description The list should only include crew members aged 30 to 40 */ -import { readJsonFile, readYamlFile } from "@/lib/utils/read-files"; -import { JSON_FILE_PATH, YAML_FILE_PATH } from "@/config/constants"; -import { jsonResultSchema } from "@/lib/schemas/json-result-schema"; -import { yamlResultSchema } from "@/lib/schemas/yaml-result-schema"; +import { JSON_FILE_PATH, YAML_FILE_PATH } from '@/config/constants'; +import { jsonResultSchema } from '@/lib/schemas/json-result-schema'; +import { yamlResultSchema } from '@/lib/schemas/yaml-result-schema'; import { mapJsonMemberToValidCrewMember, mapYamlMemberToValidCrewMember, -} from "@/lib/utils/crew-mappers"; +} from '@/lib/utils/crew-mappers'; +import { readJsonFile, readYamlFile } from '@/lib/utils/read-files'; export const getCrewMembers = async () => { const [jsonMember, yamlMembers] = await Promise.all([ diff --git a/lib/hooks/usePagination.ts b/lib/hooks/usePagination.ts index 11d0198..d88dfc2 100644 --- a/lib/hooks/usePagination.ts +++ b/lib/hooks/usePagination.ts @@ -1,16 +1,16 @@ -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { useRouter } from "next/router"; -import { parseToNumber } from "@/lib/utils/parse-to-number"; -import { getCrew } from "@/lib/api/get-crew"; +import { useRouter } from 'next/router'; + +import { getCrew } from '@/lib/api/get-crew'; +import { parseToNumber } from '@/lib/utils/parse-to-number'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; export const usePagination = () => { const { query } = useRouter(); const page = parseToNumber(query.page) || 1; const { data, isLoading, isError, error } = useQuery({ - queryKey: ["crew", page], + queryKey: ['crew', page], queryFn: () => getCrew({ page }), placeholderData: keepPreviousData, - }); return { data, isLoading, isError, error }; }; diff --git a/lib/schemas/json-result-schema.ts b/lib/schemas/json-result-schema.ts index 84f4d01..db6c131 100644 --- a/lib/schemas/json-result-schema.ts +++ b/lib/schemas/json-result-schema.ts @@ -1,9 +1,8 @@ -import { z } from "zod"; +import { z } from 'zod'; export type JsonCrewMember = z.infer; export type JsonResult = z.infer; - const jsonCrewMemberSchema = z.object({ firstName: z.string(), lastName: z.string(), @@ -13,4 +12,3 @@ const jsonCrewMemberSchema = z.object({ }); export const jsonResultSchema = z.array(jsonCrewMemberSchema); - diff --git a/lib/schemas/yaml-result-schema.ts b/lib/schemas/yaml-result-schema.ts index b38edb1..6dff515 100644 --- a/lib/schemas/yaml-result-schema.ts +++ b/lib/schemas/yaml-result-schema.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from 'zod'; export type YamlCrewMember = z.infer; export type YamlResult = z.infer; @@ -11,4 +11,3 @@ const yamlCrewMemberSchema = z.object({ }); export const yamlResultSchema = z.array(yamlCrewMemberSchema); - diff --git a/lib/utils/crew-mappers.ts b/lib/utils/crew-mappers.ts index 919661e..3a05e12 100644 --- a/lib/utils/crew-mappers.ts +++ b/lib/utils/crew-mappers.ts @@ -1,5 +1,5 @@ -import type { JsonResult } from "@/lib/schemas/json-result-schema"; -import { YamlResult } from "@/lib/schemas/yaml-result-schema"; +import type { JsonResult } from '@/lib/schemas/json-result-schema'; +import { YamlResult } from '@/lib/schemas/yaml-result-schema'; const validateAge = (age: number) => age >= 30 && age <= 40; diff --git a/lib/utils/parse-to-number.ts b/lib/utils/parse-to-number.ts index ec75c89..6d087bf 100644 --- a/lib/utils/parse-to-number.ts +++ b/lib/utils/parse-to-number.ts @@ -1,5 +1,5 @@ -export const parseToNumber = (value:unknown) : number | null => { - const parsedValue = Number(value); - if (isNaN(parsedValue)) return null; - return parsedValue; +export const parseToNumber = (value: unknown): number | null => { + const parsedValue = Number(value); + if (isNaN(parsedValue)) return null; + return parsedValue; }; diff --git a/lib/utils/read-files.ts b/lib/utils/read-files.ts index 2e6adf8..3811043 100644 --- a/lib/utils/read-files.ts +++ b/lib/utils/read-files.ts @@ -1,21 +1,24 @@ -import { readFile } from "fs/promises"; -import { join, extname, normalize } from "path"; -import { parse as yamlParse } from "yaml"; +import { readFile } from 'fs/promises'; +import { extname, join, normalize } from 'path'; +import { parse as yamlParse } from 'yaml'; -const createSafePath = (filePath: string) => normalize(join(process.cwd(), filePath)); +const createSafePath = (filePath: string) => + normalize(join(process.cwd(), filePath)); export const readJsonFile = async (filePath: string): Promise => { const fullPath = createSafePath(filePath); - if (extname(fullPath) !== ".json") throw new Error("Invalid file type must be a json file"); + if (extname(fullPath) !== '.json') + throw new Error('Invalid file type must be a json file'); - const data = await readFile(fullPath, "utf-8"); + const data = await readFile(fullPath, 'utf-8'); return JSON.parse(data); }; export const readYamlFile = async (filePath: string): Promise => { const fullPath = createSafePath(filePath); - if (extname(fullPath) !== ".yaml") throw new Error("Invalid file type must be a yaml file"); + if (extname(fullPath) !== '.yaml') + throw new Error('Invalid file type must be a yaml file'); - const data = await readFile(fullPath, "utf-8"); + const data = await readFile(fullPath, 'utf-8'); return yamlParse(data); }; diff --git a/next.config.js b/next.config.js index a843cbe..91ef62f 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, -} +}; -module.exports = nextConfig +module.exports = nextConfig; diff --git a/pages/404.tsx b/pages/404.tsx index c01600d..1b04826 100644 --- a/pages/404.tsx +++ b/pages/404.tsx @@ -1,13 +1,13 @@ -import Link from "next/link"; +import Link from 'next/link'; export default function Custom404() { return ( -
-
-

404 - Page Not Found

+
+
+

404 - Page Not Found

-
- Go home +
+ Go home
); diff --git a/pages/_app.tsx b/pages/_app.tsx index 500d417..ff638b2 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,7 +1,8 @@ -import "@/styles/globals.css"; -import type { AppProps } from "next/app"; -import { QueryClient } from "@tanstack/query-core"; -import { QueryClientProvider } from "@tanstack/react-query"; +import type { AppProps } from 'next/app'; + +import '@/styles/globals.css'; +import { QueryClient } from '@tanstack/query-core'; +import { QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient(); diff --git a/pages/_document.tsx b/pages/_document.tsx index 54e8bf3..626f034 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -1,13 +1,13 @@ -import { Html, Head, Main, NextScript } from 'next/document' +import { Head, Html, Main, NextScript } from 'next/document'; export default function Document() { return ( - +
- ) + ); } diff --git a/pages/_error.tsx b/pages/_error.tsx index 3843a24..6c08d6f 100644 --- a/pages/_error.tsx +++ b/pages/_error.tsx @@ -1,24 +1,24 @@ -import { NextPage, NextPageContext } from "next"; -import Link from "next/link"; +import { NextPage, NextPageContext } from 'next'; +import Link from 'next/link'; interface Props { statusCode?: number; message?: string; } -const Error: NextPage = ({ statusCode , message }) => { +const Error: NextPage = ({ statusCode, message }) => { return ( -
-
-

+
+
+

{statusCode ? `An error ${statusCode} occurred on server` - : "An error occurred on client"} + : 'An error occurred on client'}

- {message &&

{message}

} + {message &&

{message}

}
-
- Go home +
+ Go home
); @@ -26,7 +26,7 @@ const Error: NextPage = ({ statusCode , message }) => { Error.getInitialProps = ({ res, err }: NextPageContext) => { const statusCode = res ? res.statusCode : err ? err.statusCode : 404; - return { statusCode , message: err?.message}; + return { statusCode, message: err?.message }; }; export default Error; diff --git a/pages/api/crew.ts b/pages/api/crew.ts index 5331101..a5fa837 100644 --- a/pages/api/crew.ts +++ b/pages/api/crew.ts @@ -1,8 +1,9 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { getCrewMembers } from "@/lib/crew"; -import { getPaginatedData } from "@/lib/utils/get-paginated-data"; -import { DEFAULT_TAKE_ITEMS } from "@/config/constants"; -import { parseToNumber } from "@/lib/utils/parse-to-number"; +import type { NextApiRequest, NextApiResponse } from 'next'; + +import { DEFAULT_TAKE_ITEMS } from '@/config/constants'; +import { getCrewMembers } from '@/lib/crew'; +import { getPaginatedData } from '@/lib/utils/get-paginated-data'; +import { parseToNumber } from '@/lib/utils/parse-to-number'; /** * @todo Prepare an endpoint to return a list of crew members @@ -13,8 +14,8 @@ export default async function handler( req: NextApiRequest, res: NextApiResponse, ) { - if (req.method !== "GET") - return res.status(405).json({ message: "Method not allowed" }); + if (req.method !== 'GET') + return res.status(405).json({ message: 'Method not allowed' }); const allMembers = await getCrewMembers(); @@ -27,7 +28,7 @@ export default async function handler( if (currentPage > totalPages) { return res.status(404).json({ - message: "Page is out of range", + message: 'Page is out of range', }); } diff --git a/pages/index.tsx b/pages/index.tsx index 0b0f000..d8368f5 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,20 +1,24 @@ -import { Inter } from "next/font/google"; -import Link from "next/link"; +import { Inter } from 'next/font/google'; +import Link from 'next/link'; +import { twMerge } from 'tailwind-merge'; -const inter = Inter({ subsets: ["latin"] }); +const inter = Inter({ subsets: ['latin'] }); export default function Home() { return (
-
-

+
+

Winter Camp 2024 Recruitment Task

-
- Go to task +
+ Go to task

); diff --git a/pages/task/[page].tsx b/pages/task/[page].tsx index c9fd682..649f79f 100644 --- a/pages/task/[page].tsx +++ b/pages/task/[page].tsx @@ -2,12 +2,13 @@ * @todo List crew members using the endpoint you created * @description Use tanstack/react-query or swr to fetch data from the endpoint. Prepare pagination. */ -import Link from "next/link"; -import { Pagination } from "@/components/pagination/Pagination"; -import { CrewList } from "@/components/crew-list/CrewList"; -import { CrewLayout } from "@/components/CrewLayout"; -import { LoadingIcon } from "@/components/icons/LoadingIcon"; -import { usePagination } from "@/lib/hooks/usePagination"; +import Link from 'next/link'; + +import { CrewLayout } from '@/components/CrewLayout'; +import { CrewList } from '@/components/crew-list/CrewList'; +import { LoadingIcon } from '@/components/icons/LoadingIcon'; +import { Pagination } from '@/components/pagination/Pagination'; +import { usePagination } from '@/lib/hooks/usePagination'; export default function Task() { const { isLoading, isError, error, data } = usePagination(); @@ -25,17 +26,17 @@ export default function Task() { const LoadingComponent = () => ( - + ); const ErrorComponent = ({ error }: { error: Error | null }) => ( - +
-

Error

-

{error?.message}

-
- Go home +

Error

+

{error?.message}

+
+ Go home
diff --git a/postcss.config.js b/postcss.config.js index 33ad091..12a703d 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -3,4 +3,4 @@ module.exports = { tailwindcss: {}, autoprefixer: {}, }, -} +}; diff --git a/tailwind.config.ts b/tailwind.config.ts index c7ead80..2686392 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,4 +1,4 @@ -import type { Config } from 'tailwindcss' +import type { Config } from 'tailwindcss'; const config: Config = { content: [ @@ -16,5 +16,5 @@ const config: Config = { }, }, plugins: [], -} -export default config +}; +export default config; diff --git a/types/crew.d.ts b/types/crew.d.ts index c617e86..98cd46f 100644 --- a/types/crew.d.ts +++ b/types/crew.d.ts @@ -1,11 +1,11 @@ type CrewMember = { - fullName: string; - nationality: string; - age: number; - profession: string; + fullName: string; + nationality: string; + age: number; + profession: string; }; type CrewResponse = { - data: CrewMember[]; - pagination: Pagination; + data: CrewMember[]; + pagination: Pagination; }; diff --git a/types/pagination.d.ts b/types/pagination.d.ts index c871630..86c980d 100644 --- a/types/pagination.d.ts +++ b/types/pagination.d.ts @@ -1,10 +1,10 @@ type Pagination = { - currentPage: number; - totalPages: number; - itemsPerPage: number; - totalItems: number; - lastPage: number; + currentPage: number; + totalPages: number; + itemsPerPage: number; + totalItems: number; + lastPage: number; - previousPage: number | null; - nextPage: number | null; + previousPage: number | null; + nextPage: number | null; }; From 47402b1fb5478100fe8c5631575291112f2610d4 Mon Sep 17 00:00:00 2001 From: xyashino Date: Wed, 27 Dec 2023 23:45:22 +0100 Subject: [PATCH 21/31] feat: add param validation in getServerSideProps for enhanced security --- pages/task/[page].tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pages/task/[page].tsx b/pages/task/[page].tsx index 649f79f..a65ebce 100644 --- a/pages/task/[page].tsx +++ b/pages/task/[page].tsx @@ -2,6 +2,7 @@ * @todo List crew members using the endpoint you created * @description Use tanstack/react-query or swr to fetch data from the endpoint. Prepare pagination. */ +import { NextPageContext } from 'next'; import Link from 'next/link'; import { CrewLayout } from '@/components/CrewLayout'; @@ -9,6 +10,19 @@ import { CrewList } from '@/components/crew-list/CrewList'; import { LoadingIcon } from '@/components/icons/LoadingIcon'; import { Pagination } from '@/components/pagination/Pagination'; import { usePagination } from '@/lib/hooks/usePagination'; +import { parseToNumber } from '@/lib/utils/parse-to-number'; + +export const getServerSideProps = async ({ + query: { page }, +}: NextPageContext) => { + const parsedPage = parseToNumber(page); + if (!parsedPage) return { notFound: true }; + return { + props: { + page: parsedPage, + }, + }; +}; export default function Task() { const { isLoading, isError, error, data } = usePagination(); From 2358ad5a97d697ae37413c047be05f3bfc570a42 Mon Sep 17 00:00:00 2001 From: xyashino Date: Thu, 28 Dec 2023 00:01:59 +0100 Subject: [PATCH 22/31] refactor: rename 'usePagination' to 'useReactQueryPagination' for clarity --- lib/hooks/{usePagination.ts => useReactQueryPagination.ts} | 2 +- pages/task/[page].tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename lib/hooks/{usePagination.ts => useReactQueryPagination.ts} (91%) diff --git a/lib/hooks/usePagination.ts b/lib/hooks/useReactQueryPagination.ts similarity index 91% rename from lib/hooks/usePagination.ts rename to lib/hooks/useReactQueryPagination.ts index d88dfc2..c7a061b 100644 --- a/lib/hooks/usePagination.ts +++ b/lib/hooks/useReactQueryPagination.ts @@ -4,7 +4,7 @@ import { getCrew } from '@/lib/api/get-crew'; import { parseToNumber } from '@/lib/utils/parse-to-number'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; -export const usePagination = () => { +export const useReactQueryPagination = () => { const { query } = useRouter(); const page = parseToNumber(query.page) || 1; const { data, isLoading, isError, error } = useQuery({ diff --git a/pages/task/[page].tsx b/pages/task/[page].tsx index a65ebce..fbbc700 100644 --- a/pages/task/[page].tsx +++ b/pages/task/[page].tsx @@ -9,7 +9,7 @@ import { CrewLayout } from '@/components/CrewLayout'; import { CrewList } from '@/components/crew-list/CrewList'; import { LoadingIcon } from '@/components/icons/LoadingIcon'; import { Pagination } from '@/components/pagination/Pagination'; -import { usePagination } from '@/lib/hooks/usePagination'; +import { useReactQueryPagination } from '@/lib/hooks/useReactQueryPagination'; import { parseToNumber } from '@/lib/utils/parse-to-number'; export const getServerSideProps = async ({ @@ -25,7 +25,7 @@ export const getServerSideProps = async ({ }; export default function Task() { - const { isLoading, isError, error, data } = usePagination(); + const { isLoading, isError, error, data } = useReactQueryPagination(); if (isError) return ; if (isLoading || !data) return ; From 6632a84e0a3aa7cb9e984aa08beb107eae991325 Mon Sep 17 00:00:00 2001 From: xyashino Date: Thu, 28 Dec 2023 00:05:57 +0100 Subject: [PATCH 23/31] refactor: move logic from 'Pagination' to 'usePaginationLogic' hook for modularity --- components/pagination/Pagination.tsx | 25 +++++++++---------------- lib/hooks/usePaginationLogic.ts | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 16 deletions(-) create mode 100644 lib/hooks/usePaginationLogic.ts diff --git a/components/pagination/Pagination.tsx b/components/pagination/Pagination.tsx index e53c5ab..3847344 100644 --- a/components/pagination/Pagination.tsx +++ b/components/pagination/Pagination.tsx @@ -1,8 +1,7 @@ -import { useRouter } from 'next/router'; - import { LeftIcon } from '@/components/icons/LeftIcon'; import { RightIcon } from '@/components/icons/RightIcon'; import { PaginationButton } from '@/components/pagination/PaginationButton'; +import { usePaginationLogic } from '@/lib/hooks/usePaginationLogic'; export const Pagination = ({ nextPage, @@ -10,20 +9,17 @@ export const Pagination = ({ lastPage, currentPage, }: Pagination) => { - const { push } = useRouter(); - const handleClick = (goTo: number | null) => { - if (!goTo) return; - push(`/task/${goTo}`); - }; + const { goToPreviousPage, goToNextPage, changePage } = usePaginationLogic({ + nextPage, + previousPage, + lastPage, + }); return ( diff --git a/lib/hooks/usePaginationLogic.ts b/lib/hooks/usePaginationLogic.ts new file mode 100644 index 0000000..6a03dcf --- /dev/null +++ b/lib/hooks/usePaginationLogic.ts @@ -0,0 +1,24 @@ +import { useRouter } from 'next/router'; + +type PaginationPayload = Pick< + Pagination, + 'nextPage' | 'previousPage' | 'lastPage' +>; + +export const usePaginationLogic = ({ + nextPage, + previousPage, + lastPage, +}: PaginationPayload) => { + const { push } = useRouter(); + const changePage = (page: number | null) => { + if (!page || page <= 0 || page > lastPage) return; + return push(`/task/${page}`); + }; + + const goToPreviousPage = () => changePage(previousPage); + + const goToNextPage = () => changePage(nextPage); + + return { goToPreviousPage, goToNextPage, changePage }; +}; From 65544f0bf76a233f42feb6722a5a122b22c23dc9 Mon Sep 17 00:00:00 2001 From: xyashino Date: Thu, 28 Dec 2023 10:51:26 +0100 Subject: [PATCH 24/31] refactor: improve variable and method naming conventions - Update names to be more descriptive and align with project standards - Remove unused methods for cleaner code maintenance - Fix minor issues identified during the refactor BREAKING CHANGE: Renaming may affect dependent modules. Ensure compatibility before integrating. --- components/pagination/Pagination.tsx | 2 +- config/constants.ts | 7 ++++--- lib/api/get-crew.ts | 4 ++-- lib/crew.ts | 16 ++++++++-------- lib/hooks/usePaginationLogic.ts | 2 +- lib/schemas/json-result-schema.ts | 1 - lib/schemas/yaml-result-schema.ts | 1 - ...aginated-data.ts => create-paginated-data.ts} | 6 +++--- lib/utils/crew-mappers.ts | 11 ++++++----- pages/api/crew.ts | 12 ++++++------ pages/task/[page].tsx | 1 + types/crew.d.ts | 2 +- types/pagination.d.ts | 2 +- 13 files changed, 34 insertions(+), 33 deletions(-) rename lib/utils/{get-paginated-data.ts => create-paginated-data.ts} (62%) diff --git a/components/pagination/Pagination.tsx b/components/pagination/Pagination.tsx index 3847344..43f4a88 100644 --- a/components/pagination/Pagination.tsx +++ b/components/pagination/Pagination.tsx @@ -8,7 +8,7 @@ export const Pagination = ({ previousPage, lastPage, currentPage, -}: Pagination) => { +}: PaginationResponse) => { const { goToPreviousPage, goToNextPage, changePage } = usePaginationLogic({ nextPage, previousPage, diff --git a/config/constants.ts b/config/constants.ts index 9f8bcef..21c71a6 100644 --- a/config/constants.ts +++ b/config/constants.ts @@ -1,4 +1,5 @@ -export const JSON_FILE_PATH = '/crew.json'; -export const YAML_FILE_PATH = '/crew.yaml'; +// PATHS WILL BE COUNTED FROM ROOT OF PROJECT +export const JSON_CREW_FILE_PATH = '/crew.json'; +export const YAML_CREW_FILE_PATH = '/crew.yaml'; -export const DEFAULT_TAKE_ITEMS = 8; +export const CREW_MEMBERS_PER_PAGE = 8; diff --git a/lib/api/get-crew.ts b/lib/api/get-crew.ts index 9841d0c..2025561 100644 --- a/lib/api/get-crew.ts +++ b/lib/api/get-crew.ts @@ -1,4 +1,4 @@ -import { DEFAULT_TAKE_ITEMS } from '@/config/constants'; +import { CREW_MEMBERS_PER_PAGE } from '@/config/constants'; import { fetchApiData } from '@/lib/api/fetch-api-data'; interface GetCrewPayload { @@ -8,7 +8,7 @@ interface GetCrewPayload { export const getCrew = async ({ page = 1, - take = DEFAULT_TAKE_ITEMS, + take = CREW_MEMBERS_PER_PAGE, }: GetCrewPayload) => { if (page <= 0 || isNaN(page) || isNaN(take)) return; const res = await fetchApiData({ diff --git a/lib/crew.ts b/lib/crew.ts index 175bd0f..353b230 100644 --- a/lib/crew.ts +++ b/lib/crew.ts @@ -2,7 +2,7 @@ * @todo Prepare a method to return a list of crew members * @description The list should only include crew members aged 30 to 40 */ -import { JSON_FILE_PATH, YAML_FILE_PATH } from '@/config/constants'; +import { JSON_CREW_FILE_PATH, YAML_CREW_FILE_PATH } from '@/config/constants'; import { jsonResultSchema } from '@/lib/schemas/json-result-schema'; import { yamlResultSchema } from '@/lib/schemas/yaml-result-schema'; import { @@ -11,10 +11,13 @@ import { } from '@/lib/utils/crew-mappers'; import { readJsonFile, readYamlFile } from '@/lib/utils/read-files'; +const sortByFullName = (a: T, b: T) => + a.fullName.localeCompare(b.fullName); + export const getCrewMembers = async () => { const [jsonMember, yamlMembers] = await Promise.all([ - readJsonFile(JSON_FILE_PATH), - readYamlFile(YAML_FILE_PATH), + readJsonFile(JSON_CREW_FILE_PATH), + readYamlFile(YAML_CREW_FILE_PATH), ]); const [parsedJsonMembers, parsedYamlMembers] = await Promise.all([ @@ -22,11 +25,8 @@ export const getCrewMembers = async () => { yamlResultSchema.parseAsync(yamlMembers), ]); - // I could use inlined array, but I prefer to use a variable to make it more readable - const validSortedMembers = [ + return [ ...mapJsonMemberToValidCrewMember(parsedJsonMembers), ...mapYamlMemberToValidCrewMember(parsedYamlMembers), - ].toSorted((a, b) => a.fullName.localeCompare(b.fullName)); - - return validSortedMembers; + ].toSorted(sortByFullName); }; diff --git a/lib/hooks/usePaginationLogic.ts b/lib/hooks/usePaginationLogic.ts index 6a03dcf..8b1ddf2 100644 --- a/lib/hooks/usePaginationLogic.ts +++ b/lib/hooks/usePaginationLogic.ts @@ -1,7 +1,7 @@ import { useRouter } from 'next/router'; type PaginationPayload = Pick< - Pagination, + PaginationResponse, 'nextPage' | 'previousPage' | 'lastPage' >; diff --git a/lib/schemas/json-result-schema.ts b/lib/schemas/json-result-schema.ts index db6c131..de760ac 100644 --- a/lib/schemas/json-result-schema.ts +++ b/lib/schemas/json-result-schema.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; -export type JsonCrewMember = z.infer; export type JsonResult = z.infer; const jsonCrewMemberSchema = z.object({ diff --git a/lib/schemas/yaml-result-schema.ts b/lib/schemas/yaml-result-schema.ts index 6dff515..09d7547 100644 --- a/lib/schemas/yaml-result-schema.ts +++ b/lib/schemas/yaml-result-schema.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; -export type YamlCrewMember = z.infer; export type YamlResult = z.infer; const yamlCrewMemberSchema = z.object({ diff --git a/lib/utils/get-paginated-data.ts b/lib/utils/create-paginated-data.ts similarity index 62% rename from lib/utils/get-paginated-data.ts rename to lib/utils/create-paginated-data.ts index 1d3c89b..82584f5 100644 --- a/lib/utils/get-paginated-data.ts +++ b/lib/utils/create-paginated-data.ts @@ -1,14 +1,14 @@ -type PaginatedData = { +type PaginatedDataPayload = { data: T[]; page: number; take: number; }; -export const getPaginatedData = async ({ +export const createPaginatedData = ({ take, page, data, -}: PaginatedData) => { +}: PaginatedDataPayload) => { const start = (page - 1) * take; const end = start + take; return [...data].slice(start, end); diff --git a/lib/utils/crew-mappers.ts b/lib/utils/crew-mappers.ts index 3a05e12..087be33 100644 --- a/lib/utils/crew-mappers.ts +++ b/lib/utils/crew-mappers.ts @@ -1,7 +1,8 @@ import type { JsonResult } from '@/lib/schemas/json-result-schema'; -import { YamlResult } from '@/lib/schemas/yaml-result-schema'; +import type { YamlResult } from '@/lib/schemas/yaml-result-schema'; -const validateAge = (age: number) => age >= 30 && age <= 40; +const validateByAge = ({ age }: T) => + age >= 30 && age <= 40; export const mapJsonMemberToValidCrewMember = ( data: JsonResult, @@ -13,7 +14,7 @@ export const mapJsonMemberToValidCrewMember = ( ...rest, }), ) - .filter(({ age }) => validateAge(age)); + .filter(validateByAge); }; export const mapYamlMemberToValidCrewMember = ( @@ -24,10 +25,10 @@ export const mapYamlMemberToValidCrewMember = ( const { name, years_old, occupation, nationality } = member; return { fullName: name, + nationality, age: years_old, profession: occupation, - nationality, }; }) - .filter(({ age }) => validateAge(age)); + .filter(validateByAge); }; diff --git a/pages/api/crew.ts b/pages/api/crew.ts index a5fa837..5035c32 100644 --- a/pages/api/crew.ts +++ b/pages/api/crew.ts @@ -1,8 +1,8 @@ import type { NextApiRequest, NextApiResponse } from 'next'; -import { DEFAULT_TAKE_ITEMS } from '@/config/constants'; +import { CREW_MEMBERS_PER_PAGE } from '@/config/constants'; import { getCrewMembers } from '@/lib/crew'; -import { getPaginatedData } from '@/lib/utils/get-paginated-data'; +import { createPaginatedData } from '@/lib/utils/create-paginated-data'; import { parseToNumber } from '@/lib/utils/parse-to-number'; /** @@ -22,7 +22,7 @@ export default async function handler( const { page, take } = req.query; const currentPage = parseToNumber(page) || 1; - const howMuchItems = parseToNumber(take) || DEFAULT_TAKE_ITEMS; + const howMuchItems = parseToNumber(take) || CREW_MEMBERS_PER_PAGE; const totalPages = Math.ceil(allMembers.length / howMuchItems); @@ -35,7 +35,7 @@ export default async function handler( const nextPage = currentPage + 1 <= totalPages ? currentPage + 1 : null; const previousPage = currentPage - 1 > 0 ? currentPage - 1 : null; - const paginatedData = await getPaginatedData({ + const paginatedData = createPaginatedData({ data: allMembers, page: currentPage, take: howMuchItems, @@ -51,6 +51,6 @@ export default async function handler( previousPage, nextPage, lastPage: totalPages, - } as Pagination, - }); + }, + } as CrewResponse); } diff --git a/pages/task/[page].tsx b/pages/task/[page].tsx index fbbc700..2b190cc 100644 --- a/pages/task/[page].tsx +++ b/pages/task/[page].tsx @@ -26,6 +26,7 @@ export const getServerSideProps = async ({ export default function Task() { const { isLoading, isError, error, data } = useReactQueryPagination(); + if (isError) return ; if (isLoading || !data) return ; diff --git a/types/crew.d.ts b/types/crew.d.ts index 98cd46f..a3a7b9c 100644 --- a/types/crew.d.ts +++ b/types/crew.d.ts @@ -7,5 +7,5 @@ type CrewMember = { type CrewResponse = { data: CrewMember[]; - pagination: Pagination; + pagination: PaginationResponse; }; diff --git a/types/pagination.d.ts b/types/pagination.d.ts index 86c980d..2e11219 100644 --- a/types/pagination.d.ts +++ b/types/pagination.d.ts @@ -1,4 +1,4 @@ -type Pagination = { +type PaginationResponse = { currentPage: number; totalPages: number; itemsPerPage: number; From 4b59cb51e6811a8853d5b4f28a7aa1b850f51bfc Mon Sep 17 00:00:00 2001 From: xyashino Date: Mon, 1 Jan 2024 19:41:48 +0100 Subject: [PATCH 25/31] feat: install React Testing Library and Vitest, set up basic testing configuration - Added React Testing Library and Vitest as dev dependencies - Configured Vitest for the project - Established a basic structure for writing and running tests --- __tests__/setup.ts | 10 ++++++++++ package.json | 9 +++++++-- vitest.config.mts | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 __tests__/setup.ts create mode 100644 vitest.config.mts diff --git a/__tests__/setup.ts b/__tests__/setup.ts new file mode 100644 index 0000000..14c0ce0 --- /dev/null +++ b/__tests__/setup.ts @@ -0,0 +1,10 @@ +import { expect, afterEach } from 'vitest'; +import { cleanup } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; + +expect.extend(matchers); + + +afterEach(() => { + cleanup(); +}); diff --git a/package.json b/package.json index 2cfec5c..a63f0ee 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "format": "prettier --write ." + "format": "prettier --write .", + "test": "vitest" }, "dependencies": { "@tanstack/react-query": "^5.15.0", @@ -19,6 +20,8 @@ "zod": "^3.22.4" }, "devDependencies": { + "@testing-library/jest-dom": "^6.1.6", + "@testing-library/react": "^14.1.2", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/node": "^20", "@types/react": "^18", @@ -30,6 +33,8 @@ "prettier": "^3.1.1", "prettier-plugin-tailwindcss": "^0.5.9", "tailwindcss": "^3", - "typescript": "^5" + "typescript": "^5", + "vite-tsconfig-paths": "^4.2.3", + "vitest": "^1.1.1" } } diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..421f180 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,15 @@ +/// +/// +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import tsconfigPaths from "vite-tsconfig-paths"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [tsconfigPaths(), react()], + test: { + environment: "jsdom", + setupFiles: "./__tests__/setup.ts", + globals: true, + }, +}); From 82356b03487b0d592062f9a49934e48bd92da204 Mon Sep 17 00:00:00 2001 From: xyashino Date: Mon, 1 Jan 2024 19:42:50 +0100 Subject: [PATCH 26/31] refactor: move static data into constants.ts for better maintainability - Shifted all static data values to constants.ts - Updated imports and references in the codebase to use the new constants - Ensured no functional changes to the application --- config/constants.ts | 3 +++ lib/utils/crew-mappers.ts | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/config/constants.ts b/config/constants.ts index 21c71a6..25d3ebb 100644 --- a/config/constants.ts +++ b/config/constants.ts @@ -2,4 +2,7 @@ export const JSON_CREW_FILE_PATH = '/crew.json'; export const YAML_CREW_FILE_PATH = '/crew.yaml'; +export const CREW_MIN_AGE = 30; +export const CREW_MAX_AGE = 40; + export const CREW_MEMBERS_PER_PAGE = 8; diff --git a/lib/utils/crew-mappers.ts b/lib/utils/crew-mappers.ts index 087be33..2039d47 100644 --- a/lib/utils/crew-mappers.ts +++ b/lib/utils/crew-mappers.ts @@ -1,8 +1,9 @@ import type { JsonResult } from '@/lib/schemas/json-result-schema'; import type { YamlResult } from '@/lib/schemas/yaml-result-schema'; +import { CREW_MAX_AGE, CREW_MIN_AGE } from '@/config/constants'; -const validateByAge = ({ age }: T) => - age >= 30 && age <= 40; +export const validateByAge = ({ age }: T) => + age >= CREW_MIN_AGE && age <= CREW_MAX_AGE; export const mapJsonMemberToValidCrewMember = ( data: JsonResult, From 8e1288764bce1ca4802d1cde1422b6151ef138ac Mon Sep 17 00:00:00 2001 From: xyashino Date: Mon, 1 Jan 2024 19:43:40 +0100 Subject: [PATCH 27/31] test: prepare mocks for unit testing --- __tests__/mocks/get-crew-response.mock.ts | 15 +++++ __tests__/mocks/members.mock.ts | 71 +++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 __tests__/mocks/get-crew-response.mock.ts create mode 100644 __tests__/mocks/members.mock.ts diff --git a/__tests__/mocks/get-crew-response.mock.ts b/__tests__/mocks/get-crew-response.mock.ts new file mode 100644 index 0000000..520b64d --- /dev/null +++ b/__tests__/mocks/get-crew-response.mock.ts @@ -0,0 +1,15 @@ +import { MEMBERS_MOCK_RESULT } from '@/__tests__/mocks/members.mock'; +import { CREW_MEMBERS_PER_PAGE } from '@/config/constants'; + +export const GET_CREW_RESPONSE_MOCK_DATA: CrewResponse = { + data: MEMBERS_MOCK_RESULT, + pagination: { + currentPage: 1, + totalPages: Math.ceil(MEMBERS_MOCK_RESULT.length / CREW_MEMBERS_PER_PAGE), + totalItems: MEMBERS_MOCK_RESULT.length, + itemsPerPage: CREW_MEMBERS_PER_PAGE, + lastPage: Math.ceil(MEMBERS_MOCK_RESULT.length / CREW_MEMBERS_PER_PAGE), + previousPage: null, + nextPage: null, + }, +}; diff --git a/__tests__/mocks/members.mock.ts b/__tests__/mocks/members.mock.ts new file mode 100644 index 0000000..59e4cbe --- /dev/null +++ b/__tests__/mocks/members.mock.ts @@ -0,0 +1,71 @@ +import { CREW_MAX_AGE, CREW_MIN_AGE } from '@/config/constants'; +import { JsonResult } from '@/lib/schemas/json-result-schema'; +import { YamlResult } from '@/lib/schemas/yaml-result-schema'; + +export const JSON_MEMBERS_MOCK_DATA: JsonResult = [ + { + firstName: 'John', + lastName: 'Doe', + age: CREW_MIN_AGE + 1, + nationality: 'American', + profession: 'Engineer', + }, + { + firstName: 'Jane', + lastName: 'Doe', + age: CREW_MAX_AGE - 1, + nationality: 'British', + profession: 'Doctor', + }, +] as const; + +export const INVALID_JSON_MEMBERS_MOCK_DATA: JsonResult = [ + { + firstName: 'Invalid', + lastName: 'User', + age: CREW_MAX_AGE + 1, + nationality: 'Poland', + profession: 'Crew', + }, + ...JSON_MEMBERS_MOCK_DATA, +] as const; + +export const YAML_MEMBERS_MOCK_DATA: YamlResult = [ + { + name: 'John Doe', + years_old: CREW_MIN_AGE + 1, + nationality: 'American', + occupation: 'Engineer', + }, + { + name: 'Jane Doe', + years_old: CREW_MAX_AGE - 1, + nationality: 'British', + occupation: 'Doctor', + }, +] as const; + +export const INVALID_YAML_MEMBERS_MOCK_DATA: YamlResult = [ + { + name: 'Invalid User', + years_old: CREW_MAX_AGE + 1, + nationality: 'Poland', + occupation: 'Crew', + }, + ...YAML_MEMBERS_MOCK_DATA, +] + +export const MEMBERS_MOCK_RESULT: CrewMember[] = [ + { + fullName: 'John Doe', + age: CREW_MIN_AGE + 1, + nationality: 'American', + profession: 'Engineer', + }, + { + fullName: 'Jane Doe', + age: CREW_MAX_AGE - 1, + nationality: 'British', + profession: 'Doctor', + }, +] as const; From 075c71d62e3a6b8b74260d05e83ae36b59cfa6d8 Mon Sep 17 00:00:00 2001 From: xyashino Date: Mon, 1 Jan 2024 19:44:27 +0100 Subject: [PATCH 28/31] test: add unit tests for all utility functions - Implemented comprehensive unit tests covering all functions in utils - Ensured tests validate both positive and edge case scenarios - Updated testing documentation to reflect new test additions --- __tests__/api/fetch-api-data.test.ts | 32 ++++++++ __tests__/api/get-crew.test.ts | 39 ++++++++++ __tests__/hooks/usePaginationLogic.test.ts | 42 +++++++++++ __tests__/utils/create-paginated-data.test.ts | 32 ++++++++ __tests__/utils/crew-mappers.test.ts | 73 +++++++++++++++++++ __tests__/utils/parse-to-number.test.ts | 25 +++++++ __tests__/utils/read-files.test.ts | 39 ++++++++++ 7 files changed, 282 insertions(+) create mode 100644 __tests__/api/fetch-api-data.test.ts create mode 100644 __tests__/api/get-crew.test.ts create mode 100644 __tests__/hooks/usePaginationLogic.test.ts create mode 100644 __tests__/utils/create-paginated-data.test.ts create mode 100644 __tests__/utils/crew-mappers.test.ts create mode 100644 __tests__/utils/parse-to-number.test.ts create mode 100644 __tests__/utils/read-files.test.ts diff --git a/__tests__/api/fetch-api-data.test.ts b/__tests__/api/fetch-api-data.test.ts new file mode 100644 index 0000000..302f10b --- /dev/null +++ b/__tests__/api/fetch-api-data.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi , beforeEach } from 'vitest'; +import { fetchApiData } from '@/lib/api/fetch-api-data'; + +global.fetch = vi.fn(); + +describe('fetchApiData', () => { + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('constructs URL correctly for GET requests with query params', async () => { + const path = '/test'; + const query = { param1: 'value1', param2: 'value2' }; + + await fetchApiData({ path, query }); + + expect(global.fetch).toHaveBeenCalledWith('/test?param1=value1¶m2=value2', expect.anything()); + }); + + it('sets default method to GET and adds default headers', async () => { + const path = '/test'; + + await fetchApiData({ path }); + + expect(global.fetch).toHaveBeenCalledWith(path, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + }); + +}); diff --git a/__tests__/api/get-crew.test.ts b/__tests__/api/get-crew.test.ts new file mode 100644 index 0000000..8d2cfb3 --- /dev/null +++ b/__tests__/api/get-crew.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { fetchApiData } from '@/lib/api/fetch-api-data'; +import { getCrew } from '@/lib/api/get-crew'; + +vi.mock('@/lib/api/fetch-api-data', () => ({ + fetchApiData: vi.fn(), +})); + +describe('getCrew', () => { + beforeEach(() => { + vi.mocked(fetchApiData).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: 'mockData' }), + } as Response); + + vi.clearAllMocks(); + }); + + it('calls fetchApiData with correct parameters', async () => { + await getCrew({ page: 2, take: 5 }); + expect(fetchApiData).toHaveBeenCalledWith({ + path: '/api/crew', + query: { take: 5, page: 2 }, + }); + }); + + it(`shouldn't call fetchApiData if page less or equal 0`, async () => { + await getCrew({ page: 0, take: 5 }); + await getCrew({ page: -1, take: 5 }); + expect(fetchApiData).not.toHaveBeenCalled(); + }); + + it(`shouldn't call fetchApiData if page is NaN`, async () => { + await getCrew({ page: NaN, take: 5 }); + expect(fetchApiData).not.toHaveBeenCalled(); + }); + +}); diff --git a/__tests__/hooks/usePaginationLogic.test.ts b/__tests__/hooks/usePaginationLogic.test.ts new file mode 100644 index 0000000..2355716 --- /dev/null +++ b/__tests__/hooks/usePaginationLogic.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, Mock , beforeEach} from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { usePaginationLogic } from '@/lib/hooks/usePaginationLogic'; +import { act } from 'react-dom/test-utils'; +import { useRouter } from 'next/router'; + +vi.mock('next/router', () => ({ + useRouter: vi.fn(), +})); + +describe('usePaginationLogic', () => { + let mockPush: Mock; + + beforeEach(() => { + mockPush = vi.fn(); + vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any); + vi.clearAllMocks(); + }); + + it('navigates to the correct page on changePage', () => { + const { result } = renderHook(() => + usePaginationLogic({ nextPage: 3, previousPage: 1, lastPage: 5 }) + ); + + act(() => { + result.current.changePage(2); + }); + + expect(mockPush).toHaveBeenCalledWith('/task/2'); + }); + + it('does not navigate on invalid page number', () => { + const { result } = renderHook(() => + usePaginationLogic({ nextPage: 3, previousPage: 1, lastPage: 5 }) + ); + + act(() => { + result.current.changePage(6); + }); + expect(mockPush).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/utils/create-paginated-data.test.ts b/__tests__/utils/create-paginated-data.test.ts new file mode 100644 index 0000000..95f3b28 --- /dev/null +++ b/__tests__/utils/create-paginated-data.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { createPaginatedData } from '@/lib/utils/create-paginated-data'; + +describe('createPaginatedData', () => { + const sampleData = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']; + + it(`shouldn't manipulate the input`, () => { + const result = createPaginatedData({ data: sampleData, page: 1, take: 3 }); + expect(result).not.toBe(sampleData); + expect(sampleData).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']); + }); + + it('correctly paginates data', () => { + const result = createPaginatedData({ data: sampleData, page: 2, take: 3 }); + expect(result).toEqual(['d', 'e', 'f']); + }); + + it('handles boundary conditions', () => { + const result = createPaginatedData({ data: sampleData, page: 4, take: 3 }); + expect(result).toEqual(['j']); + }); + + it('returns empty array for empty data', () => { + const result = createPaginatedData({ data: [], page: 1, take: 5 }); + expect(result).toEqual([]); + }); + + it('handles page 0 and take 0', () => { + const result = createPaginatedData({ data: sampleData, page: 0, take: 0 }); + expect(result).toEqual([]); + }); +}); diff --git a/__tests__/utils/crew-mappers.test.ts b/__tests__/utils/crew-mappers.test.ts new file mode 100644 index 0000000..f2c5fa1 --- /dev/null +++ b/__tests__/utils/crew-mappers.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { CREW_MAX_AGE, CREW_MIN_AGE } from '@/config/constants'; +import { + mapJsonMemberToValidCrewMember, + mapYamlMemberToValidCrewMember, + validateByAge, +} from '@/lib/utils/crew-mappers'; +import { + INVALID_JSON_MEMBERS_MOCK_DATA, + INVALID_YAML_MEMBERS_MOCK_DATA, + JSON_MEMBERS_MOCK_DATA, + MEMBERS_MOCK_RESULT, + YAML_MEMBERS_MOCK_DATA, +} from '@/__tests__/mocks/members.mock'; + + +describe('crew-mappers', () => { + describe('validateByAge', () => { + it('returns true when age is within the valid range', () => { + const result = validateByAge({ age: CREW_MIN_AGE + 1 }); + expect(result).toBe(true); + }); + + it('returns false when age is below the valid range', () => { + const result = validateByAge({ age: CREW_MIN_AGE - 1 }); + expect(result).toBe(false); + }); + + it('returns false when age is above the valid range', () => { + const result = validateByAge({ age: CREW_MAX_AGE + 1 }); + expect(result).toBe(false); + }); + + it('returns true when age is exactly the minimum valid age', () => { + const result = validateByAge({ age: CREW_MIN_AGE }); + expect(result).toBe(true); + }); + + it('returns true when age is exactly the maximum valid age', () => { + const result = validateByAge({ age: CREW_MAX_AGE }); + expect(result).toBe(true); + }); + }); + + describe('mapJsonMemberToValidCrewMember', () => { + it('returns valid crew members when provided with valid JSON data', () => { + const result = mapJsonMemberToValidCrewMember(JSON_MEMBERS_MOCK_DATA); + expect(result).toEqual(MEMBERS_MOCK_RESULT); + }); + + it('filters out invalid crew members when provided with JSON data', () => { + const result = mapJsonMemberToValidCrewMember( + INVALID_JSON_MEMBERS_MOCK_DATA, + ); + expect(result).toEqual(MEMBERS_MOCK_RESULT); + }); + }); + + describe('mapYamlMemberToValidCrewMember', () => { + it('returns valid crew members when provided with valid YAML data', () => { + const result = mapYamlMemberToValidCrewMember(YAML_MEMBERS_MOCK_DATA); + expect(result).toEqual(MEMBERS_MOCK_RESULT); + }); + + it('filters out invalid crew members when provided with YAML data', () => { + const result = mapYamlMemberToValidCrewMember( + INVALID_YAML_MEMBERS_MOCK_DATA, + ); + expect(result).toEqual(MEMBERS_MOCK_RESULT); + }); + }); +}); diff --git a/__tests__/utils/parse-to-number.test.ts b/__tests__/utils/parse-to-number.test.ts new file mode 100644 index 0000000..fc98736 --- /dev/null +++ b/__tests__/utils/parse-to-number.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { parseToNumber } from '@/lib/utils/parse-to-number'; + +describe('parseToNumber', () => { + it('returns a number when a number is provided as a string', () => { + const result = parseToNumber('123'); + expect(result).toEqual(123); + }); + + it('returns a number when a number is provided', () => { + const result = parseToNumber(123); + expect(result).toEqual(123); + }); + + it('returns null when a non-numeric string is provided', () => { + const result = parseToNumber('abc'); + expect(result).toBeNull(); + }); + + it('returns null when undefined is provided', () => { + const result = parseToNumber(undefined); + expect(result).toBeNull(); + }); +}); diff --git a/__tests__/utils/read-files.test.ts b/__tests__/utils/read-files.test.ts new file mode 100644 index 0000000..dbab9fd --- /dev/null +++ b/__tests__/utils/read-files.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest'; +import { readFile } from 'fs/promises'; +import { readJsonFile, readYamlFile } from '@/lib/utils/read-files'; + +vi.mock('fs/promises'); + + +describe('read-files', () => { + + describe('readJsonFile', () => { + it('reads and parses a JSON file correctly', async () => { + const fakeData = { key: 'value' }; + vi.mocked(readFile).mockResolvedValue(JSON.stringify(fakeData)); + + const result = await readJsonFile('test.json'); + expect(result).toEqual(fakeData); + }); + + it('throws an error for non-JSON files', async () => { + await expect(readJsonFile('test.txt')).rejects.toThrow('Invalid file type must be a json file'); + }); + }); + + describe('readYamlFile', () => { + it('reads and parses a YAML file correctly', async () => { + const fakeData = { key: 'value' }; + const fakeJsonData = JSON.stringify(fakeData); + vi.mocked(readFile).mockResolvedValue(fakeJsonData); + + const result = await readYamlFile('test.yaml'); + expect(result).toEqual(fakeData); + }); + + it('throws an error for non-YAML files', async () => { + await expect(readYamlFile('test.json')).rejects.toThrow('Invalid file type must be a yaml file'); + }); + }); + +}); From 2cc38f380fdc3083081748c4c6084f69b72d217b Mon Sep 17 00:00:00 2001 From: xyashino Date: Mon, 1 Jan 2024 19:47:50 +0100 Subject: [PATCH 29/31] test: add data-testid attributes to Pagination.tsx for testing ease - Included data-testid attributes in key elements of Pagination.tsx - Updated tests to utilize the new data-testid attributes --- components/pagination/Pagination.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/pagination/Pagination.tsx b/components/pagination/Pagination.tsx index 43f4a88..b7fdd0b 100644 --- a/components/pagination/Pagination.tsx +++ b/components/pagination/Pagination.tsx @@ -19,7 +19,7 @@ export const Pagination = ({ aria-label='Pagination' className='inline-flex m-4 space-x-2 rounded-md shadow-sm' > - + {Array(lastPage) @@ -33,7 +33,7 @@ export const Pagination = ({ {i + 1} ))} - + From f650e9cee5cead416934696a46751556c35f6391 Mon Sep 17 00:00:00 2001 From: xyashino Date: Mon, 1 Jan 2024 19:49:17 +0100 Subject: [PATCH 30/31] test: add unit tests for specific components - Created unit tests for [CrewCard; CrewList;Pagination; PaginationButton ] - Ensured comprehensive coverage including edge cases --- __tests__/components/CrewCard.test.tsx | 25 +++++ __tests__/components/CrewList.test.tsx | 37 ++++++++ __tests__/components/Pagination.test.tsx | 94 +++++++++++++++++++ .../components/PaginationButton.test.tsx | 30 ++++++ 4 files changed, 186 insertions(+) create mode 100644 __tests__/components/CrewCard.test.tsx create mode 100644 __tests__/components/CrewList.test.tsx create mode 100644 __tests__/components/Pagination.test.tsx create mode 100644 __tests__/components/PaginationButton.test.tsx diff --git a/__tests__/components/CrewCard.test.tsx b/__tests__/components/CrewCard.test.tsx new file mode 100644 index 0000000..4e75159 --- /dev/null +++ b/__tests__/components/CrewCard.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { CrewCard } from '@/components/crew-list/CrewCard'; + +describe('CrewCard', () => { + it('displays crew member details', () => { + const crewMember = { + fullName: 'Jane Doe', + age: 30, + nationality: 'American', + profession: 'Engineer', + }; + + render(); + + expect(screen.getByText('Full Name:')).toBeInTheDocument(); + expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + expect(screen.getByText('Age:')).toBeInTheDocument(); + expect(screen.getByText('30')).toBeInTheDocument(); + expect(screen.getByText('Nationality:')).toBeInTheDocument(); + expect(screen.getByText('American')).toBeInTheDocument(); + expect(screen.getByText('Profession:')).toBeInTheDocument(); + expect(screen.getByText('Engineer')).toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/CrewList.test.tsx b/__tests__/components/CrewList.test.tsx new file mode 100644 index 0000000..82fa40f --- /dev/null +++ b/__tests__/components/CrewList.test.tsx @@ -0,0 +1,37 @@ +import type { ComponentProps } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { CrewCard } from '@/components/crew-list/CrewCard'; +import { CrewList } from '@/components/crew-list/CrewList'; +import { render, screen } from '@testing-library/react'; + +type Props = ComponentProps; + +vi.mock('@/components/crew-list/CrewCard', () => ({ + CrewCard: ({ fullName }: Props) =>
{fullName}
, +})); + +describe('CrewList', () => { + it('renders a list of crew members', () => { + const crewMembers = [ + { + fullName: 'John Doe', + age: 30, + nationality: 'American', + profession: 'Engineer', + }, + { + fullName: 'Jane Smith', + age: 28, + nationality: 'Canadian', + profession: 'Pilot', + }, + ]; + + render(); + + expect(screen.getAllByText(/John Doe|Jane Smith/)).toHaveLength( + crewMembers.length, + ); + }); +}); diff --git a/__tests__/components/Pagination.test.tsx b/__tests__/components/Pagination.test.tsx new file mode 100644 index 0000000..0ba0848 --- /dev/null +++ b/__tests__/components/Pagination.test.tsx @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Pagination } from '@/components/pagination/Pagination'; +import { usePaginationLogic } from '@/lib/hooks/usePaginationLogic'; +import { fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@/lib/hooks/usePaginationLogic'); + +describe('Pagination', () => { + const mockGoToPreviousPage = vi.fn(); + const mockGoToNextPage = vi.fn(); + const mockChangePage = vi.fn(); + + let mockPaginationResponse: PaginationResponse; + + beforeEach(() => { + vi.mocked(usePaginationLogic).mockReturnValue({ + goToPreviousPage: mockGoToPreviousPage, + goToNextPage: mockGoToNextPage, + changePage: mockChangePage, + }); + + mockPaginationResponse = { + nextPage: 2, + previousPage: 1, + lastPage: 3, + currentPage: 1, + totalItems: 40, + totalPages: 3, + itemsPerPage: 8, + }; + vi.clearAllMocks(); + }); + + it('renders pagination buttons correctly', () => { + render(); + + const previousButton = screen.getByTestId('prev-btn'); + const nextButton = screen.getByTestId('next-btn'); + const pageButtons = screen.getAllByRole('button'); + + expect(previousButton).toBeInTheDocument(); + expect(nextButton).toBeInTheDocument(); + expect(pageButtons).toHaveLength(mockPaginationResponse.lastPage + 2); + }); + + it('calls the correct function when the prev/next button is clicked', () => { + render(); + + const previousButton = screen.getByTestId('prev-btn'); + fireEvent.click(previousButton); + const nextButton = screen.getByTestId('next-btn'); + fireEvent.click(nextButton); + + expect(mockGoToNextPage).toHaveBeenCalledTimes(1); + expect(mockGoToPreviousPage).toHaveBeenCalledTimes(1); + }); + + it('calls the correct function when a page button is clicked', () => { + render(); + + const pageButton = + screen.getAllByRole('button')[mockPaginationResponse.currentPage + 1]; + fireEvent.click(pageButton); + + expect(mockChangePage).toHaveBeenCalledTimes(1); + }); + + it('disables the prev/next button when there is no previous/next page', () => { + render( + , + ); + + const previousButton = screen.getByTestId('prev-btn'); + const nextButton = screen.getByTestId('next-btn'); + + expect(previousButton).toBeDisabled(); + expect(nextButton).toBeDisabled(); + }); + + it('disables the page button when it is the current page', () => { + render(); + + const pageButton = + screen.getAllByRole('button')[mockPaginationResponse.currentPage]; + fireEvent.click(pageButton); + expect(pageButton).toBeDisabled(); + expect(mockChangePage).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/PaginationButton.test.tsx b/__tests__/components/PaginationButton.test.tsx new file mode 100644 index 0000000..1e4dd43 --- /dev/null +++ b/__tests__/components/PaginationButton.test.tsx @@ -0,0 +1,30 @@ +import { describe, expect, it , vi } from 'vitest'; + +import { render, screen , fireEvent} from '@testing-library/react'; +import { PaginationButton } from '@/components/pagination/PaginationButton'; + + +describe('PaginationButton', () => { + it('renders a button with the correct classes', () => { + const className = 'custom-class'; + render(Click me); + const button = screen.getByRole('button', { name: /click me/i }); + expect(button).toHaveClass(className); + }); + + it('includes screen reader text when srText is provided', () => { + const srText = 'Screen reader text'; + render(Click me); + expect(screen.getByText(srText)).toBeInTheDocument(); + }); + + it('handles click events', () => { + const handleClick = vi.fn(); + const text = 'Click me'; + render({text}); + const button = screen.getByRole('button', { name: text }); + + fireEvent.click(button); + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); From f4f883cf076f83372a18b2bfaaecf5a5c5027304 Mon Sep 17 00:00:00 2001 From: xyashino Date: Mon, 1 Jan 2024 19:58:53 +0100 Subject: [PATCH 31/31] chore: extend Vitest types with @testing-library/jest-dom - Integrated @testing-library/jest-dom/vitest for enhanced type support in Vitest - Updated relevant test files to utilize the extended type definitions - Improved testing capabilities and assertions specificity --- __tests__/setup.ts | 10 ++++++---- vitest.config.mts | 2 -- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/__tests__/setup.ts b/__tests__/setup.ts index 14c0ce0..67b373e 100644 --- a/__tests__/setup.ts +++ b/__tests__/setup.ts @@ -1,9 +1,11 @@ -import { expect, afterEach } from 'vitest'; -import { cleanup } from "@testing-library/react"; -import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, expect } from 'vitest'; + +import * as matchers from '@testing-library/jest-dom/matchers'; +import { cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; -expect.extend(matchers); +expect.extend(matchers); afterEach(() => { cleanup(); diff --git a/vitest.config.mts b/vitest.config.mts index 421f180..f839ebf 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,5 +1,3 @@ -/// -/// import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import tsconfigPaths from "vite-tsconfig-paths";