diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..4d29575d --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..e671dade --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "bracketSameLine": false, + "printWidth": 120, + "bracketSpacing": true +} \ No newline at end of file diff --git a/README.md b/README.md index 91d4d5b9..cac9cb91 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,47 @@ -# GlobalWebIndex Engineering Challenge +**Cat Lover App** -## Exercise: CatLover +Overview -Create a React application for cat lovers which is going to build upon thecatapi.com and will have 3 views. -The **first** view displays a list of 10 random cat images and a button to load more. Clicking on any of those images opens a modal view with the image and the information about the cat’s breed if available. This would be a link to the second view below - the breed detail. The modal should also contain a form to mark the image as your favourite (a part of the third view as well). Make sure you can copy-paste the URL of the modal and send it to your friends - they should see the same image as you can see. +This application is a React-based web app for managing and displaying cat images. Users can browse cat images, view detailed information about cat breeds, and manage their favorite images. The app integrates with a backend API for fetching data and supports modern React features such as hooks and context for state management. -The **second** view displays a list of cat breeds. Each breed opens a modal again with a list of cat images of that breed. Each of those images must be a link to the image detail from the previous point. -The **third** view allows you do the following things: +**API Integration** -- Display your favourite cats -- Remove an image from your favourites (use any UX option you like) +The app integrates with a backend API to fetch and manage data. -You can find the API documentation here: https://developers.thecatapi.com/ -We give you a lot of freedom in technologies and ways of doing things. We only insist on you using React.js. Get creative as much as you want, we WILL appreciate it. You will not be evaluated based on how well you follow these instructions, but based on how sensible your solution will be. In case you are not able to implement something you would normally implement for time reasons, make it clear with a comment. +**Endpoints** -## Submission + - GET /breeds: Fetches a list of cat breeds. -Once you have built your app, share your code in the mean suits you best -Good luck, potential colleague! +- GET /images/search: Fetches random cat images. + +- GET /favourites: Fetches the user's favorite cat images. + +- POST /favourites: Adds an image to the user's favorites. + +- DELETE /favourites/:id: Removes an image from the user's favorites. + +**Axios Instance (apiClient.ts)** + + - Configures a centralized Axios instance with base URL and headers for API requests. + +- Includes error handling via interceptors + +**Features** + +1. Image Gallery: Display a grid of cat images with a "Load More" functionality. + +2. Favorites Management: Add or remove cat images from favorites. + +3. Breed Information: View detailed information about specific cat breeds. + +4. Error Handling: Provides user-friendly error messages. + +5. Responsive Design: Utilizes react-bootstrap for a consistent, responsive UI. + +6. Global State Management: Shares the favorites state across components using the Context API. + +**Installation and Setup** + +1. yarn install +2. yarn start \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 00000000..a968ac6e --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "platform-react-challenge", + "version": "0.1.0", + "private": true, + "dependencies": { + "@testing-library/jest-dom": "^5.14.1", + "@testing-library/react": "^13.0.0", + "@testing-library/user-event": "^13.2.1", + "@types/jest": "^27.0.1", + "@types/node": "^16.7.13", + "@types/react": "^19.0.7", + "@types/react-dom": "^19.0.3", + "axios": "^1.7.9", + "bootstrap": "^5.3.3", + "react": "18", + "react-bootstrap": "^2.10.8", + "react-dom": "18", + "react-error-boundary": "^5.0.0", + "react-router-dom": "^7.1.3", + "react-scripts": "5.0.1", + "react-window": "^1.8.11", + "typescript": "^4.4.2", + "web-vitals": "^2.1.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/react-modal": "^3.16.3", + "@types/react-router-dom": "^5.3.3", + "@types/react-window": "^1.8.8", + "prettier": "3.4.2" + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..a11777cc Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 00000000..aa069f27 --- /dev/null +++ b/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + React App + + + +
+ + + diff --git a/public/logo192.png b/public/logo192.png new file mode 100644 index 00000000..fc44b0a3 Binary files /dev/null and b/public/logo192.png differ diff --git a/public/logo512.png b/public/logo512.png new file mode 100644 index 00000000..a4e47a65 Binary files /dev/null and b/public/logo512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 00000000..080d6c77 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 00000000..e9e57dc4 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/src/App.css b/src/App.css new file mode 100644 index 00000000..2bea5c1f --- /dev/null +++ b/src/App.css @@ -0,0 +1,4 @@ +.App { + text-align: center; +} + diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 00000000..c1411844 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,35 @@ +import { Route, Routes } from 'react-router-dom'; +import { lazy, Suspense } from 'react'; + +import Home from './pages/Home/Home'; +import Layout from './Layout/Layout'; +import ErrorBoundary from './components/ErrorBoundary/ErrorBoundary'; +import { ROUTES } from './constants'; + +import './App.css'; + +// Lazy loading non-critical components +const Breeds = lazy(() => import('./pages/Breeds/Breeds')); +const Favorites = lazy(() => import('./pages/Favorites/Favorites')); +const NotFound = lazy(() => import('./pages/NotFound/NotFound')); +const SpinnerComponent = lazy(() => import('./components/SpinnerComponent/SpinnerComponent')); + +const App = () => ( +
+ + + }> + + } /> + } /> + } /> + } /> + } /> + + + + +
+); + +export default App; diff --git a/src/Layout/Layout.css b/src/Layout/Layout.css new file mode 100644 index 00000000..d28e0c41 --- /dev/null +++ b/src/Layout/Layout.css @@ -0,0 +1,31 @@ +.custom-navbar { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + padding: 10px 20px; +} + +.navbar-brand { + font-size: 1.5rem; + font-weight: bold; + color: var(--navColor) !important; + cursor: pointer; +} + +.navbar-link { + font-size: 1.2rem; + margin: 0 10px; + transition: color 0.3s ease; + color: var(--navColor) !important; +} + +.navbar-brand:hover, .navbar-link:hover { + color: #8e9d76 !important; +} + +.content { + margin-top: 5rem; +} + +.custom-toggler { + background-color: var(--navColor) !important +} + diff --git a/src/Layout/Layout.tsx b/src/Layout/Layout.tsx new file mode 100644 index 00000000..4e75154e --- /dev/null +++ b/src/Layout/Layout.tsx @@ -0,0 +1,69 @@ +import { useState, FC, ReactNode, useEffect } from 'react'; + +import { Navbar, Nav, Container, Offcanvas } from 'react-bootstrap'; +import { useNavigate } from 'react-router-dom'; + +import './Layout.css'; + +interface LayoutProps { + children?: ReactNode; +} + +/** + * Layout component that provides a navigation bar and main content area. + * Includes responsive navigation with an offcanvas menu for smaller screens. + * @param children - The components to render inside the layout's content area. + */ +const Layout: FC = ({ children }) => { + const navigate = useNavigate(); + const [showOffcanvas, setShowOffcanvas] = useState(false); + + const handleNavClick = (redirectTo: string) => { + navigate(redirectTo); + setShowOffcanvas(false); + }; + + const toggleOffcanvas = () => setShowOffcanvas(!showOffcanvas); + const closeOffcanvas = () => setShowOffcanvas(false); + + useEffect(() => { + document.title = 'Cat Lover App'; + }, []); + + return ( + <> + + + navigate('/')} className="navbar-brand"> + Cat Lover App + + + + + Navigation + + + + + + + + {children} + + ); +}; + +export default Layout; diff --git a/src/apiClient/apiClient.ts b/src/apiClient/apiClient.ts new file mode 100644 index 00000000..63c104bf --- /dev/null +++ b/src/apiClient/apiClient.ts @@ -0,0 +1,20 @@ +import axios from 'axios'; + +const apiKey = 'live_4yaPeUDj6HG4aQbbQwPa7rk1MCuNR835rhVQkmcatbdlFk3oxmoRXlILFsZ5YsQn'; + +const axiosInstance = axios.create({ + baseURL: 'https://api.thecatapi.com/v1', + headers: { + 'x-api-key': apiKey, + }, + timeout: 10000, +}); + +axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + throw error.response?.data?.message || error.message || 'An error occurred'; + } +); + +export default axiosInstance; diff --git a/src/apiClient/apiService.ts b/src/apiClient/apiService.ts new file mode 100644 index 00000000..96a5ed14 --- /dev/null +++ b/src/apiClient/apiService.ts @@ -0,0 +1,70 @@ +import { IFavoriteCatImage, ICatImage, IBreed } from '../definitions'; +import { API_ENDPOINTS } from '../constants'; +import axiosInstance from './apiClient'; + +/** + * Fetches the list of all cat breeds. + * @returns A promise that resolves to an array of cat breeds. + */ +export const fetchBreedsData = async (): Promise => { + const response = await axiosInstance.get(API_ENDPOINTS.BREEDS); + return response.data; +}; + +/** + * Fetches images for a specific cat breed. + * @param breedId - The ID of the breed to fetch images for. + * @returns A promise that resolves to an array of cat images for the specified breed. + */ +export const fetchBreedImagesData = async (breedId: string): Promise => { + const response = await axiosInstance.get( + `${API_ENDPOINTS.IMAGES}/search?limit=100&breed_ids=${breedId}` + ); + return response.data; +}; + +/** + * Fetches all favorite cat images. + * @returns A promise that resolves to an array of favorite cat images. + */ +export const fetchFavoritesData = async (): Promise => { + const response = await axiosInstance.get(`${API_ENDPOINTS.FAVORITES}?order=DESC&attach_image=1`); + return response.data; +}; + +/** + * Adds a cat image to the user's favorites. + * @param imageId - The ID of the image to add to favorites. + * @returns A promise that resolves when the favorite is added. + */ +export const addFavoriteImage = async (imageId: string): Promise => { + await axiosInstance.post(API_ENDPOINTS.FAVORITES, { image_id: imageId }); +}; + +/** + * Removes a cat image from the user's favorites. + * @param favoriteId - The ID of the favorite to remove. + * @returns A promise that resolves when the favorite is removed. + */ +export const removeFavoriteImage = async (favoriteId: string): Promise => { + await axiosInstance.delete(`${API_ENDPOINTS.FAVORITES}/${favoriteId}`); +}; + +/** + * Fetches details of a specific cat image by its ID. + * @param imageId - The ID of the image to fetch. + * @returns A promise that resolves to the cat image details. + */ +export const fetchImageDataById = async (imageId: string): Promise => { + const response = await axiosInstance.get(`${API_ENDPOINTS.IMAGES}/${imageId}`); + return response.data; +}; + +/** + * Fetches 10 random cat images. + * @returns A promise that resolves to an array of random cat images. + */ +export const fetchRandomImages = async (): Promise => { + const response = await axiosInstance.get(`${API_ENDPOINTS.IMAGES}/search?limit=10`); + return response.data; +}; diff --git a/src/components/AccordionComponent/AccordionComponent.test.tsx b/src/components/AccordionComponent/AccordionComponent.test.tsx new file mode 100644 index 00000000..d9130f70 --- /dev/null +++ b/src/components/AccordionComponent/AccordionComponent.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render } from '@testing-library/react'; +import AccordionComponent, { AccordionItem } from './AccordionComponent'; + +const mockItems: AccordionItem[] = [ + { eventKey: '0', title: 'Title 1', content: 'Content 1' }, + { eventKey: '1', title: 'Title 2', content: 'Content 2' }, +]; + +const setup = (props = {}) => { + const defaultProps = { items: mockItems, defaultActiveKey: '0' }; + return render(); +}; + +describe('AccordionComponent', () => { + it('renders all items correctly', () => { + // Arrange + const { getAllByRole } = setup(); + + // Act + const titles = getAllByRole('button', { name: /Title/i }); + + // Assert + expect(titles).toHaveLength(2); + expect(titles[0]).toHaveTextContent('Title 1'); + expect(titles[1]).toHaveTextContent('Title 2'); + }); + + it('displays content for the default active item', () => { + // Arrange + const { getByText } = setup(); + + // Act + const content = getByText('Content 1'); + + // Assert + expect(content).toBeInTheDocument(); + }); + + it('displays no items message when items array is empty', () => { + // Arrange + const { getByText } = setup({ items: [] }); + + // Act + const noItemsMessage = getByText('No items to display'); + + // Assert + expect(noItemsMessage).toBeInTheDocument(); + }); + + it('toggles content visibility on header click', () => { + // Arrange + const { getByRole, getByText } = setup(); + + // Act + const title = getByRole('button', { name: /Title 2/i }); + fireEvent.click(title); + + // Assert + expect(getByText('Content 2')).toBeInTheDocument(); + }); +}); diff --git a/src/components/AccordionComponent/AccordionComponent.tsx b/src/components/AccordionComponent/AccordionComponent.tsx new file mode 100644 index 00000000..1c7798ba --- /dev/null +++ b/src/components/AccordionComponent/AccordionComponent.tsx @@ -0,0 +1,38 @@ +import { FC, ReactNode } from 'react'; +import { Accordion } from 'react-bootstrap'; + +export interface AccordionItem { + eventKey: string; + title: string; + content: ReactNode; +} + +interface AccordionComponentProps { + items: AccordionItem[]; + defaultActiveKey?: string; +} + +/** + * @component + * A reusable accordion component which renders a list of items that collapse + * @param items - Array of accordion items to display. + * @param defaultActiveKey - The event key of the item that is open by default. + */ +const AccordionComponent: FC = ({ items, defaultActiveKey = '0' }) => { + if (!items || items.length === 0) { + return

No items to display

; + } + + return ( + + {items.map((item) => ( + + {item.title} + {item.content} + + ))} + + ); +}; + +export default AccordionComponent; diff --git a/src/components/ErrorBoundary/ErrorBoundary.test.tsx b/src/components/ErrorBoundary/ErrorBoundary.test.tsx new file mode 100644 index 00000000..761bf7f3 --- /dev/null +++ b/src/components/ErrorBoundary/ErrorBoundary.test.tsx @@ -0,0 +1,35 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import ErrorBoundary from './ErrorBoundary'; + +describe('ErrorBoundary', () => { + const ProblematicComponent = () => { + throw new Error('Test error'); + }; + + const setup = (children: React.ReactNode) => render({children}); + + it('renders fallback UI when an error is thrown', () => { + // Arrange + const { getByText } = setup(); + + // Act + const heading = getByText('Something went wrong'); + const message = getByText('Test error'); + + // Assert + expect(heading).toBeInTheDocument(); + expect(message).toBeInTheDocument(); + }); + + it('renders children normally when no error is thrown', () => { + // Arrange + const ChildComponent = () =>
Safe Component
; + const { getByText } = setup(); + + // Act + const safeContent = getByText('Safe Component'); + + // Assert + expect(safeContent).toBeInTheDocument(); + }); +}); diff --git a/src/components/ErrorBoundary/ErrorBoundary.tsx b/src/components/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 00000000..d270ed6f --- /dev/null +++ b/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,47 @@ +import { FC, ReactNode } from 'react'; +import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary'; + +interface ErrorBoundaryProps { + children: ReactNode; +} + +/** + * @component + * A reusable error boundary component to catch and handle JavaScript errors in child components. + * Displays a fallback UI and logs error details to the console. + * @param children - The child components to render within the error boundary. + */ +const ErrorBoundary: FC = ({ children }) => { + /** + * Error handler that logs error details to the console. + * @param error - The error that was thrown. + * @param errorInfo - Additional error details, including the component stack trace. + */ + const errorHandler = (error: Error, errorInfo: { componentStack?: string | null }) => { + console.error('Error caught in ErrorBoundary:', error); + if (errorInfo.componentStack) { + console.error('Component Stack:', errorInfo.componentStack); + } + }; + + /** + * Renders the fallback UI when an error occurs. + * @param error - The error that was thrown. + * @param resetErrorBoundary - Function to reset the error boundary and attempt to re-render. + */ + const fallbackRender = ({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) => ( +
+

Something went wrong

+

{error.message}

+ +
+ ); + + return ( + + {children} + + ); +}; + +export default ErrorBoundary; diff --git a/src/components/ImageCard/ImageCard.css b/src/components/ImageCard/ImageCard.css new file mode 100644 index 00000000..d9e05f91 --- /dev/null +++ b/src/components/ImageCard/ImageCard.css @@ -0,0 +1,36 @@ +.image-card { + cursor: pointer; + transition: transform 0.3s, box-shadow 0.3s; + border: 1px solid #ddd; + border-radius: 8px; + overflow: hidden; + background-color: #fff; /* Add a background color for better visuals */ +} + +.image-card:hover { + transform: scale(1.05); /* Slightly smaller scale for subtle effect */ + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2); + border: 1px solid black; /* Optional: Separates the image from the card content */ +} + +.image-card img { + width: 100%; + height: 220px; /* Maintain aspect ratio */ + object-fit: cover; /* Ensure the image fills the card */ + border: 1px solid #ddd; /* Optional: Separates the image from the card content */ +} + +.image-card .placeholder { + width: 100%; + height: 220px; /* Match the image height */ + border-radius: 8px; + background-color: #f0f0f0; /* Lighter background for placeholder */ +} + +.image-card img.hidden { + display: none; +} + +.image-card img.visible { + display: block; +} \ No newline at end of file diff --git a/src/components/ImageCard/ImageCard.test.tsx b/src/components/ImageCard/ImageCard.test.tsx new file mode 100644 index 00000000..85927833 --- /dev/null +++ b/src/components/ImageCard/ImageCard.test.tsx @@ -0,0 +1,57 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import ImageCard from './ImageCard'; + +describe('ImageCard Component', () => { + const mockSrc = 'https://example.com/image.jpg'; + const mockAlt = 'Test Image'; + const mockOnClick = jest.fn(); + + it('renders the placeholder while the image is loading', () => { + // Arrange + render(); + + // Act + const image = screen.getByAltText(mockAlt); + + // Assert + expect(image).toHaveClass('hidden'); + }); + + it('hides the placeholder and shows the image after it loads', async () => { + // Arrange + render(); + + // Act + const image = screen.getByAltText(mockAlt); + fireEvent.load(image); + + // Assert + const placeholder = screen.queryByRole('status'); + expect(placeholder).not.toBeInTheDocument(); + expect(image).toHaveClass('visible'); + }); + + it('triggers onClick when the card is clicked', () => { + // Arrange + render(); + const card = screen.getByRole('img', { hidden: true }); + + // Act + fireEvent.click(card); + + // Assert + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('renders the correct image with provided src and alt attributes', () => { + // Arrange + render(); + + // Act + const image = screen.getByAltText(mockAlt); + + // Assert + expect(image).toHaveAttribute('src', mockSrc); + expect(image).toHaveAttribute('alt', mockAlt); + }); +}); diff --git a/src/components/ImageCard/ImageCard.tsx b/src/components/ImageCard/ImageCard.tsx new file mode 100644 index 00000000..87b0c57b --- /dev/null +++ b/src/components/ImageCard/ImageCard.tsx @@ -0,0 +1,41 @@ +import { FC, useState } from 'react'; +import { Card, Placeholder } from 'react-bootstrap'; + +import './ImageCard.css'; + +interface ImageCardProps { + src: string; + alt: string; + onClick?: () => void; +} + +/** + * @component + * A reusable card component for displaying an image with a loading placeholder. + * @param src - The source URL of the image to display. + * @param alt - The alternative text for the image. + * @param onClick - Optional callback function triggered when the card is clicked. + * + */ +const ImageCard: FC = ({ src, alt, onClick }) => { + const [loading, setLoading] = useState(true); // Loading state for image + + return ( + + {loading && ( + + + + )} + setLoading(false)} // Set loading to false when the image loads + className={loading ? 'hidden' : 'visible'} // Hide image while loading + /> + + ); +}; + +export default ImageCard; diff --git a/src/components/ModalComponent/ModalComponent.css b/src/components/ModalComponent/ModalComponent.css new file mode 100644 index 00000000..49a831fb --- /dev/null +++ b/src/components/ModalComponent/ModalComponent.css @@ -0,0 +1,4 @@ +.modal-body-scroll { + max-height: 600px; /* Set the maximum height of the modal body */ + overflow-y: auto; /* Enable vertical scrolling */ +} \ No newline at end of file diff --git a/src/components/ModalComponent/ModalComponent.test.tsx b/src/components/ModalComponent/ModalComponent.test.tsx new file mode 100644 index 00000000..0250060f --- /dev/null +++ b/src/components/ModalComponent/ModalComponent.test.tsx @@ -0,0 +1,88 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import ModalComponent from './ModalComponent'; + +describe('ModalComponent', () => { + const mockOnHide = jest.fn(); + const modalTitle = 'Test Modal'; + const modalContent = 'This is a test modal'; + + const defaultProps = { + show: true, + onHide: mockOnHide, + title: modalTitle, + hasFixedHeight: true, + children: modalContent, + }; + + const setup = (props = {}) => { + const defaultProps = { + show: true, + onHide: mockOnHide, + title: modalTitle, + hasFixedHeight: true, + children: modalContent, + }; + + return render(); + }; + + it('renders the modal when `show` is true', () => { + // Arrange + setup({ show: true }); + + // Act + const title = screen.getByText(modalTitle); + const content = screen.getByText(modalContent); + + // Assert + expect(title).toBeInTheDocument(); + expect(content).toBeInTheDocument(); + }); + + it('does not render the modal when `show` is false', () => { + // Arrange + setup({ show: false }); + + // Act + const title = screen.queryByText(modalTitle); + const content = screen.queryByText(modalContent); + + // Assert + expect(title).not.toBeInTheDocument(); + expect(content).not.toBeInTheDocument(); + }); + + it('calls `onHide` when the close button is clicked', () => { + // Arrange + setup(); + + // Act + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + // Assert + expect(mockOnHide).toHaveBeenCalledTimes(1); + }); + + it('applies the `modal-body-scroll` class when `hasFixedHeight` is true', () => { + // Arrange + setup({ hasFixedHeight: true }); + + // Act + const modalBody = screen.getByText(modalContent); + + // Assert + expect(modalBody).toHaveClass('modal-body-scroll'); + }); + + it('does not apply the `modal-body-scroll` class when `hasFixedHeight` is false', () => { + // Arrange + setup({ hasFixedHeight: false }); + + // Act + const modalBody = screen.getByText(modalContent); + + // Assert + expect(modalBody).not.toHaveClass('modal-body-scroll'); + }); +}); diff --git a/src/components/ModalComponent/ModalComponent.tsx b/src/components/ModalComponent/ModalComponent.tsx new file mode 100644 index 00000000..d52c8843 --- /dev/null +++ b/src/components/ModalComponent/ModalComponent.tsx @@ -0,0 +1,36 @@ +import { FC, ReactNode } from 'react'; +import { Modal } from 'react-bootstrap'; + +import './ModalComponent.css'; + +interface ModalComponentProps { + show: boolean; + //Whether the modal body should have a fixed height with scroll support. + hasFixedHeight?: boolean; + onHide: () => void; + title: string; + //The content to display inside the modal. + children: ReactNode; +} + +/** + * @component + * A reusable modal component + * + * @param show - Controls the visibility of the modal. + * @param hasFixedHeight - Determines whether the modal body has fixed height (default is `true`). + * @param onHide - Callback to handle modal close events. + * @param title - The title displayed in the modal header. + * @param children - The content rendered inside the modal body. + * + */ +const ModalComponent: FC = ({ show, onHide, title, children, hasFixedHeight = true }) => ( + + + {title} + + {children} + +); + +export default ModalComponent; diff --git a/src/components/SpinnerComponent/SpinnerComponent.test.tsx b/src/components/SpinnerComponent/SpinnerComponent.test.tsx new file mode 100644 index 00000000..fb172c8b --- /dev/null +++ b/src/components/SpinnerComponent/SpinnerComponent.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react'; +import SpinnerComponent from './SpinnerComponent'; + +describe('SpinnerComponent', () => { + const setup = () => render(); + + it('renders the spinner', () => { + // Arrange + setup(); + + // Act + const spinner = screen.getByTestId('spinner'); + + // Assert + expect(spinner).toBeInTheDocument(); + }); + + it('applies correct styling', () => { + // Arrange + const { container } = setup(); + + // Act + const spinnerWrapper = container.firstChild; + + // Assert + expect(spinnerWrapper).toHaveStyle('text-align: center; margin: 20px 0;'); + }); +}); diff --git a/src/components/SpinnerComponent/SpinnerComponent.tsx b/src/components/SpinnerComponent/SpinnerComponent.tsx new file mode 100644 index 00000000..80fff58d --- /dev/null +++ b/src/components/SpinnerComponent/SpinnerComponent.tsx @@ -0,0 +1,15 @@ +import { memo } from 'react'; +import { Spinner } from 'react-bootstrap'; + +/** + * @component + * A memoized component that displays a centered loading spinner. + * Used for indicating loading states in the application. + */ +const SpinnerComponent = memo(() => ( +
+ +
+)); + +export default SpinnerComponent; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 00000000..8b2b7bfe --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,13 @@ +export const API_ENDPOINTS = { + BREEDS: '/breeds', + IMAGES: '/images', + FAVORITES: '/favourites', +} as const; + +export const ROUTES = { + HOME: '/', + BREEDS: '/breeds', + IMAGE: '/image', + FAVORITES: '/favorites', + NOT_FOUND: '*', +} as const; diff --git a/src/context/ErrorContext.test.tsx b/src/context/ErrorContext.test.tsx new file mode 100644 index 00000000..01c145be --- /dev/null +++ b/src/context/ErrorContext.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ErrorProvider, useError } from './ErrorContext'; + +// Helper Component to Test `useError` +const TestComponent = () => { + const { showError } = useError(); + return ; +}; + +const setup = () => + render( + + + + ); + +describe('ErrorProvider and useError', () => { + it('renders children normally', () => { + // Arrange + const { getByText } = render( + +
Child Component
+
+ ); + + // Act + const child = getByText('Child Component'); + + // Assert + expect(child).toBeInTheDocument(); + }); + + it('displays error message when `showError` is called', () => { + // Arrange + setup(); + + // Act + const triggerButton = screen.getByText('Trigger Error'); + fireEvent.click(triggerButton); + + // Assert + expect(screen.getByText('Test error message')).toBeInTheDocument(); + }); + + it('hides the error message after close button is clicked', () => { + // Arrange + setup(); + + // Act + const triggerButton = screen.getByText('Trigger Error'); + fireEvent.click(triggerButton); + + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + // Assert + expect(screen.queryByText('Test error message')).not.toBeInTheDocument(); + }); +}); diff --git a/src/context/ErrorContext.tsx b/src/context/ErrorContext.tsx new file mode 100644 index 00000000..d5115156 --- /dev/null +++ b/src/context/ErrorContext.tsx @@ -0,0 +1,60 @@ +import { createContext, FC, useContext, ReactNode, useState } from 'react'; +import { Toast, ToastContainer } from 'react-bootstrap'; + +/** + * Interface for the ErrorContext value. + */ +interface ErrorContextProps { + showError: (message: string) => void; +} + +// Create the context for error handling +const ErrorContext = createContext(undefined); + +/** + * Custom hook to access the ErrorContext. + */ +export const useError = () => { + const context = useContext(ErrorContext); + if (!context) { + throw new Error('useError must be used within an ErrorProvider'); + } + return context; +}; + +/** + * Provider component to manage error notifications and make the `showError` function + * available to its children via context. + */ +export const ErrorProvider: FC<{ children: ReactNode }> = ({ children }) => { + const [errorMessage, setErrorMessage] = useState(null); + const [show, setShow] = useState(false); + + /** + * Displays an error message in a toast notification. + * @param message - The error message to display. + */ + const showError = (message: string) => { + setErrorMessage(message); + setShow(true); + }; + + const handleClose = () => { + setShow(false); + setErrorMessage(null); + }; + + return ( + + {children} + + + + Error + + {errorMessage} + + + + ); +}; diff --git a/src/context/FavoritesContext.test.tsx b/src/context/FavoritesContext.test.tsx new file mode 100644 index 00000000..94626dfb --- /dev/null +++ b/src/context/FavoritesContext.test.tsx @@ -0,0 +1,125 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { IFavoriteCatImage } from '../definitions'; +import * as apiService from '../apiClient/apiService'; +import { ErrorProvider, useError } from './ErrorContext'; +import { FavoritesProvider, useFavoritesContext } from './FavoritesContext'; + +// Mock data +const mockFavorites: Partial[] = [ + { id: '1', image_id: 'img1', image: { id: 'img1', url: 'image1.jpg' } }, + { id: '2', image_id: 'img2', image: { id: 'img2', url: 'image2.jpg' } }, +]; + +jest.mock('../apiClient/apiService'); +jest.mock('./ErrorContext'); + +const TestComponent = () => { + const { favorites, loading, addFavorite, removeFavorite } = useFavoritesContext(); + return ( +
+ {loading &&

Loading...

} +
    + {favorites.map((fav) => ( +
  • + {fav.image.url} + +
  • + ))} +
