diff --git a/package-lock.json b/package-lock.json index 49b8a8e..60709c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "name": "rs-react-app", "version": "0.0.0", "dependencies": { + "@reduxjs/toolkit": "^2.8.2", "@types/react-router-dom": "^5.3.3", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-redux": "^9.2.0", "react-router": "^7.7.1", "react-router-dom": "^7.7.1" }, @@ -1325,6 +1327,31 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", + "integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.19", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", @@ -1591,6 +1618,16 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -1816,6 +1853,11 @@ "@testing-library/user-event": "*" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.36.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz", @@ -4039,6 +4081,15 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5359,6 +5410,28 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5417,6 +5490,19 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5459,6 +5545,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" + }, "node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", @@ -6476,6 +6567,14 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz", diff --git a/package.json b/package.json index 4f8c9a3..ba9326a 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,11 @@ "coverage": "vitest run --coverage" }, "dependencies": { + "@reduxjs/toolkit": "^2.8.2", "@types/react-router-dom": "^5.3.3", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-redux": "^9.2.0", "react-router": "^7.7.1", "react-router-dom": "^7.7.1" }, diff --git a/src/About.tsx b/src/About.tsx index ccc1750..65c3a8d 100644 --- a/src/About.tsx +++ b/src/About.tsx @@ -1,9 +1,7 @@ import React from "react"; -import Header from "./Header"; function About(): React.ReactElement { return (
-

Star Wars Planets App

The author of the application is Pavel Kozin

diff --git a/src/App.css b/src/App.css index 11ea586..a936122 100644 --- a/src/App.css +++ b/src/App.css @@ -1,3 +1,11 @@ +body { + background-color: white; +} + +body.dark { + background-color: #2a2a2a; +} + .app-container { max-width: 1200px; margin: 0 auto; @@ -6,6 +14,14 @@ padding-right: 100px; } +.light { + background-color: white; +} + +.dark { + background-color: #2a2a2a; +} + .title { text-align: center; color: #000000; @@ -13,6 +29,10 @@ font-size: 2.5rem; } +.title.dark { + color: #ffffff; +} + .search-container { display: flex; align-items: center; @@ -23,7 +43,7 @@ margin-bottom: 30px; } -input { +.main-input { flex-grow: 1; padding: 12px 15px; border: 2px solid #444; @@ -32,6 +52,9 @@ input { color: white; font-size: 1rem; } +.search-container.dark { + background-color: #0e0e0e; +} .search-container button { padding: 12px 25px; @@ -68,11 +91,39 @@ input { gap: 10px; } +.planet-checkbox { + width: 20px; + height: 20px; + + appearance: none; + outline: none; + cursor: pointer; + + border: 2px solid #000; + background-color: #fff; + border-radius: 4px; + + position: relative; + transition: all 0.2s ease; +} + +.planet-checkbox:checked { + background-color: #ffd700; + border-color: #000; +} + +.planet-checkbox:hover { + box-shadow: 0 0 0 2px rgba(255, 215, 0, 0.3); +} + .planet-card { + display: flex; + justify-content: space-between; background-color: #333; border-radius: 8px; padding: 10px 20px 10px 20px; transition: transform 0.3s; + cursor: pointer; } .planet-details { @@ -199,8 +250,8 @@ input { transform: rotate(360deg); } } + .error-test-button { - margin: 20px auto; padding: 10px 20px; background-color: #ffe81f; color: rgb(0, 0, 0); diff --git a/src/App.tsx b/src/App.tsx index bef00bd..def1f3d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,21 +5,31 @@ import About from "./About"; import ErrorBoundary from "./ErrorBoundary"; import { BrowserRouter } from "react-router"; import PlanetCard from "./PlanetCard"; +import { ThemeProvider } from "./ThemeProvider"; +import { Provider } from "react-redux"; +import { store } from "./store/store"; +import Wrapper from "./Wrapper"; function App() { return ( - - - - } /> - }> - } /> - - } /> - } /> - - - + + + + + + }> + } /> + } /> + }> + } /> + + } /> + + + + + + ); } diff --git a/src/Header.css b/src/Header.css deleted file mode 100644 index 37ec9df..0000000 --- a/src/Header.css +++ /dev/null @@ -1,24 +0,0 @@ -header { - background-color: #2a2a2a; - padding: 20px 30px; - border-radius: 10px; - font-weight: bold; - color: #aaa; -} -nav { - display: flex; - justify-content: start; - gap: 30px; -} -.nav-link { - text-decoration: none; - color: inherit; - - color: #aaa; - font-weight: bold; -} - -.nav-link:hover { - text-decoration: none; - color: #f8e329; -} diff --git a/src/Header.tsx b/src/Header.tsx deleted file mode 100644 index 9e2b4aa..0000000 --- a/src/Header.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { NavLink } from "react-router-dom"; -import "./Header.css"; -function Header() { - return ( -
- -
- ); -} -export default Header; diff --git a/src/Header/Header.css b/src/Header/Header.css new file mode 100644 index 0000000..eeda275 --- /dev/null +++ b/src/Header/Header.css @@ -0,0 +1,53 @@ +header.light { + background-color: #2a2a2a; + color: #aaa; +} + +header.dark { + background-color: #131212; + color: #525151; +} + +nav { + display: flex; + justify-content: start; + gap: 30px; +} +.nav-link { + text-decoration: none; + color: inherit; + + color: #aaa; + font-weight: bold; +} + +.nav-link:hover { + text-decoration: none; + color: #f8e329; +} + +header { + padding: 20px 30px; + border-radius: 10px; + font-weight: bold; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 3%; +} + +.switch_theme_button { + align-self: flex-end; + padding: 12px 25px; + background-color: #f8e329; + color: #000; + border-radius: 6px; + font-weight: bold; + cursor: pointer; + transition: background-color 0.3s; +} + +.switch_theme_button:hover { + background-color: #f1cc10; +} diff --git a/src/Header/Header.tsx b/src/Header/Header.tsx new file mode 100644 index 0000000..cadf6c1 --- /dev/null +++ b/src/Header/Header.tsx @@ -0,0 +1,26 @@ +import { NavLink } from "react-router-dom"; +import "./Header.css"; +import { ThemeContext } from "../ThemeContext"; +import { useContext } from "react"; +import ErrorButton from "../ErrorButton"; + +function Header({ handleError }: { handleError: () => void }) { + const { theme, toggleTheme } = useContext(ThemeContext) || {}; + return ( +
+ + + +
+ ); +} +export default Header; diff --git a/src/Input.tsx b/src/Input.tsx index 57205cc..5ca588c 100644 --- a/src/Input.tsx +++ b/src/Input.tsx @@ -1,11 +1,21 @@ import React from "react"; +import { useContext } from "react"; +import { ThemeContext } from "./ThemeContext"; interface InputProps { value: string; onChange: (e: React.ChangeEvent) => void; } function Input({ value, onChange }: InputProps): React.ReactElement { - return ; + const { theme } = useContext(ThemeContext) || {}; + return ( + + ); } export default Input; diff --git a/src/MainPage.tsx b/src/MainPage.tsx index dbc9468..cd398e8 100644 --- a/src/MainPage.tsx +++ b/src/MainPage.tsx @@ -1,4 +1,10 @@ -import React, { useRef, useState, useEffect, useCallback } from "react"; +import React, { + useRef, + useState, + useEffect, + useCallback, + useContext, +} from "react"; import { useParams, useSearchParams, @@ -6,8 +12,6 @@ import { useNavigate, } from "react-router-dom"; import "./App.css"; -import ErrorButton from "./ErrorButton"; -import Header from "./Header"; import { PlanetApi } from "./PlanetFetch"; import Planets from "./Planets"; import Button from "./Button"; @@ -15,7 +19,11 @@ import Input from "./Input"; import type { PlanetsListItem } from "./types"; import Pagination from "./Pagination"; import useLocalStorage from "./hooks/useLocalStorage"; - +import { ThemeContext } from "./ThemeContext"; +import { useDispatch } from "react-redux"; +import { addItem, removeItem, stateItems } from "./store/selectedItemsSlice"; +import SelectedPlanets from "./SelectedPlanets/SelectedPlanets.tsx"; +import { useAppSelector } from "./hooks/hooks.ts"; const planetsPerPage = 10; function MainPage(): React.ReactElement { @@ -33,6 +41,21 @@ function MainPage(): React.ReactElement { const [searchPlanets, setPlanets] = useState([]); const [isLoading, setIsLoading] = useState(false); const [totalPlanets, setTotalPlanets] = useState(0); + + const { theme } = useContext(ThemeContext) || {}; + + const dispatch = useDispatch(); + + const selectedItems = useAppSelector(stateItems); + + const toggleItemSelection = (item: PlanetsListItem) => { + if (selectedItems.some((selected) => selected.uid === item.uid)) { + dispatch(removeItem(item.uid)); + } else { + dispatch(addItem(item)); + } + }; + const getPlanets = useCallback(async () => { let errorMessage = "Unknown error"; setIsLoading(true); @@ -73,32 +96,20 @@ function MainPage(): React.ReactElement { }, [getPlanets, searchQuery]); const [error, setError] = useState(null); - const [errorBoolean, setErrorBoolean] = useState(false); - - function triggerError() { - setErrorBoolean(true); - } function handleSearch() { - navigate(`/1?search=${encodeURIComponent(inputValue)}`); + navigate(`/list/1?search=${encodeURIComponent(inputValue)}`); } function handleInputChange(e: React.ChangeEvent) { setInputValue(e.target.value); } - if (errorBoolean) { - throw new Error("Test error"); - } - return ( -
-
-

Star Wars Planets

- + <> +

Star Wars Planets

{error ?
{error}
: null} - -
+
@@ -107,6 +118,8 @@ function MainPage(): React.ReactElement { searchPlanets={searchPlanets} isLoading={isLoading} inputValue={inputValue} + onItemSelect={toggleItemSelection} + selectedItems={selectedItems} />
@@ -118,8 +131,8 @@ function MainPage(): React.ReactElement { searchQuery={searchQuery} > )} - -
+ {!isLoading && } + ); } diff --git a/src/Pagination.tsx b/src/Pagination.tsx index ec20f7a..7de7a52 100644 --- a/src/Pagination.tsx +++ b/src/Pagination.tsx @@ -24,7 +24,7 @@ export default function Pagination({
{pageNumbers.map((number) => ( { - navigate(pageNumber ? `/${pageNumber}` : "/"); + navigate(pageNumber ? `/list/${pageNumber}` : "/"); }; useEffect(() => { diff --git a/src/PlanetMiniCard.tsx b/src/PlanetMiniCard.tsx index 422f968..77c4255 100644 --- a/src/PlanetMiniCard.tsx +++ b/src/PlanetMiniCard.tsx @@ -2,6 +2,10 @@ import React from "react"; import type { PlanetsListItem } from "./types"; import { useNavigate, useParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { addItem, removeItem, stateItems } from "./store/selectedItemsSlice"; +import { useAppSelector } from "./hooks/hooks.ts"; + interface PlanetMiniCardProps { planet: PlanetsListItem; } @@ -10,21 +14,43 @@ function PlanetMiniCard({ planet }: PlanetMiniCardProps): React.ReactElement { const { pageNumber } = useParams(); const [searchParams] = useSearchParams(); const searchQuery = searchParams.get("search") || ""; + const navigate = useNavigate(); + + const dispatch = useDispatch(); + const selectedItems = useAppSelector(stateItems); + const id = planet.url.split("/").pop(); + + const isSelected = selectedItems.some((item) => item.uid === planet.uid); + + const handleCheckboxChange = (e: React.ChangeEvent) => { + e.stopPropagation(); + if (isSelected) { + dispatch(removeItem(planet.uid)); + } else { + dispatch(addItem(planet)); + } + }; + + const handleCardClick = () => { + if (!pageNumber) { + navigate(`/list/1/${id}?search=${searchQuery}`); + } else { + navigate(`/list/${pageNumber}/${id}?search=${searchQuery}`); + } + }; + return ( -
{ - if (!pageNumber) { - navigate(`/1/${id}?search=${searchQuery}`); - } else { - navigate(`/${pageNumber}/${id}?search=${searchQuery}`); - } - }} - > +

{planet.name}

+ e.stopPropagation()} + className="planet-checkbox" + />
); } diff --git a/src/Planets.tsx b/src/Planets.tsx index 6201c7f..742ba99 100644 --- a/src/Planets.tsx +++ b/src/Planets.tsx @@ -6,6 +6,8 @@ interface PlanetsProps { searchPlanets: PlanetsListItem[]; isLoading: boolean; inputValue: string; + onItemSelect: (item: PlanetsListItem) => void; + selectedItems: PlanetsListItem[]; } export default function Planets({ diff --git a/src/SelectedPlanets/SelectedPlanets.css b/src/SelectedPlanets/SelectedPlanets.css new file mode 100644 index 0000000..bd93acb --- /dev/null +++ b/src/SelectedPlanets/SelectedPlanets.css @@ -0,0 +1,64 @@ +.selected-planets { + position: fixed; + bottom: -1000px; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.9); + color: white; + padding: 1rem; + z-index: 1000; + max-height: 50vh; + transition: bottom 0.3s ease; +} + +.selected-planets.visible { + bottom: 0px; +} + +.download_button { + align-self: flex-end; + background-color: #000000; + color: #ffe81f; + border: 2px solid #ffe81f; + padding: 8px 16px; + font-weight: bold; + border-radius: 4px; + cursor: pointer; + width: 30%; + text-transform: uppercase; + transition: all 0.3s ease; +} + +.download_button:hover { + background-color: #ffe81f; + color: #000000; + box-shadow: 0 0 10px #ffe81f; +} + +.download_button:active { + transform: scale(0.95); +} + +.clear_button { + align-self: flex-end; + background-color: #000000; + color: #ffe81f; + border: 2px solid #ffe81f; + padding: 8px 16px; + font-weight: bold; + border-radius: 4px; + cursor: pointer; + width: 30%; + text-transform: uppercase; + transition: all 0.3s ease; +} + +.clear_button:hover { + background-color: #ffe81f; + color: #000000; + box-shadow: 0 0 10px #ffe81f; +} + +.clear_button:active { + transform: scale(0.95); +} diff --git a/src/SelectedPlanets/SelectedPlanets.tsx b/src/SelectedPlanets/SelectedPlanets.tsx new file mode 100644 index 0000000..86e7b25 --- /dev/null +++ b/src/SelectedPlanets/SelectedPlanets.tsx @@ -0,0 +1,57 @@ +import { useAppSelector } from "../hooks/hooks.ts"; +import { stateItems, clearAllItems } from "../store/selectedItemsSlice.ts"; +import { useRef, useState } from "react"; +import { useDispatch } from "react-redux"; +import "./SelectedPlanets.css"; + +export default function SelectedPlanets() { + const selectedItems = useAppSelector(stateItems); + const downloadRef = useRef(null); + const [downloadUrl, setDownloadUrl] = useState(""); + const [filename, setFilename] = useState(""); + + const dispatch = useDispatch(); + + const handleClearAll = () => { + dispatch(clearAllItems()); + }; + + function download() { + const name_column = "id,name,url \r\n"; + const download_data = selectedItems + .map((items, id) => id + 1 + "," + items.name + "," + items.url) + .join("\r\n"); + const csvdata = name_column + download_data; + const csvBlob = new Blob([csvdata], { type: "text/csv" }); + const url = URL.createObjectURL(csvBlob); + setDownloadUrl(url); + setFilename(`${selectedItems.length}_items.csv`); + setTimeout(() => { + if (downloadRef.current) { + downloadRef.current.click(); + URL.revokeObjectURL(url); + setDownloadUrl(""); + } + }, 0); + } + + return ( +
+

Selected Planets: {selectedItems.length}

+ + + +
+ ); +} diff --git a/src/ThemeContext.ts b/src/ThemeContext.ts new file mode 100644 index 0000000..173bade --- /dev/null +++ b/src/ThemeContext.ts @@ -0,0 +1,11 @@ +import { createContext } from "react"; + +type Theme = "light" | "dark"; +type ThemeContextType = { + theme: Theme; + toggleTheme: () => void; +}; + +export const ThemeContext = createContext( + {} as ThemeContextType, +); diff --git a/src/ThemeProvider.tsx b/src/ThemeProvider.tsx new file mode 100644 index 0000000..acc785a --- /dev/null +++ b/src/ThemeProvider.tsx @@ -0,0 +1,25 @@ +import React, { useState, useEffect } from "react"; +import { ThemeContext } from "./ThemeContext"; +type Theme = "light" | "dark"; + +export function ThemeProvider({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement { + const [theme, setTheme] = useState("light"); + const toggleTheme = () => { + setTheme(theme === "light" ? "dark" : "light"); + }; + + useEffect(() => { + document.body.classList.remove("light", "dark"); + document.body.classList.add(theme); + }, [theme]); + + return ( + + {children} + + ); +} diff --git a/src/Wrapper.tsx b/src/Wrapper.tsx new file mode 100644 index 0000000..b81384b --- /dev/null +++ b/src/Wrapper.tsx @@ -0,0 +1,25 @@ +import { Outlet } from "react-router-dom"; +import Header from "./Header/Header"; +import { useContext, useState } from "react"; +import { ThemeContext } from "./ThemeContext"; + +function Wrapper() { + const { theme } = useContext(ThemeContext) || {}; + const [errorBoolean, setErrorBoolean] = useState(false); + + function triggerError() { + setErrorBoolean(true); + } + if (errorBoolean) { + throw new Error("Test error"); + } + + return ( +
+
+ +
+ ); +} + +export default Wrapper; diff --git a/src/__tests__/About.test.tsx b/src/__tests__/About.test.tsx new file mode 100644 index 0000000..ea573c3 --- /dev/null +++ b/src/__tests__/About.test.tsx @@ -0,0 +1,22 @@ +import { screen, render } from "@testing-library/react"; +import { BrowserRouter as Router } from "react-router-dom"; +import { Provider } from "react-redux"; +import { store } from "../store/store"; +import About from "../About"; + +describe("App tests render", () => { + test("should render about", () => { + render( + + + + + , + ); + + expect(screen.getByRole("paragraph")).toHaveTextContent( + "The author of the application is Pavel Kozin", + ); + expect(screen.getByText("RS School")).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx new file mode 100644 index 0000000..7d3c37d --- /dev/null +++ b/src/__tests__/App.test.tsx @@ -0,0 +1,20 @@ +import { screen, render } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { BrowserRouter } from "react-router-dom"; +import { store } from "../store/store"; +import App from "../App"; + +describe("App tests render", () => { + test("should render app", () => { + render( + + + + + , + ); + expect(screen.getByTestId("app")).toBeInTheDocument(); + expect(screen.getByRole("navigation")).toBeInTheDocument(); + expect(screen.getByText("Star Wars Planets")).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/Card.test.tsx b/src/__tests__/Card.test.tsx new file mode 100644 index 0000000..b4699ab --- /dev/null +++ b/src/__tests__/Card.test.tsx @@ -0,0 +1,51 @@ +import { describe, test, expect, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import PlanetCard from "../PlanetCard"; +import { PlanetApi } from "../PlanetFetch"; +import { BrowserRouter as Router } from "react-router-dom"; +import { Provider } from "react-redux"; +import { store } from "../store/store"; +import type { Params } from "react-router-dom"; + +vi.mock("../PlanetFetch", () => ({ + PlanetApi: { + fetchPlanetDetail: vi.fn(), + }, +})); + +vi.mock("react-router-dom", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useParams: (): Readonly> => ({ planetId: "2" }), + }; +}); + +describe("Planet Cards Rendering", () => { + test("should render 1 planet cards", async () => { + const mockPlanetData = { + name: "Alderaan", + diameter: "12500", + climate: "temperate", + population: "2000000000", + rotation_period: "24", + orbital_period: "364", + gravity: "1 standard", + terrain: "grasslands, mountains", + }; + + vi.mocked(PlanetApi.fetchPlanetDetail).mockResolvedValue(mockPlanetData); + render( + + + + + , + ); + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /Alderaan/ }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/CardList_Component_Tests.test.tsx b/src/__tests__/CardList.test.tsx similarity index 83% rename from src/__tests__/CardList_Component_Tests.test.tsx rename to src/__tests__/CardList.test.tsx index c702f61..69fb0a4 100644 --- a/src/__tests__/CardList_Component_Tests.test.tsx +++ b/src/__tests__/CardList.test.tsx @@ -5,6 +5,8 @@ import MainPage from "../MainPage"; import { PlanetApi } from "../PlanetFetch"; import { BrowserRouter as Router } from "react-router-dom"; import type { PlanetsListItem } from "../types"; +import { Provider } from "react-redux"; +import { store } from "../store/store"; vi.mock("../PlanetFetch", () => ({ PlanetApi: { @@ -37,9 +39,11 @@ describe("Planet Cards Rendering", () => { const user = userEvent.setup(); vi.mocked(PlanetApi.fetchPlanets).mockResolvedValue(mockPlanet); render( - - - , + + + + + , ); const inputElement = screen.getByRole("textbox"); @@ -57,9 +61,11 @@ describe("Planet Cards Rendering", () => { test("should render all planet cards", async () => { vi.mocked(PlanetApi.fetchPlanets).mockResolvedValue(mockPlanet); render( - - - , + + + + + , ); expect(await screen.findByRole("article")).toBeInTheDocument(); expect(screen.getByText(mockPlanet.planets[0].name)).toBeInTheDocument(); @@ -69,9 +75,11 @@ describe("Planet Cards Rendering", () => { new Error("Error in request"), ); render( - - - , + + + + + , ); const error = await screen.findByTestId("error-message"); diff --git a/src/__tests__/Error.test.tsx b/src/__tests__/Error.test.tsx index 07a2204..c8e6dbc 100644 --- a/src/__tests__/Error.test.tsx +++ b/src/__tests__/Error.test.tsx @@ -4,17 +4,23 @@ import ErrorBoundary from "../ErrorBoundary"; import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { BrowserRouter as Router } from "react-router-dom"; +import { Provider } from "react-redux"; +import { store } from "../store/store"; +import Wrapper from "../Wrapper"; describe("Error component", () => { it("should throw error", async () => { const ErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); render( - - - - - , + + + + + + + + , ); const errorButton = screen.getByRole("button", { name: /Error/ }); @@ -30,11 +36,14 @@ describe("Error component", () => { it("should click reboot button", async () => { render( - - - - - , + + + + + + + + , ); const errorButton = screen.getByRole("button", { name: /Error/ }); diff --git a/src/__tests__/App_test1.test.tsx b/src/__tests__/Header.test.tsx similarity index 64% rename from src/__tests__/App_test1.test.tsx rename to src/__tests__/Header.test.tsx index 6080f06..6a72493 100644 --- a/src/__tests__/App_test1.test.tsx +++ b/src/__tests__/Header.test.tsx @@ -1,13 +1,19 @@ import { screen, render } from "@testing-library/react"; import MainPage from "../MainPage"; import { BrowserRouter as Router } from "react-router-dom"; +import { Provider } from "react-redux"; +import { store } from "../store/store"; +import Wrapper from "../Wrapper"; describe("App tests render", () => { test("should render the title", () => { render( - - - , + + + + + + , ); expect( diff --git a/src/__tests__/Page404.test.tsx b/src/__tests__/Page404.test.tsx new file mode 100644 index 0000000..1127124 --- /dev/null +++ b/src/__tests__/Page404.test.tsx @@ -0,0 +1,24 @@ +import { screen, render } from "@testing-library/react"; +import { BrowserRouter as Router } from "react-router-dom"; +import { Provider } from "react-redux"; +import { store } from "../store/store"; +import Page404 from "../Page404"; + +describe("App tests render", () => { + test("should render about", () => { + render( + + + + + , + ); + + expect( + screen.getByRole("heading", { + level: 1, + }), + ).toHaveTextContent("404"); + expect(screen.getByRole("link", { name: /Вернуться на главную/ })); + }); +}); diff --git "a/src/__tests__/Search_\320\241omponent_Tests.test.tsx" b/src/__tests__/Search.test.tsx similarity index 77% rename from "src/__tests__/Search_\320\241omponent_Tests.test.tsx" rename to src/__tests__/Search.test.tsx index c069772..e04e9c8 100644 --- "a/src/__tests__/Search_\320\241omponent_Tests.test.tsx" +++ b/src/__tests__/Search.test.tsx @@ -3,6 +3,8 @@ import { render, screen } from "@testing-library/react"; import MainPage from "../MainPage"; import userEvent from "@testing-library/user-event"; import { BrowserRouter as Router } from "react-router-dom"; +import { Provider } from "react-redux"; +import { store } from "../store/store"; describe("Search Component Tests", () => { const localStorageMock = { @@ -16,9 +18,11 @@ describe("Search Component Tests", () => { test("should render Input and Button elements", () => { render( - - - , + + + + + , ); const inputElement = screen.getByRole("textbox"); @@ -38,9 +42,11 @@ describe("Search Component Tests", () => { } }); render( - - - , + + + + + , ); expect(screen.getByRole("textbox")).toHaveValue(testQuery); expect(localStorageMock.getItem).toHaveBeenCalledWith("starWarsQuery"); @@ -51,9 +57,12 @@ describe("Search Component Tests", () => { return null; }); render( - - - , + + + + + , + , ); expect(screen.getByRole("textbox")).toHaveValue(""); expect(localStorageMock.getItem).toHaveBeenCalledWith("starWarsQuery"); @@ -62,9 +71,12 @@ describe("Search Component Tests", () => { test("should update input value when typing", async () => { const user = userEvent.setup(); render( - - - , + + + + + , + , ); const inputElement = screen.getByRole("textbox"); @@ -77,9 +89,12 @@ describe("Search Component Tests", () => { const user = userEvent.setup(); render( - - - , + + + + + , + , ); const inputElement = screen.getByRole("textbox"); @@ -98,9 +113,12 @@ describe("Search Component Tests", () => { const user = userEvent.setup(); render( - - - , + + + + + , + , ); const inputElement = screen.getByRole("textbox"); diff --git a/src/hooks/hooks.ts b/src/hooks/hooks.ts new file mode 100644 index 0000000..5908179 --- /dev/null +++ b/src/hooks/hooks.ts @@ -0,0 +1,4 @@ +import { useSelector } from "react-redux"; +import type { RootState } from "../store/store"; + +export const useAppSelector = useSelector.withTypes(); diff --git a/src/store/selectedItemsSlice.ts b/src/store/selectedItemsSlice.ts new file mode 100644 index 0000000..9c2af53 --- /dev/null +++ b/src/store/selectedItemsSlice.ts @@ -0,0 +1,36 @@ +import { createSlice } from "@reduxjs/toolkit"; +import type { PayloadAction } from "@reduxjs/toolkit"; +import type { PlanetsListItem } from "../types"; +import type { RootState } from "../store/store"; + +interface SelectedItemsState { + items: PlanetsListItem[]; +} + +const initialState: SelectedItemsState = { + items: [], +}; + +export const selectedItemsSlice = createSlice({ + name: "selectedItems", + initialState, + reducers: { + addItem: (state, action: PayloadAction) => { + if (!state.items.find((item) => item.uid === action.payload.uid)) { + state.items.push(action.payload); + } + }, + removeItem: (state, action: PayloadAction) => { + state.items = state.items.filter((item) => item.uid !== action.payload); + }, + clearAllItems: (state) => { + state.items = []; + }, + }, +}); + +export const stateItems = (state: RootState) => state.selectedItems.items; + +export const { addItem, removeItem, clearAllItems } = + selectedItemsSlice.actions; +export default selectedItemsSlice.reducer; diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 0000000..eb02d11 --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,11 @@ +import { configureStore } from "@reduxjs/toolkit"; +import selectedItemsReducer from "./selectedItemsSlice"; + +export const store = configureStore({ + reducer: { + selectedItems: selectedItemsReducer, + }, +}); +export type AppStore = typeof store; +export type RootState = ReturnType; +export type AppDispatch = AppStore["dispatch"];