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 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..58beb87 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,21 @@ +{ + "semi": true, + "tabWidth": 2, + "singleQuote": true, + "jsxSingleQuote": true, + "importOrder": [ + "^@(.*)$", + "^@/(.*)$", + "^@/types/(.*)$", + "^@/app/(.*)$", + "^@/lib/(.*)$", + "^@/components/(.*)$", + "^[./]" + ], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true, + "plugins": [ + "prettier-plugin-tailwindcss", + "@trivago/prettier-plugin-sort-imports" + ] +} 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__/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); + }); +}); 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__/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; diff --git a/__tests__/setup.ts b/__tests__/setup.ts new file mode 100644 index 0000000..67b373e --- /dev/null +++ b/__tests__/setup.ts @@ -0,0 +1,12 @@ +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); + +afterEach(() => { + cleanup(); +}); 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'); + }); + }); + +}); diff --git a/components/CrewLayout.tsx b/components/CrewLayout.tsx new file mode 100644 index 0000000..66261c3 --- /dev/null +++ b/components/CrewLayout.tsx @@ -0,0 +1,17 @@ +import { PropsWithChildren } from 'react'; +import { twMerge } from 'tailwind-merge'; + +interface CrewLayoutProps extends PropsWithChildren { + className?: string; +} + +export const CrewLayout = ({ children, className }: CrewLayoutProps) => ( +
+ {children} +
+); diff --git a/components/crew-list/CrewCard.tsx b/components/crew-list/CrewCard.tsx new file mode 100644 index 0000000..5a28503 --- /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..25824f9 --- /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) => ( + + ))} +
+ ); +}; diff --git a/components/icons/LeftIcon.tsx b/components/icons/LeftIcon.tsx new file mode 100644 index 0000000..87da23b --- /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/LoadingIcon.tsx b/components/icons/LoadingIcon.tsx new file mode 100644 index 0000000..cbe7820 --- /dev/null +++ b/components/icons/LoadingIcon.tsx @@ -0,0 +1,27 @@ +import { SVGProps } from 'react'; + +export const LoadingIcon = (props: SVGProps) => ( + + + + + + +); diff --git a/components/icons/RightIcon.tsx b/components/icons/RightIcon.tsx new file mode 100644 index 0000000..24f202b --- /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..b7fdd0b --- /dev/null +++ b/components/pagination/Pagination.tsx @@ -0,0 +1,41 @@ +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, + previousPage, + lastPage, + currentPage, +}: PaginationResponse) => { + const { goToPreviousPage, goToNextPage, changePage } = usePaginationLogic({ + nextPage, + previousPage, + lastPage, + }); + return ( + + ); +}; diff --git a/components/pagination/PaginationButton.tsx b/components/pagination/PaginationButton.tsx new file mode 100644 index 0000000..7da1233 --- /dev/null +++ b/components/pagination/PaginationButton.tsx @@ -0,0 +1,28 @@ +import { ButtonHTMLAttributes } from 'react'; +import { twMerge } from 'tailwind-merge'; + +interface PaginationButtonProps + extends ButtonHTMLAttributes { + srText?: string; +} + +export const PaginationButton = ({ + srText, + children, + className, + ...rest +}: PaginationButtonProps) => ( + +); diff --git a/config/constants.ts b/config/constants.ts new file mode 100644 index 0000000..25d3ebb --- /dev/null +++ b/config/constants.ts @@ -0,0 +1,8 @@ +// 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 CREW_MIN_AGE = 30; +export const CREW_MAX_AGE = 40; + +export const CREW_MEMBERS_PER_PAGE = 8; diff --git a/lib/api/fetch-api-data.ts b/lib/api/fetch-api-data.ts new file mode 100644 index 0000000..2b75cf6 --- /dev/null +++ b/lib/api/fetch-api-data.ts @@ -0,0 +1,24 @@ +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(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..2025561 --- /dev/null +++ b/lib/api/get-crew.ts @@ -0,0 +1,25 @@ +import { CREW_MEMBERS_PER_PAGE } from '@/config/constants'; +import { fetchApiData } from '@/lib/api/fetch-api-data'; + +interface GetCrewPayload { + page?: number; + take?: number; +} + +export const getCrew = async ({ + page = 1, + take = CREW_MEMBERS_PER_PAGE, +}: 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.'); +}; diff --git a/lib/crew.ts b/lib/crew.ts index b49024d..353b230 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 { 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 { + mapJsonMemberToValidCrewMember, + mapYamlMemberToValidCrewMember, +} 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_CREW_FILE_PATH), + readYamlFile(YAML_CREW_FILE_PATH), + ]); + + const [parsedJsonMembers, parsedYamlMembers] = await Promise.all([ + jsonResultSchema.parseAsync(jsonMember), + yamlResultSchema.parseAsync(yamlMembers), + ]); + + return [ + ...mapJsonMemberToValidCrewMember(parsedJsonMembers), + ...mapYamlMemberToValidCrewMember(parsedYamlMembers), + ].toSorted(sortByFullName); +}; diff --git a/lib/hooks/usePaginationLogic.ts b/lib/hooks/usePaginationLogic.ts new file mode 100644 index 0000000..8b1ddf2 --- /dev/null +++ b/lib/hooks/usePaginationLogic.ts @@ -0,0 +1,24 @@ +import { useRouter } from 'next/router'; + +type PaginationPayload = Pick< + PaginationResponse, + '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 }; +}; diff --git a/lib/hooks/useReactQueryPagination.ts b/lib/hooks/useReactQueryPagination.ts new file mode 100644 index 0000000..c7a061b --- /dev/null +++ b/lib/hooks/useReactQueryPagination.ts @@ -0,0 +1,16 @@ +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 useReactQueryPagination = () => { + const { query } = useRouter(); + const page = parseToNumber(query.page) || 1; + const { data, isLoading, isError, error } = useQuery({ + 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 new file mode 100644 index 0000000..de760ac --- /dev/null +++ b/lib/schemas/json-result-schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +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..09d7547 --- /dev/null +++ b/lib/schemas/yaml-result-schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +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); diff --git a/lib/utils/create-paginated-data.ts b/lib/utils/create-paginated-data.ts new file mode 100644 index 0000000..82584f5 --- /dev/null +++ b/lib/utils/create-paginated-data.ts @@ -0,0 +1,15 @@ +type PaginatedDataPayload = { + data: T[]; + page: number; + take: number; +}; + +export const createPaginatedData = ({ + take, + page, + data, +}: 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 new file mode 100644 index 0000000..2039d47 --- /dev/null +++ b/lib/utils/crew-mappers.ts @@ -0,0 +1,35 @@ +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'; + +export const validateByAge = ({ age }: T) => + age >= CREW_MIN_AGE && age <= CREW_MAX_AGE; + +export const mapJsonMemberToValidCrewMember = ( + data: JsonResult, +): CrewMember[] => { + return data + .map( + ({ firstName, lastName, ...rest }): CrewMember => ({ + fullName: `${firstName} ${lastName}`, + ...rest, + }), + ) + .filter(validateByAge); +}; + +export const mapYamlMemberToValidCrewMember = ( + data: YamlResult, +): CrewMember[] => { + return data + .map((member): CrewMember => { + const { name, years_old, occupation, nationality } = member; + return { + fullName: name, + nationality, + age: years_old, + profession: occupation, + }; + }) + .filter(validateByAge); +}; diff --git a/lib/utils/parse-to-number.ts b/lib/utils/parse-to-number.ts new file mode 100644 index 0000000..6d087bf --- /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/lib/utils/read-files.ts b/lib/utils/read-files.ts new file mode 100644 index 0000000..3811043 --- /dev/null +++ b/lib/utils/read-files.ts @@ -0,0 +1,24 @@ +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)); + +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/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/package.json b/package.json index dd54a2f..a63f0ee 100644 --- a/package.json +++ b/package.json @@ -6,22 +6,35 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "format": "prettier --write .", + "test": "vitest" }, "dependencies": { + "@tanstack/react-query": "^5.15.0", + "next": "13.5.6", "react": "^18", "react-dom": "^18", - "next": "13.5.6" + "tailwind-merge": "^2.2.0", + "yaml": "^2.3.4", + "zod": "^3.22.4" }, "devDependencies": { - "typescript": "^5", + "@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", "@types/react-dom": "^18", "autoprefixer": "^10", + "eslint": "^8", + "eslint-config-next": "13.5.6", "postcss": "^8", + "prettier": "^3.1.1", + "prettier-plugin-tailwindcss": "^0.5.9", "tailwindcss": "^3", - "eslint": "^8", - "eslint-config-next": "13.5.6" + "typescript": "^5", + "vite-tsconfig-paths": "^4.2.3", + "vitest": "^1.1.1" } } diff --git a/pages/404.tsx b/pages/404.tsx new file mode 100644 index 0000000..1b04826 --- /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/_app.tsx b/pages/_app.tsx index 021681f..ff638b2 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,6 +1,15 @@ -import '@/styles/globals.css' -import type { AppProps } from 'next/app' +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(); export default function App({ Component, pageProps }: AppProps) { - return + return ( + + + + ); } 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 new file mode 100644 index 0000000..6c08d6f --- /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; diff --git a/pages/api/crew.ts b/pages/api/crew.ts index 0f56e1f..5035c32 100644 --- a/pages/api/crew.ts +++ b/pages/api/crew.ts @@ -1,10 +1,56 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import type { NextApiRequest, NextApiResponse } from 'next'; + +import { CREW_MEMBERS_PER_PAGE } from '@/config/constants'; +import { getCrewMembers } from '@/lib/crew'; +import { createPaginatedData } from '@/lib/utils/create-paginated-data'; +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) || CREW_MEMBERS_PER_PAGE; + + 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 = createPaginatedData({ + 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 CrewResponse); } diff --git a/pages/index.tsx b/pages/index.tsx index 2d922d3..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 86f1097..2b190cc 100644 --- a/pages/task/[page].tsx +++ b/pages/task/[page].tsx @@ -2,7 +2,57 @@ * @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'; +import { CrewList } from '@/components/crew-list/CrewList'; +import { LoadingIcon } from '@/components/icons/LoadingIcon'; +import { Pagination } from '@/components/pagination/Pagination'; +import { useReactQueryPagination } from '@/lib/hooks/useReactQueryPagination'; +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() { - return
Task
; + const { isLoading, isError, error, data } = useReactQueryPagination(); + + if (isError) return ; + if (isLoading || !data) return ; + + const { data: crew, pagination } = data; + return ( + + + + + ); } + +const LoadingComponent = () => ( + + + +); + +const ErrorComponent = ({ error }: { error: Error | null }) => ( + +
+

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 new file mode 100644 index 0000000..a3a7b9c --- /dev/null +++ b/types/crew.d.ts @@ -0,0 +1,11 @@ +type CrewMember = { + fullName: string; + nationality: string; + age: number; + profession: string; +}; + +type CrewResponse = { + data: CrewMember[]; + pagination: PaginationResponse; +}; diff --git a/types/pagination.d.ts b/types/pagination.d.ts new file mode 100644 index 0000000..2e11219 --- /dev/null +++ b/types/pagination.d.ts @@ -0,0 +1,10 @@ +type PaginationResponse = { + currentPage: number; + totalPages: number; + itemsPerPage: number; + totalItems: number; + lastPage: number; + + previousPage: number | null; + nextPage: number | null; +}; diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..f839ebf --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,13 @@ +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, + }, +});