+ +
+ ); +}; + +const setup = () => + render( + + + + + + ); + +describe('FavoritesProvider and useFavoritesContext', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches and displays favorites on mount', async () => { + // Arrange + // @ts-ignore + jest.spyOn(apiService, 'fetchFavoritesData').mockResolvedValue(mockFavorites); + + // Act + setup(); + + // Assert + await waitFor(() => { + expect(screen.getByText('image1.jpg')).toBeInTheDocument(); + expect(screen.getByText('image2.jpg')).toBeInTheDocument(); + }); + }); + + it('adds a favorite image and refetches favorites', async () => { + // Arrange + // @ts-ignore + jest.spyOn(apiService, 'fetchFavoritesData').mockResolvedValue(mockFavorites); + // @ts-ignore + jest.spyOn(apiService, 'addFavoriteImage').mockResolvedValue({}); + + // Act + setup(); + const addButton = screen.getByText('Add Favorite'); + fireEvent.click(addButton); + + // Assert + await waitFor(() => { + expect(apiService.addFavoriteImage).toHaveBeenCalledWith('img3'); + expect(apiService.fetchFavoritesData).toHaveBeenCalledTimes(2); // Initial fetch + after add + }); + }); + + it('removes a favorite image and updates the state', async () => { + // Arrange + // @ts-ignore + jest.spyOn(apiService, 'fetchFavoritesData').mockResolvedValue(mockFavorites); + // @ts-ignore + jest.spyOn(apiService, 'removeFavoriteImage').mockResolvedValue({}); + + // Act + setup(); + await waitFor(() => screen.getByText('image1.jpg')); // Wait for initial data + const removeButton = screen.getAllByText('Remove')[0]; + fireEvent.click(removeButton); + + // Assert + await waitFor(() => { + expect(apiService.removeFavoriteImage).toHaveBeenCalledWith('1'); + expect(screen.queryByText('image1.jpg')).not.toBeInTheDocument(); + }); + }); + + it('displays an error if `fetchFavorites` fails', async () => { + // Arrange + const mockShowError = jest.fn(); + jest.spyOn(apiService, 'fetchFavoritesData').mockRejectedValue('Fetch error'); + jest.spyOn(useError(), 'showError').mockImplementation(mockShowError); + + // Act + setup(); + + // Assert + await waitFor(() => { + expect(mockShowError).toHaveBeenCalledWith('Failed to fetch favorites: Fetch error'); + }); + }); + + it('throws an error if `useFavoritesContext` is used outside of `FavoritesProvider`', () => { + const InvalidComponent = () => { + useFavoritesContext(); // This should throw + return null; + }; + + expect(() => render()).toThrow('useFavoritesContext must be used within a FavoritesProvider'); + }); +}); diff --git a/src/context/FavoritesContext.tsx b/src/context/FavoritesContext.tsx new file mode 100644 index 00000000..ed701671 --- /dev/null +++ b/src/context/FavoritesContext.tsx @@ -0,0 +1,86 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { IFavoriteCatImage } from '../definitions'; +import { addFavoriteImage, fetchFavoritesData, removeFavoriteImage } from '../apiClient/apiService'; +import { useError } from './ErrorContext'; + +/** + * Interface for the FavoritesContext value. + */ +interface FavoritesContextProps { + //List of favorite cat images + favorites: IFavoriteCatImage[]; + //Indicates whether favorites are being loaded. + loading: boolean; + addFavorite: (imageId: string) => Promise; + removeFavorite: (favoriteId: string) => Promise; +} + +// Create the FavoritesContext +const FavoritesContext = createContext(undefined); + +/** + * Provides the FavoritesContext to its children and manages favorites state and actions. + */ +export const FavoritesProvider = ({ children }: { children: ReactNode }) => { + const [favorites, setFavorites] = useState([]); + const [loading, setLoading] = useState(false); + const { showError } = useError(); + + /** + * Fetches the list of favorite cat images and updates the state. + */ + const fetchFavorites = async () => { + setLoading(true); + try { + const data = await fetchFavoritesData(); + setFavorites(data); + } catch (error) { + showError(`Failed to fetch favorites: ${error || 'Unknown error'}`); + } finally { + setLoading(false); + } + }; + + /** + * Adds a favorite image by its ID and updates the favorites list. + * @param imageId - The ID of the image to add to favorites. + */ + const addFavorite = async (imageId: string) => + addFavoriteImage(imageId) + .then(() => fetchFavorites()) + .catch((error) => showError(`Failed to add favorite: ${error || 'Unknown error'}`)); + + /** + * Removes a favorite image by its favorite ID and updates the state directly. + * @param favoriteId - The ID of the favorite to remove. + */ + const removeFavorite = async (favoriteId: string) => { + try { + await removeFavoriteImage(favoriteId); + setFavorites((prev) => prev.filter((fav) => fav.id !== favoriteId)); // Update context state directly + } catch (error) { + showError(`Failed to remove favorite: ${error || 'Unknown error'}`); + } + }; + + useEffect(() => { + fetchFavorites(); // Fetch favorites only once when the provider mounts + }, []); + + return ( + + {children} + + ); +}; + +/** + * Custom hook to access the FavoritesContext. + */ +export const useFavoritesContext = (): FavoritesContextProps => { + const context = useContext(FavoritesContext); + if (!context) { + throw new Error('useFavoritesContext must be used within a FavoritesProvider'); + } + return context; +}; diff --git a/src/definitions.ts b/src/definitions.ts new file mode 100644 index 00000000..1113b4c7 --- /dev/null +++ b/src/definitions.ts @@ -0,0 +1,41 @@ +interface CatIdentity { + id: string; +} + +/** + * Interface for breed details of cat. + */ +export interface IBreed extends CatIdentity { + weight: { + imperial: string; + metric: string; + }; + name: string; + temperament: string; + origin: string; + description: string; + life_span: string; + alt_names: string; + country_code: string; + energy_level: number; + wikipedia_url: string; +} + +/** + * Interface for image details of cat. + */ +export interface ICatImage extends CatIdentity { + height: number; + url: string; + width: number; + breeds?: IBreed[]; +} + +/** + * Interface for favourite details of cat. + */ +export interface IFavoriteCatImage extends CatIdentity { + image: Pick; + image_id: string; + user_id: string; +} diff --git a/src/hooks/useBreeds.test.ts b/src/hooks/useBreeds.test.ts new file mode 100644 index 00000000..035223fd --- /dev/null +++ b/src/hooks/useBreeds.test.ts @@ -0,0 +1,66 @@ +import { renderHook, act } from '@testing-library/react'; +import { useError } from '../context/ErrorContext'; +import { fetchBreedsData, fetchBreedImagesData } from '../apiClient/apiService'; +import useBreeds from './useBreeds'; + +// Mock dependencies +jest.mock('../context/ErrorContext', () => ({ + useError: jest.fn(), +})); + +jest.mock('../apiClient/apiService', () => ({ + fetchBreedsData: jest.fn(), + fetchBreedImagesData: jest.fn(), +})); + +describe('useBreeds', () => { + const mockShowError = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useError as jest.Mock).mockReturnValue({ showError: mockShowError }); + }); + + it('does not fetch breeds if they are already cached', async () => { + const mockBreeds = [ + { id: '1', name: 'Breed 1' }, + { id: '2', name: 'Breed 2' }, + ]; + sessionStorage.setItem('breeds', JSON.stringify(mockBreeds)); + + const { result } = renderHook(() => useBreeds()); + + expect(result.current.breeds).toEqual(mockBreeds); + expect(fetchBreedsData).not.toHaveBeenCalled(); + }); + + it('fetches breed images for a given breed ID', async () => { + const mockBreedImages = [ + { id: 'img1', url: 'image1.jpg' }, + { id: 'img2', url: 'image2.jpg' }, + ]; + (fetchBreedImagesData as jest.Mock).mockResolvedValue(mockBreedImages); + + const { result } = renderHook(() => useBreeds('1')); + + await act(async () => { + await result.current.fetchBreedImages('1'); + }); + + expect(result.current.breedImages).toEqual(mockBreedImages); + expect(result.current.loadingImages).toBe(false); + }); + + it('shows an error if fetching breed images fails', async () => { + (fetchBreedImagesData as jest.Mock).mockRejectedValue('Fetch error'); + + const { result } = renderHook(() => useBreeds('1')); + + await act(async () => { + await result.current.fetchBreedImages('1'); + }); + + expect(mockShowError).toHaveBeenCalledWith('Failed to fetch breed images: Fetch error'); + expect(result.current.loadingImages).toBe(false); + }); +}); diff --git a/src/hooks/useBreeds.ts b/src/hooks/useBreeds.ts new file mode 100644 index 00000000..a685371d --- /dev/null +++ b/src/hooks/useBreeds.ts @@ -0,0 +1,91 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useError } from '../context/ErrorContext'; +import { IBreed, ICatImage } from '../definitions'; +import { fetchBreedImagesData, fetchBreedsData } from '../apiClient/apiService'; + +const SESSION_STORAGE_KEY = 'breeds'; + +/** + * Custom hook to manage breeds and breed-related images. + * @param breedId - Optional breed ID to fetch specific breed images. + * @returns Object containing breeds, selectedBreed, breedImages, loading states, and action functions. + */ +const useBreeds = (breedId?: string) => { + const [breeds, setBreeds] = useState(() => { + const storedBreeds = sessionStorage.getItem(SESSION_STORAGE_KEY); + return storedBreeds ? JSON.parse(storedBreeds) : []; + }); + + const [selectedBreed, setSelectedBreed] = useState(null); + const [breedImages, setBreedImages] = useState([]); + + const [loadingBreeds, setLoadingBreeds] = useState(false); + const [loadingImages, setLoadingImages] = useState(false); + + const { showError } = useError(); + + /** + * Fetches the list of breeds and caches them in session storage. + */ + const fetchBreeds = useCallback(async () => { + if (breeds.length > 0) return; // Avoid fetching if breeds are already loaded + + setLoadingBreeds(true); + try { + const data = await fetchBreedsData(); + setBreeds(data); + sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(data)); // Cache in session storage + } catch (error) { + showError(`Failed to fetch breeds: ${error || 'Unknown error'}`); + } finally { + setLoadingBreeds(false); + } + }, [breeds, showError]); + + /** + * Fetches images for a specific breed by its ID. + * @param breedId - The ID of the breed to fetch images for. + */ + const fetchBreedImages = useCallback( + async (breedId: string) => { + setLoadingImages(true); + try { + const data = await fetchBreedImagesData(breedId); + setBreedImages(data); + } catch (error) { + showError(`Failed to fetch breed images: ${error || 'Unknown error'}`); + } finally { + setLoadingImages(false); + } + }, + [showError] + ); + + // Fetch breeds on mount + useEffect(() => { + fetchBreeds(); + }, [fetchBreeds]); + + // Fetch breed images when a specific breed ID is provided (after redirecting usually) + useEffect(() => { + if (breedId) { + const breed = breeds.find((b) => b.id === breedId); + if (breed) { + setSelectedBreed(breed); + fetchBreedImages(breed.id); + } + } + }, [breedId, breeds, fetchBreedImages]); + + return { + breeds, + selectedBreed, + setSelectedBreed, + breedImages, + loadingBreeds, + loadingImages, + fetchBreedImages, + }; +}; + +export default useBreeds; diff --git a/src/hooks/useImages.test.ts b/src/hooks/useImages.test.ts new file mode 100644 index 00000000..e6ce13b4 --- /dev/null +++ b/src/hooks/useImages.test.ts @@ -0,0 +1,74 @@ +import { renderHook, act } from '@testing-library/react'; +import { useError } from '../context/ErrorContext'; +import { fetchRandomImages } from '../apiClient/apiService'; +import useImages from './useImages'; +import { ICatImage } from '../definitions'; + +jest.mock('../context/ErrorContext', () => ({ + useError: jest.fn(), +})); + +jest.mock('../apiClient/apiService', () => ({ + fetchRandomImages: jest.fn(), +})); + +describe('useImages', () => { + const mockImages: Partial[] = [ + { id: '1', url: 'image1.jpg' }, + { id: '2', url: 'image2.jpg' }, + ]; + const mockShowError = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useError as jest.Mock).mockReturnValue({ showError: mockShowError }); + }); + + it('fetches images on initial load', async () => { + // Arrange + (fetchRandomImages as jest.Mock).mockResolvedValue(mockImages); + const { result } = renderHook(() => useImages()); + + // Act + await act(async () => { + await result.current.fetchImages(); + }); + + // Assert + expect(result.current.images).toEqual(mockImages); + expect(result.current.loading).toBe(false); + }); + + it('handles errors when fetchImages fails', async () => { + // Arrange + (fetchRandomImages as jest.Mock).mockRejectedValue('Fetch error'); + const { result } = renderHook(() => useImages()); + + // Act + await act(async () => { + await result.current.fetchImages(); + }); + + // Assert + expect(mockShowError).toHaveBeenCalledWith('Failed to fetch images: Fetch error'); + expect(result.current.loading).toBe(false); + }); + + it('sets loading state correctly during image fetch', async () => { + // Arrange + (fetchRandomImages as jest.Mock).mockResolvedValue(mockImages); + const { result } = renderHook(() => useImages()); + + // Act + act(() => { + result.current.fetchImages(); + }); + // Assert + expect(result.current.loading).toBe(true); + + await act(async () => { + await result.current.fetchImages(); + }); + expect(result.current.loading).toBe(false); + }); +}); diff --git a/src/hooks/useImages.ts b/src/hooks/useImages.ts new file mode 100644 index 00000000..81e786bc --- /dev/null +++ b/src/hooks/useImages.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { useError } from '../context/ErrorContext'; +import { ICatImage } from '../definitions'; +import { fetchRandomImages } from '../apiClient/apiService'; + +/** + * Custom hook to manage fetching and state for cat images. + * @returns Object containing the list of images, a function to fetch images, and the loading state. + */ +const useImages = () => { + const [images, setImages] = useState([]); + const [loading, setLoading] = useState(false); + + const { showError } = useError(); + + /** + * Fetches random cat images and updates the state. + * @param append - Whether to append new images to the existing list. Defaults to `false`. + */ + const fetchImages = useCallback( + async (append: boolean = false) => { + setLoading(true); + try { + const data = await fetchRandomImages(); + setImages((prevImages) => (append ? [...prevImages, ...data] : data)); + } catch (error) { + showError(`Failed to fetch images: ${error || 'Unknown error'}`); + } finally { + setLoading(false); + } + }, + [showError] + ); + + // Fetch images on mount + useEffect(() => { + fetchImages(); + }, []); + + return { images, fetchImages, loading }; +}; + +export default useImages; diff --git a/src/index.css b/src/index.css new file mode 100644 index 00000000..8b5b79ca --- /dev/null +++ b/src/index.css @@ -0,0 +1,18 @@ +:root { + --navColor: #debf83; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: #fbf9f1; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 00000000..10db558f --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,26 @@ +import ReactDOM from 'react-dom/client'; +import App from './App'; +import reportWebVitals from './reportWebVitals'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { ErrorProvider } from './context/ErrorContext'; +import { FavoritesProvider } from './context/FavoritesContext'; + +import 'bootstrap/dist/css/bootstrap.min.css'; +import './index.css'; + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); + +root.render( + + + + + + + +); + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git a/src/logo.svg b/src/logo.svg new file mode 100644 index 00000000..9dfc1c05 --- /dev/null +++ b/src/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pages/Breeds/Breeds.css b/src/pages/Breeds/Breeds.css new file mode 100644 index 00000000..02c25fa1 --- /dev/null +++ b/src/pages/Breeds/Breeds.css @@ -0,0 +1,8 @@ +.no-scrollbar::-webkit-scrollbar { + display: none; /* For Chrome, Safari, and Edge */ +} + +.no-scrollbar { + -ms-overflow-style: none; /* For Internet Explorer and Edge */ + scrollbar-width: none; /* For Firefox */ +} diff --git a/src/pages/Breeds/Breeds.tsx b/src/pages/Breeds/Breeds.tsx new file mode 100644 index 00000000..004a6d4c --- /dev/null +++ b/src/pages/Breeds/Breeds.tsx @@ -0,0 +1,105 @@ +import { FC, CSSProperties } from 'react'; +import { FixedSizeList as List } from 'react-window'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Row, Col, ListGroup, Container } from 'react-bootstrap'; + +import useBreeds from '../../hooks/useBreeds'; +import ModalComponent from '../../components/ModalComponent/ModalComponent'; +import SpinnerComponent from '../../components/SpinnerComponent/SpinnerComponent'; +import ImageCard from '../../components/ImageCard/ImageCard'; +import { ROUTES } from '../../constants'; +import { IBreed } from '../../definitions'; + +import './Breeds.css'; + +/** + * @component + * A component that displays a list of cat breeds with a virtualized scrollable list. + * Also, when a breed is selected, a modal opens to show related images for that breed. + * + */ +const Breeds: FC = () => { + const { id } = useParams(); + const navigate = useNavigate(); + + const { breeds, selectedBreed, setSelectedBreed, breedImages, loadingBreeds, loadingImages } = useBreeds(id); + + const openBreedModal = (breed: IBreed) => { + navigate(`${ROUTES.BREEDS}/${breed.id}`); + }; + + const closeModal = () => { + setSelectedBreed(null); + navigate(ROUTES.BREEDS); + }; + + /** + * Renders a single row in the virtualized breed list. + * @param index - The index of the breed in the list. + * @param style - The CSS styles applied to the row. + */ + const renderBreedRow = ({ index, style }: { index: number; style: CSSProperties }) => { + const breed = breeds[index]; + + return ( + openBreedModal(breed)} + className="text-center d-flex justify-content-center align-items-center" + > + {breed.name} + + ); + }; + + return ( + +

Cat Breeds

+ + {/* List of Breeds */} + {loadingBreeds ? ( + + ) : ( + + + {renderBreedRow} + + + )} + + {/* Modal for Selected Breed */} + {selectedBreed && ( + + {loadingImages ? ( + + ) : ( + <> + +
Related cats:
+
+ + {breedImages.map((image) => ( + + navigate(`${ROUTES.IMAGE}/${image.id}`)} /> + + ))} + + + )} +
+ )} +
+ ); +}; + +export default Breeds; diff --git a/src/pages/Favorites/Favorites.css b/src/pages/Favorites/Favorites.css new file mode 100644 index 00000000..7009b7e2 --- /dev/null +++ b/src/pages/Favorites/Favorites.css @@ -0,0 +1,85 @@ +@keyframes fadeOut { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.9); + } +} + +.favorites-container { + display: flex; + flex-wrap: wrap; + gap: 20px; + justify-content: flex-start; + padding: 20px; +} + +.favorite-item { + position: relative; + flex: 1 1 calc(25% - 20px); /* Four items per row with gap */ + max-width: 200px; + margin: 10px; +} + +.favorite-item img { + width: 100%; + object-fit: cover; + border-radius: 8px; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.favorite-item img:hover { + transform: scale(1.05); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +.delete-button { + position: absolute; + top: 5px; + right: 5px; + background-color: red; + color: white; + border: none; + border-radius: 50%; + width: 30px; + height: 30px; + font-size: 20px; + font-weight: bold; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + transition: transform 0.2s ease, background-color 0.2s ease; +} + +.delete-button:hover { + background-color: darkred; + transform: scale(1.1); +} + +@media (max-width: 768px) { + .favorite-item { + flex: 1 1 calc(50% - 20px); /* Two items per row on smaller screens */ + } +} + +@media (max-width: 544px) { + .favorites-container { + justify-content: center; + } + .favorite-item { + flex: 1 1 calc(100% - 20px); /* One item per row on very small screens */ + } +} + +.image-card card { + height: 100%; +} + +.favorite-item.removing { + animation: fadeOut 0.3s ease-out forwards; +} diff --git a/src/pages/Favorites/Favorites.tsx b/src/pages/Favorites/Favorites.tsx new file mode 100644 index 00000000..06ebaafe --- /dev/null +++ b/src/pages/Favorites/Favorites.tsx @@ -0,0 +1,94 @@ +import { FC, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import SpinnerComponent from '../../components/SpinnerComponent/SpinnerComponent'; +import ImageCard from '../../components/ImageCard/ImageCard'; +import ModalComponent from '../../components/ModalComponent/ModalComponent'; +import { useFavoritesContext } from '../../context/FavoritesContext'; +import { ROUTES } from '../../constants'; + +import './Favorites.css'; + +/** + * @component + * A component to display and manage the user's favorite cat images. + * Also, Includes functionality for removing favorites. + */ +const Favorites: FC = () => { + const { favorites, loading, removeFavorite } = useFavoritesContext(); + const navigate = useNavigate(); + + const [removingIds, setRemovingIds] = useState([]); // Track IDs being removed + const [selectedFavoriteId, setSelectedFavoriteId] = useState(null); // ID of the item to delete + + const handleRemoveFavorite = async (id: string) => { + setRemovingIds((prev) => [...prev, id]); // Mark as removing + + try { + await removeFavorite(id); + } finally { + setRemovingIds((prev) => prev.filter((favId) => favId !== id)); + } + }; + + const confirmRemoveFavorite = (id: string) => { + setSelectedFavoriteId(id); + }; + + const proceedWithDeletion = () => { + if (selectedFavoriteId) { + handleRemoveFavorite(selectedFavoriteId); + setSelectedFavoriteId(null); + } + }; + + const cancelDeletion = () => { + setSelectedFavoriteId(null); + }; + + return ( +
+

Your Favorite Cats

+ {loading ? ( + + ) : favorites.length === 0 ? ( +
You have no favorite cats yet!
+ ) : ( +
+ {favorites.map((favorite) => ( +
+ navigate(`${ROUTES.IMAGE}/${favorite.image.id}`)} + /> + +
+ ))} +
+ )} + + {/* Confirmation Modal */} + +

Are you sure you want to delete this favorite?

+
+ + +
+
+
+ ); +}; + +export default Favorites; diff --git a/src/pages/Home/Home.css b/src/pages/Home/Home.css new file mode 100644 index 00000000..a65a99a8 --- /dev/null +++ b/src/pages/Home/Home.css @@ -0,0 +1,12 @@ +.image-grid-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); /* Adjust 150px as needed for smaller screens */ + gap: 15px; + margin: 20px; +} + +.image-content { + width: 100%; + height: auto; +} + diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx new file mode 100644 index 00000000..1fee8d6f --- /dev/null +++ b/src/pages/Home/Home.tsx @@ -0,0 +1,132 @@ +import { useEffect, FC, useState, ChangeEvent } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Button, Form, Spinner } from 'react-bootstrap'; + +import useImages from '../../hooks/useImages'; +import ModalComponent from '../../components/ModalComponent/ModalComponent'; +import ImageCard from '../../components/ImageCard/ImageCard'; +import { useError } from '../../context/ErrorContext'; +import { ICatImage } from '../../definitions'; +import { fetchImageDataById } from '../../apiClient/apiService'; +import { useFavoritesContext } from '../../context/FavoritesContext'; +import ImageDetails from './ImageDetails'; +import { ROUTES } from '../../constants'; + +import './Home.css'; + +/** + * @component + * The Home component is the main page of the application, displaying a grid of cat images. + * It includes functionality for viewing image details, marking favorites, and pagination for loading more images. + */ +const Home: FC = () => { + const { images, fetchImages, loading } = useImages(); + const { favorites, addFavorite, removeFavorite } = useFavoritesContext(); + const { showError } = useError(); + + const [selectedImage, setSelectedImage] = useState(null); + const [isFavorite, setIsFavorite] = useState(false); + const [isOpenModal, setIsOpenModal] = useState(false); + + const navigate = useNavigate(); + const { id } = useParams(); + + /** + * Fetches the details of a specific image when its ID is present in the route. + */ + useEffect(() => { + if (id) { + fetchImageDataById(id) + .then((data) => { + if (data) { + const isFav = favorites.some((fav) => fav.image_id === id); + + setSelectedImage(data); + setIsFavorite(isFav); + setIsOpenModal(true); + } + }) + .catch((error) => { + showError(`Error fetching image: ${error || 'Unknown error'}`); + navigate('/'); + }); + } + }, [id]); + + const openModal = (image: ICatImage) => { + setSelectedImage(image); + setIsFavorite(favorites.some((fav) => fav.image_id === image.id)); + + navigate(`${ROUTES.IMAGE}/${image.id}`); + }; + + const closeModal = () => { + setIsOpenModal(false); + setSelectedImage(null); + + navigate(`/`); + }; + + const handleFavoriteChange = async (e: ChangeEvent) => { + if (!selectedImage) return; + + const checked = e.target.checked; + setIsFavorite(checked); + + if (checked) { + await addFavorite(selectedImage.id); + } else { + const favorite = favorites.find((fav) => fav.image_id === selectedImage.id); + if (favorite) { + await removeFavorite(favorite.id); + } + } + }; + + return ( + <> + {/* Render Image Grid */} +

Cat Images

+
+ {images.map((image, index) => ( + openModal(image)} /> + ))} +
+ + + {/* Render modal with selected image details */} + {selectedImage && ( + + Selected Cat + {selectedImage.breeds?.[0] ? ( + + ) : ( +

+ Breed: Unknown +

+ )} +
+ + +
+ )} + + ); +}; + +export default Home; diff --git a/src/pages/Home/ImageDetails.tsx b/src/pages/Home/ImageDetails.tsx new file mode 100644 index 00000000..2090f4a5 --- /dev/null +++ b/src/pages/Home/ImageDetails.tsx @@ -0,0 +1,89 @@ +import React, { FC } from 'react'; +import { Button, ProgressBar } from 'react-bootstrap'; +import { useNavigate } from 'react-router-dom'; + +import AccordionComponent, { AccordionItem } from '../../components/AccordionComponent/AccordionComponent'; +import { IBreed } from '../../definitions'; +import { ROUTES } from '../../constants'; + +interface ImageDetailsProps { + breedDetails: IBreed; +} + +/** + * @component + * A component that displays detailed information about a specific cat breed. + * They are organized into an accordion with sections for basic info and detailed attributes. + * @param breedDetails - The breed details to display. + */ +const ImageDetails: FC = ({ breedDetails }) => { + const navigate = useNavigate(); + + const accordionItems: AccordionItem[] = [ + { + eventKey: '0', + title: 'Basic Information', + content: ( + <> +

+ Breed: {breedDetails.name} +

+

+ Alternative Names: {breedDetails?.alt_names || 'N/A'} +

+

+ Description: {breedDetails.description} +

+ + ), + }, + { + eventKey: '1', + title: 'Details', + content: ( + <> +

+ Life Span: {breedDetails.life_span} years +

+

+ Origin: {breedDetails.origin} +

+

+ Temperament: {breedDetails.temperament} +

+

+ Weight (Imperial): {breedDetails.weight.imperial} lbs +

+

+ Weight (Metric): {breedDetails.weight.metric} kg +

+

+ Wikipedia info: + + {breedDetails.wikipedia_url} + +

+

+ Energy Level: +

+ + ), + }, + ]; + + return ( +
+ + +
+ ); +}; + +export default ImageDetails; diff --git a/src/pages/NotFound/NotFound.test.tsx b/src/pages/NotFound/NotFound.test.tsx new file mode 100644 index 00000000..600d16b7 --- /dev/null +++ b/src/pages/NotFound/NotFound.test.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import NotFound from './NotFound'; + +describe('NotFound Component', () => { + const setup = () => + render( + + + + ); + + it('renders the 404 message', () => { + // Arrange + const { getByText } = setup(); + + // Act + const heading = getByText('404 - Page Not Found'); + const message = getByText('The page you are looking for does not exist.'); + + // Assert + expect(heading).toBeInTheDocument(); + expect(message).toBeInTheDocument(); + }); + + it('renders the link to go back to home', () => { + // Arrange + const { getByRole } = setup(); + + // Act + const link = getByRole('link', { name: /go back to home/i }); + + // Assert + expect(link).toHaveAttribute('href', '/'); + }); +}); diff --git a/src/pages/NotFound/NotFound.tsx b/src/pages/NotFound/NotFound.tsx new file mode 100644 index 00000000..9c31ef3a --- /dev/null +++ b/src/pages/NotFound/NotFound.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react'; +import { Link } from 'react-router-dom'; + +/** + * @component + * A simple component to display a 404 - Page Not Found message. + * Also, provides a link to navigate back to the home page. + */ +const NotFound: FC = () => ( +
+

404 - Page Not Found

+

The page you are looking for does not exist.

+ Go back to Home +
+); + +export default NotFound; diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts new file mode 100644 index 00000000..6431bc5f --- /dev/null +++ b/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/reportWebVitals.ts b/src/reportWebVitals.ts new file mode 100644 index 00000000..49a2a16e --- /dev/null +++ b/src/reportWebVitals.ts @@ -0,0 +1,15 @@ +import { ReportHandler } from 'web-vitals'; + +const reportWebVitals = (onPerfEntry?: ReportHandler) => { + if (onPerfEntry && onPerfEntry instanceof Function) { + import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + getCLS(onPerfEntry); + getFID(onPerfEntry); + getFCP(onPerfEntry); + getLCP(onPerfEntry); + getTTFB(onPerfEntry); + }); + } +}; + +export default reportWebVitals; diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 00000000..8f2609b7 --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..a273b0cf --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": [ + "src" + ] +}