diff --git a/package-lock.json b/package-lock.json index 030f70b..d2531b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "rss-react", "version": "0.1.0", "dependencies": { + "@reduxjs/toolkit": "^1.9.3", "@testing-library/jest-dom": "^5.16.5", "@testing-library/user-event": "^13.5.0", "@types/jest": "^27.5.2", @@ -18,8 +19,10 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.43.9", + "react-redux": "^8.0.5", "react-router-dom": "^6.9.0", "react-scripts": "5.0.1", + "redux": "^4.2.1", "sass": "^1.59.3", "typescript": "^4.9.5", "web-vitals": "^2.1.4" @@ -3166,6 +3169,29 @@ } } }, + "node_modules/@reduxjs/toolkit": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.3.tgz", + "integrity": "sha512-GU2TNBQVofL09VGmuSioNPQIu6Ml0YLf4EJhgj0AvBadRlCGzUWet8372LjvO4fqKZF2vH1xU0htAa7BrK9pZg==", + "dependencies": { + "immer": "^9.0.16", + "redux": "^4.2.0", + "redux-thunk": "^2.4.2", + "reselect": "^4.1.7" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18", + "react-redux": "^7.2.1 || ^8.0.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.4.0.tgz", @@ -3848,6 +3874,15 @@ "@types/node": "*" } }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -4046,6 +4081,11 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" + }, "node_modules/@types/ws": { "version": "8.5.4", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", @@ -8857,6 +8897,19 @@ "integrity": "sha512-tWCK4biJ6hcLqTviLXVR9DTRfYGQMXEIUj3gwJ2rZ5wO/at3XtkI4g8mCvFdUF9l1KMBNCfmNAdnahm1cgavQA==", "dev": true }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/hoopy": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", @@ -15410,6 +15463,49 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "node_modules/react-redux": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.0.5.tgz", + "integrity": "sha512-Q2f6fCKxPFpkXt1qNRZdEDLlScsDWyrgSj0mliK59qU6W5gvBiKkdMEG2lJzhd1rCctf0hb6EtePPLZ2e0m1uw==", + "dependencies": { + "@babel/runtime": "^7.12.1", + "@types/hoist-non-react-statics": "^3.3.1", + "@types/use-sync-external-store": "^0.0.3", + "hoist-non-react-statics": "^3.3.2", + "react-is": "^18.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "@types/react": "^16.8 || ^17.0 || ^18.0", + "@types/react-dom": "^16.8 || ^17.0 || ^18.0", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0", + "react-native": ">=0.59", + "redux": "^4" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -15575,6 +15671,22 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/redux-thunk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", + "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", + "peerDependencies": { + "redux": "^4" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -15701,6 +15813,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "node_modules/reselect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.7.tgz", + "integrity": "sha512-Zu1xbUt3/OPwsXL46hvOOoQrap2azE7ZQbokq61BQfiXvhewsKDwhMeZjTX9sX0nvw1t/U5Audyn1I9P/m9z0A==" + }, "node_modules/resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", @@ -17437,6 +17554,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -20591,6 +20716,17 @@ "source-map": "^0.7.3" } }, + "@reduxjs/toolkit": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.3.tgz", + "integrity": "sha512-GU2TNBQVofL09VGmuSioNPQIu6Ml0YLf4EJhgj0AvBadRlCGzUWet8372LjvO4fqKZF2vH1xU0htAa7BrK9pZg==", + "requires": { + "immer": "^9.0.16", + "redux": "^4.2.0", + "redux-thunk": "^2.4.2", + "reselect": "^4.1.7" + } + }, "@remix-run/router": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.4.0.tgz", @@ -21083,6 +21219,15 @@ "@types/node": "*" } }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -21281,6 +21426,11 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==" }, + "@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" + }, "@types/ws": { "version": "8.5.4", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", @@ -24761,6 +24911,21 @@ "integrity": "sha512-tWCK4biJ6hcLqTviLXVR9DTRfYGQMXEIUj3gwJ2rZ5wO/at3XtkI4g8mCvFdUF9l1KMBNCfmNAdnahm1cgavQA==", "dev": true }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + }, + "dependencies": { + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + } + } + }, "hoopy": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", @@ -29287,6 +29452,26 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "react-redux": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.0.5.tgz", + "integrity": "sha512-Q2f6fCKxPFpkXt1qNRZdEDLlScsDWyrgSj0mliK59qU6W5gvBiKkdMEG2lJzhd1rCctf0hb6EtePPLZ2e0m1uw==", + "requires": { + "@babel/runtime": "^7.12.1", + "@types/hoist-non-react-statics": "^3.3.1", + "@types/use-sync-external-store": "^0.0.3", + "hoist-non-react-statics": "^3.3.2", + "react-is": "^18.0.0", + "use-sync-external-store": "^1.0.0" + }, + "dependencies": { + "react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + } + } + }, "react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -29407,6 +29592,20 @@ "strip-indent": "^3.0.0" } }, + "redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, + "redux-thunk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", + "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", + "requires": {} + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -29508,6 +29707,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "reselect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.7.tgz", + "integrity": "sha512-Zu1xbUt3/OPwsXL46hvOOoQrap2azE7ZQbokq61BQfiXvhewsKDwhMeZjTX9sX0nvw1t/U5Audyn1I9P/m9z0A==" + }, "resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", @@ -30770,6 +30974,12 @@ "requires-port": "^1.0.0" } }, + "use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "requires": {} + }, "util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", diff --git a/package.json b/package.json index 93e9782..ca82528 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@reduxjs/toolkit": "^1.9.3", "@testing-library/jest-dom": "^5.16.5", "@testing-library/user-event": "^13.5.0", "@types/jest": "^27.5.2", @@ -14,8 +15,10 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.43.9", + "react-redux": "^8.0.5", "react-router-dom": "^6.9.0", "react-scripts": "5.0.1", + "redux": "^4.2.1", "sass": "^1.59.3", "typescript": "^4.9.5", "web-vitals": "^2.1.4" diff --git a/src/components/app/App.test.tsx b/src/components/app/App.test.tsx index 8ce39e0..1c0961a 100644 --- a/src/components/app/App.test.tsx +++ b/src/components/app/App.test.tsx @@ -3,13 +3,24 @@ import { render, screen } from '@testing-library/react'; import App from './App'; import { MemoryRouter } from 'react-router-dom'; +import * as reduxHooks from 'react-redux'; +jest.mock('react-redux'); +const mockedUseSelector = jest.spyOn(reduxHooks, 'useSelector'); +const mockedUseDispatch = jest.spyOn(reduxHooks, 'useDispatch'); +import { initialState } from '../../store/moviesSlice'; + describe('test App component', () => { test('it renders', () => { + mockedUseSelector.mockReturnValue(initialState); + const dispatch = jest.fn(); + mockedUseDispatch.mockReturnValue(dispatch); + render( ); + expect(screen.getByRole('app')).toBeInTheDocument(); screen.debug(); }); diff --git a/src/components/app/App.tsx b/src/components/app/App.tsx index 15f6782..fad80e4 100644 --- a/src/components/app/App.tsx +++ b/src/components/app/App.tsx @@ -12,7 +12,7 @@ import Layout from '../layout'; export default class App extends Component<{}, {}> { render() { return ( -
+
}> }> diff --git a/src/components/card/card.test.tsx b/src/components/card/card.test.tsx index 719ac41..98da2b5 100644 --- a/src/components/card/card.test.tsx +++ b/src/components/card/card.test.tsx @@ -1,19 +1,40 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import Card from './card'; -import { dataMovie } from '../../data/dataMovie'; +import { dataMovie } from '../../testData/dataMovie'; -describe('test Card component', () => { - const mockFunction = jest.fn(); +import * as reduxHooks from 'react-redux'; +import * as actions from './../../store/moviesSlice'; +jest.mock('react-redux'); +const mockedUseDispatch = jest.spyOn(reduxHooks, 'useDispatch'); +describe('test Card component', () => { test('it renders', () => { - render(); + render(); expect(screen.getByRole('card-item')).toBeInTheDocument(); }); test('it renders title of film "Fight Club", release-year "1999"', () => { - render(); + render(); expect(screen.getByText(/Fight Club/i)).toBeInTheDocument(); expect(screen.getByText(/1999/i)).toBeInTheDocument(); }); + + test('dispatch actions called ', () => { + const dispatch = jest.fn(); + mockedUseDispatch.mockReturnValue(dispatch); + const mockedToggleModal = jest.spyOn(actions, 'toggleModal'); + const mockedUpdateMovieId = jest.spyOn(actions, 'updateMovieId'); + + render(); + + const button = screen.getByRole('showMoreInfo-button'); + fireEvent.click(button); + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(mockedToggleModal).toHaveBeenCalledTimes(1); + + expect(mockedUpdateMovieId).toHaveBeenCalledTimes(1); + expect(mockedUpdateMovieId).toHaveBeenCalledWith(550); //get id=550 from dataMovie + }); }); diff --git a/src/components/card/card.tsx b/src/components/card/card.tsx index 73a517f..fba44ab 100644 --- a/src/components/card/card.tsx +++ b/src/components/card/card.tsx @@ -4,20 +4,23 @@ import './card.scss'; import { IMovie } from '../types'; import { _baseImagePath } from '../../services/movies-services'; +import { useAppDispatch } from '../../hook'; +import { toggleModal, updateMovieId } from './../../store/moviesSlice'; + type CardProps = { item: IMovie; - setIsModalOpen: (newValue: boolean) => void; - showDetailInfo: (id: number) => void; }; -const Card = ({ item, setIsModalOpen, showDetailInfo }: CardProps) => { +const Card = ({ item }: CardProps) => { const { id, title, original_title, poster_path, vote_average, release_date } = item; const baseImagePath = _baseImagePath; const yearRelease: string = release_date.slice(0, 4); + const dispatch = useAppDispatch(); + const showMoreInfo = () => { - setIsModalOpen(true); - showDetailInfo(id); + dispatch(toggleModal()); + dispatch(updateMovieId(id)); }; return ( diff --git a/src/components/cardForm-list/cardForm-list.test.tsx b/src/components/cardForm-list/cardForm-list.test.tsx deleted file mode 100644 index 9108865..0000000 --- a/src/components/cardForm-list/cardForm-list.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import { testCardsForm } from '../../testData/testCardsForm'; -import CardFormList from './cardForm-list'; - -describe('test CardFormList component', () => { - test('render CardFormList', () => { - render(); - expect(screen.getByTestId('card-form-list')).toBeInTheDocument(); - }); - - test('should be render all CardsForm in CardFormList ', () => { - render(); - expect(screen.getAllByRole('card-form').length).toBe(testCardsForm.length); - }); -}); diff --git a/src/components/cards-list/cards-list.test.tsx b/src/components/cards-list/cards-list.test.tsx index 9e85e30..850939e 100644 --- a/src/components/cards-list/cards-list.test.tsx +++ b/src/components/cards-list/cards-list.test.tsx @@ -2,22 +2,23 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import CardsList from './cards-list'; -import { dataMovie } from '../../data/dataMovie'; +import { dataMovie } from '../../testData/dataMovie'; import { IMovie } from '../types'; +jest.mock('react-redux'); + describe('test CardsList component', () => { - const itemsMoke: IMovie[] = [dataMovie, dataMovie]; - const mockFunction = jest.fn(); + const itemsMoke: IMovie[] = [dataMovie]; test('it renders', async () => { - render(); + render(); const cardsList = await waitFor(() => screen.getByTestId('cards-list')); expect(cardsList).toBeInTheDocument(); }); test('should be render all Card in CardsList ', () => { - render(); + render(); expect(screen.getAllByRole('card-item').length).toBe(itemsMoke.length); }); }); diff --git a/src/components/cards-list/cards-list.tsx b/src/components/cards-list/cards-list.tsx index fd989e9..7d30010 100644 --- a/src/components/cards-list/cards-list.tsx +++ b/src/components/cards-list/cards-list.tsx @@ -7,17 +7,15 @@ import { IMovie } from '../types'; type CardListProps = { items: IMovie[]; - setIsModalOpen: (newValue: boolean) => void; - showDetailInfo: (id: number) => void; }; -const CardList = ({ items, setIsModalOpen, showDetailInfo }: CardListProps) => { +const CardList = ({ items }: CardListProps) => { const cards = items.map((item) => { const { id } = item; return (
  • - +
  • ); }); diff --git a/src/components/errorIndicator/errorIndicator.test.tsx b/src/components/errorIndicator/errorIndicator.test.tsx new file mode 100644 index 0000000..06ea82b --- /dev/null +++ b/src/components/errorIndicator/errorIndicator.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from '@testing-library/react'; + +import ErrorIndicator from './errorIndicator'; + +describe('test ErrorIndicator', () => { + test('should render', () => { + render(); + expect(screen.getByTestId('error-indicator')).toBeInTheDocument(); + }); +}); diff --git a/src/components/cardForm-list/cardForm-list.scss b/src/components/form/cardForm-list/cardForm-list.scss similarity index 100% rename from src/components/cardForm-list/cardForm-list.scss rename to src/components/form/cardForm-list/cardForm-list.scss diff --git a/src/components/form/cardForm-list/cardForm-list.test.tsx b/src/components/form/cardForm-list/cardForm-list.test.tsx new file mode 100644 index 0000000..1efbf9e --- /dev/null +++ b/src/components/form/cardForm-list/cardForm-list.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react'; +import * as reduxHooks from 'react-redux'; + +jest.mock('react-redux'); +const mockedUseSelector = jest.spyOn(reduxHooks, 'useSelector'); + +import { testCardsForm } from '../../../testData/testCardsForm'; +import CardFormList from './cardForm-list'; + +describe('test CardFormList component', () => { + test('should create CardFormList with empty list', () => { + mockedUseSelector.mockReturnValue([]); + render(); + expect(screen.getByTestId('card-form-list')).toBeInTheDocument(); + }); + + test('should create CardFormList with data testCardsForm', () => { + mockedUseSelector.mockReturnValue(testCardsForm); + render(); + expect(screen.getAllByRole('card-form').length).toBe(testCardsForm.length); + }); +}); diff --git a/src/components/cardForm-list/cardForm-list.tsx b/src/components/form/cardForm-list/cardForm-list.tsx similarity index 62% rename from src/components/cardForm-list/cardForm-list.tsx rename to src/components/form/cardForm-list/cardForm-list.tsx index 99d21ee..f81ea27 100644 --- a/src/components/cardForm-list/cardForm-list.tsx +++ b/src/components/form/cardForm-list/cardForm-list.tsx @@ -1,16 +1,14 @@ import React, { FC } from 'react'; -import { ICardForm } from '../cardForm/cardForm'; import CardForm from '../cardForm'; +import { useAppSelector } from '../../../hook'; import './cardForm-list.scss'; -type CardFormListProps = { - cardsForm: ICardForm[]; -}; +const CardFormList: FC = () => { + const cardsForm = useAppSelector((state) => state.formReducer.cardsForm); -const CardFormList: FC = (props: CardFormListProps) => { - const cards = props.cardsForm.map((card) => { + const cards = cardsForm.map((card) => { const { id } = card; return ( diff --git a/src/components/cardForm-list/index.tsx b/src/components/form/cardForm-list/index.tsx similarity index 100% rename from src/components/cardForm-list/index.tsx rename to src/components/form/cardForm-list/index.tsx diff --git a/src/components/cardForm/cardForm.scss b/src/components/form/cardForm/cardForm.scss similarity index 96% rename from src/components/cardForm/cardForm.scss rename to src/components/form/cardForm/cardForm.scss index 6838cc7..be242be 100644 --- a/src/components/cardForm/cardForm.scss +++ b/src/components/form/cardForm/cardForm.scss @@ -1,4 +1,4 @@ -@import '../../scss/constants.scss'; +@import '../../../scss/constants.scss'; .card { display: flex; diff --git a/src/components/cardForm/cardForm.test.tsx b/src/components/form/cardForm/cardForm.test.tsx similarity index 93% rename from src/components/cardForm/cardForm.test.tsx rename to src/components/form/cardForm/cardForm.test.tsx index e1cda13..f404268 100644 --- a/src/components/cardForm/cardForm.test.tsx +++ b/src/components/form/cardForm/cardForm.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; -import CardForm from '../../components/cardForm/cardForm'; -import { testCardForm } from '../../testData/testCardForm'; +import CardForm from './cardForm'; +import { testCardForm } from '../../../testData/testCardForm'; describe('test CardForm component', () => { test('CardForm renders', () => { diff --git a/src/components/cardForm/cardForm.tsx b/src/components/form/cardForm/cardForm.tsx similarity index 98% rename from src/components/cardForm/cardForm.tsx rename to src/components/form/cardForm/cardForm.tsx index 4fca094..90ede5d 100644 --- a/src/components/cardForm/cardForm.tsx +++ b/src/components/form/cardForm/cardForm.tsx @@ -3,7 +3,7 @@ import React, { FC } from 'react'; import './cardForm.scss'; export interface ICardForm { - id?: number; + id?: string; userName: string; gender: string; birthday: string; diff --git a/src/components/cardForm/index.tsx b/src/components/form/cardForm/index.tsx similarity index 100% rename from src/components/cardForm/index.tsx rename to src/components/form/cardForm/index.tsx diff --git a/src/components/form/form.test.tsx b/src/components/form/form.test.tsx index cd4e2fd..b575db0 100644 --- a/src/components/form/form.test.tsx +++ b/src/components/form/form.test.tsx @@ -2,127 +2,63 @@ import { render, screen, fireEvent } from '@testing-library/react'; import Form from './form'; +jest.mock('react-redux'); + describe('test Form component', () => { test('render Form', () => { - render( -
    { - throw new Error(); - }} - /> - ); + render(); expect(screen.getByRole('form')).toBeInTheDocument(); }); test('should contain input type="text" field "userName"', () => { - render( - { - throw new Error(); - }} - /> - ); + render(); expect(screen.getByRole('user-name')).toBeInTheDocument(); }); test('value of input type="text" field "userName" changes by user', () => { - render( - { - throw new Error(); - }} - /> - ); + render(); const input = screen.getByRole('user-name') as HTMLInputElement; fireEvent.change(input, { target: { value: 'Anna' } }); expect(input.value).toBe('Anna'); }); test('should contain input type="date" field "date-birthday"', () => { - render( - { - throw new Error(); - }} - /> - ); + render(); expect(screen.getByRole('date-birthday')).toBeInTheDocument(); }); test('should contain 1 select name="country"', () => { - render( - { - throw new Error(); - }} - /> - ); + render(); expect(screen.getByRole('select-country')).toBeInTheDocument(); }); test('should contain select name="country" with 6 option field', () => { - render( - { - throw new Error(); - }} - /> - ); + render(); expect(screen.getAllByRole('option').length).toBe(6); }); test('should contain 2 input type="radio" field name="gender"', () => { - render( - { - throw new Error(); - }} - /> - ); + render(); expect(screen.getAllByRole('radio').length).toBe(2); }); test('should contain 1 input type="file" field name="profile"', () => { - render( - { - throw new Error(); - }} - /> - ); + render(); expect(screen.getByRole('profile')).toBeInTheDocument(); }); test('should contain 1 textarea name="feedback"', () => { - render( - { - throw new Error(); - }} - /> - ); + render(); expect(screen.getByRole('textarea')).toBeInTheDocument(); }); test('should contain 1 input type="checkbox" name="agree-consent-data"', () => { - render( - { - throw new Error(); - }} - /> - ); + render(); expect(screen.getByRole('checkbox')).toBeInTheDocument(); }); test('should contain 2 buttons: Submit and Reset', () => { - render( - { - throw new Error(); - }} - /> - ); + render(); expect(screen.getAllByRole('button').length).toBe(2); }); }); diff --git a/src/components/form/form.tsx b/src/components/form/form.tsx index 3a5757c..2277887 100644 --- a/src/components/form/form.tsx +++ b/src/components/form/form.tsx @@ -2,12 +2,10 @@ import React, { FC, useState } from 'react'; import { useForm, SubmitHandler } from 'react-hook-form'; import './form.scss'; -import { ICardForm } from '../cardForm/cardForm'; +import { ICardForm } from './cardForm/cardForm'; import { errorTextMessage } from './form-utils'; - -type FormProps = { - addCardForm: (card: ICardForm) => void; -}; +import { useAppDispatch } from '../../hook'; +import { addCardForm } from '../../store/formSlice'; type FormValues = { userName: string; @@ -19,7 +17,7 @@ type FormValues = { gender: string; }; -const Form: FC = (props: FormProps) => { +const Form: FC = () => { const { register, handleSubmit, @@ -27,6 +25,8 @@ const Form: FC = (props: FormProps) => { reset, } = useForm({ mode: 'onSubmit' }); + const dispatch = useAppDispatch(); + const [success, setSuccess] = useState(''); const showSuccessMessage = () => { @@ -40,9 +40,9 @@ const Form: FC = (props: FormProps) => { const onSubmit: SubmitHandler = (data) => { const { image, ...dataLast } = data; const imageSrc: string = URL.createObjectURL(image[0]); - const newCardForm: ICardForm = { imageSrc, ...dataLast }; - - props.addCardForm(newCardForm); + const id = new Date().toISOString(); + const newCardForm: ICardForm = { imageSrc, ...dataLast, id }; + dispatch(addCardForm(newCardForm)); showSuccessMessage(); reset(); diff --git a/src/components/detailInfo/detailInfo.scss b/src/components/modal/detailInfo/detailInfo.scss similarity index 86% rename from src/components/detailInfo/detailInfo.scss rename to src/components/modal/detailInfo/detailInfo.scss index 583894d..800baa4 100644 --- a/src/components/detailInfo/detailInfo.scss +++ b/src/components/modal/detailInfo/detailInfo.scss @@ -1,4 +1,4 @@ -@import '../../scss/constants.scss'; +@import '../../../scss/constants.scss'; .detail-info { &__title { diff --git a/src/components/detailInfo/detailInfo.tsx b/src/components/modal/detailInfo/detailInfo.tsx similarity index 74% rename from src/components/detailInfo/detailInfo.tsx rename to src/components/modal/detailInfo/detailInfo.tsx index 4704c37..fcd13b2 100644 --- a/src/components/detailInfo/detailInfo.tsx +++ b/src/components/modal/detailInfo/detailInfo.tsx @@ -1,14 +1,16 @@ import React from 'react'; import './detailInfo.scss'; -import { IMovie } from '../types'; +import { IMovie } from '../../types'; type DetailInfoProps = { info: IMovie | null; }; -const DetailInfo = ({ info }: DetailInfoProps) => { - const { overview, title, homepage } = info!; +const DetailInfo: React.FC = ({ info }) => { + if (!info) return <>; + + const { overview, title, homepage } = info; return (
    diff --git a/src/components/detailInfo/index.tsx b/src/components/modal/detailInfo/index.tsx similarity index 100% rename from src/components/detailInfo/index.tsx rename to src/components/modal/detailInfo/index.tsx diff --git a/src/components/modal/modal.test.tsx b/src/components/modal/modal.test.tsx new file mode 100644 index 0000000..e6feb97 --- /dev/null +++ b/src/components/modal/modal.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import Modal from './modal'; + +import * as reduxHooks from 'react-redux'; +import * as actions from './../../store/moviesSlice'; +jest.mock('react-redux'); +const mockedUseDispatch = jest.spyOn(reduxHooks, 'useDispatch'); + +describe('test Modal', () => { + it('render modal', () => { + render( + +

    test

    +
    + ); + const modal = screen.getByTestId('modal'); + expect(modal).toBeInTheDocument(); + }); + + it('render Modal with "children" content', () => { + const children =

    test

    ; + render({children}); + const content = screen.getByText('test'); + expect(content).toBeInTheDocument(); + }); + + it('render close-modal button in Modal', () => { + render( + +

    test

    +
    + ); + const closeButton = screen.getByTestId('close-modal'); + expect(closeButton).toBeInTheDocument(); + }); + + it('onClick by "close-modal" dispatch "toggleModal"', () => { + const dispatch = jest.fn(); + mockedUseDispatch.mockReturnValue(dispatch); + const mockedToggleModal = jest.spyOn(actions, 'toggleModal'); + + render( + +

    test

    +
    + ); + + const closeButton = screen.getByTestId('close-modal'); + fireEvent.click(closeButton); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(mockedToggleModal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/modal/modal.tsx b/src/components/modal/modal.tsx index ede58b2..0f76578 100644 --- a/src/components/modal/modal.tsx +++ b/src/components/modal/modal.tsx @@ -1,16 +1,18 @@ import React, { MouseEvent, ReactElement } from 'react'; +import { useAppDispatch } from '../../hook'; +import { toggleModal } from './../../store/moviesSlice'; import './modal.scss'; type ModalProps = { - isModalOpen: boolean; - setIsModalOpen: (newValue: boolean) => void; children: ReactElement; }; -const Modal = ({ setIsModalOpen, children }: ModalProps) => { +const Modal = ({ children }: ModalProps) => { + const dispatch = useAppDispatch(); + const closeModal = () => { - setIsModalOpen(false); + dispatch(toggleModal()); }; const unCloseModal = (e: MouseEvent): void => { @@ -19,8 +21,8 @@ const Modal = ({ setIsModalOpen, children }: ModalProps) => { return (
    -
    -
    +
    +
    X
    {children} diff --git a/src/components/search-panel/search-panel.test.tsx b/src/components/search-panel/search-panel.test.tsx index a9ce170..c65ada2 100644 --- a/src/components/search-panel/search-panel.test.tsx +++ b/src/components/search-panel/search-panel.test.tsx @@ -1,17 +1,28 @@ import { fireEvent, render, screen } from '@testing-library/react'; import SearchPanel from './search-panel'; +import * as reduxHooks from 'react-redux'; + +jest.mock('react-redux'); +const mockedUseSelector = jest.spyOn(reduxHooks, 'useSelector'); + describe('test SearchPanel component', () => { - const onChange = jest.fn(); test('should contains input', () => { - render(); + render(); expect(screen.getByRole('search-input')).toBeInTheDocument(); }); test('value of input changes by user', () => { - render(); + render(); const input = screen.getByRole('search-input') as HTMLInputElement; fireEvent.change(input, { target: { value: 'some text for test' } }); expect(input.value).toBe('some text for test'); }); + + test('should contains input value from store', () => { + mockedUseSelector.mockReturnValue('hello'); + render(); + const input = screen.getByRole('search-input') as HTMLInputElement; + expect(input.value).toBe('hello'); + }); }); diff --git a/src/components/search-panel/search-panel.tsx b/src/components/search-panel/search-panel.tsx index b23ac70..2946c35 100644 --- a/src/components/search-panel/search-panel.tsx +++ b/src/components/search-panel/search-panel.tsx @@ -1,32 +1,22 @@ -import React, { ChangeEvent, KeyboardEvent, useState, useEffect, useRef } from 'react'; +import React, { ChangeEvent, KeyboardEvent, useRef } from 'react'; +import { useAppSelector, useAppDispatch } from './../../hook'; +import { updateSearchValue } from '../../store/searchSlice'; import './search-panel.scss'; -type SearchPanelProps = { - updateSearchValue: (searchValue: string) => void; -}; - -const SearchPanel = ({ updateSearchValue }: SearchPanelProps) => { - const initSearchValue: string = localStorage.getItem('searchValue') || ''; - const [searchValue, setSearchValue] = useState(initSearchValue); +const SearchPanel: React.FC = () => { + const searchValue = useAppSelector((state) => state.searchReducer.searchValue); + const dispatch = useAppDispatch(); const searchRef = useRef(searchValue); - useEffect(() => { - //like componentWillUnmount - return function saveToLS() { - localStorage.setItem('searchValue', searchRef.current); - }; - }, []); - const onSearchChange = (e: ChangeEvent): void => { const newSearchValue = e.target.value.trimStart(); - setSearchValue(newSearchValue); searchRef.current = newSearchValue; }; const handleKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Enter') { - updateSearchValue(searchValue); + dispatch(updateSearchValue(searchRef.current)); } }; @@ -37,7 +27,7 @@ const SearchPanel = ({ updateSearchValue }: SearchPanelProps) => { type='text' className='search-panel search-input btn--border' placeholder={searchText} - value={searchValue} + defaultValue={searchValue} onChange={(e) => onSearchChange(e)} onKeyDown={handleKeyDown} role='search-input' diff --git a/src/hook.ts b/src/hook.ts new file mode 100644 index 0000000..8c4ab48 --- /dev/null +++ b/src/hook.ts @@ -0,0 +1,5 @@ +import { useSelector, useDispatch, TypedUseSelectorHook } from 'react-redux'; +import type { RootState, AppDispatch } from './store'; + +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/index.tsx b/src/index.tsx index 6832360..267eed8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,8 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import store from './store'; import './index.scss'; import App from './components/app'; @@ -8,8 +10,10 @@ import App from './components/app'; const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( - - - + + + + + ); diff --git a/src/mocks/handlers.tsx b/src/mocks/handlers.tsx index 5d589a7..f710af3 100644 --- a/src/mocks/handlers.tsx +++ b/src/mocks/handlers.tsx @@ -1,5 +1,5 @@ import { rest } from 'msw'; -import { dataMovie } from '../data/dataMovie'; +import { dataMovie } from '../testData/dataMovie'; export const handlers = [ rest.post('/login', (req, res, ctx) => { diff --git a/src/pages/formPage/formPage.test.tsx b/src/pages/formPage/formPage.test.tsx index be79cac..e88558a 100644 --- a/src/pages/formPage/formPage.test.tsx +++ b/src/pages/formPage/formPage.test.tsx @@ -2,20 +2,35 @@ import { render, screen } from '@testing-library/react'; import FormPage from './formPage'; +import * as reduxHooks from 'react-redux'; +import { testCardsForm } from '../../testData/testCardsForm'; +jest.mock('react-redux'); +const mockedUseSelector = jest.spyOn(reduxHooks, 'useSelector'); + describe('test FormPage component', () => { - test('renders FormPage', () => { + test('renders FormPage with empty state for CardFormList', () => { + mockedUseSelector.mockReturnValue([]); render(); expect(screen.getByRole('form-page')).toBeInTheDocument(); }); test('renders Form on FormPage', () => { + mockedUseSelector.mockReturnValue([]); render(); expect(screen.getByText(/Add feedback/i)).toBeInTheDocument(); expect(screen.getByRole('form')).toBeInTheDocument(); }); - test('render CardFormList on FormPage', () => { + test('render CardFormList on FormPage with empty state for CardFormList', () => { + mockedUseSelector.mockReturnValue([]); + render(); + expect(screen.getByTestId('card-form-list')).toBeInTheDocument(); + }); + + test('render CardFormList on FormPage with data testCardsForm', () => { + mockedUseSelector.mockReturnValue(testCardsForm); render(); expect(screen.getByTestId('card-form-list')).toBeInTheDocument(); + expect(screen.getAllByRole('card-form').length).toBe(testCardsForm.length); }); }); diff --git a/src/pages/formPage/formPage.tsx b/src/pages/formPage/formPage.tsx index 6139756..e3023be 100644 --- a/src/pages/formPage/formPage.tsx +++ b/src/pages/formPage/formPage.tsx @@ -1,46 +1,16 @@ -import React, { FC, useState } from 'react'; +import React, { FC } from 'react'; import './formPage.scss'; import Form from '../../components/form'; -import { ICardForm } from '../../components/cardForm/cardForm'; -import CardFormList from '../../components/cardForm-list'; +import CardFormList from '../../components/form/cardForm-list'; const FormPage: FC = () => { - let cardId = 1; - const [cardsForm, setCardsForm] = useState([]); - - const addCardForm = (card: ICardForm): void => { - const newCard = createCard(card); - - setCardsForm((prevCardsForm) => { - const newArr = [...prevCardsForm]; - newArr.push(newCard); - - return newArr; - }); - }; - - const createCard = (cardData: ICardForm) => { - const { userName, gender, birthday, country, isConsentPersonalData, feedbackText, imageSrc } = cardData; - - return { - userName: userName, - gender: gender, - birthday: birthday, - country: country, - isConsentPersonalData, - feedbackText, - imageSrc, - id: cardId++, - }; - }; - return (

    Form page

    - - + +
    ); }; diff --git a/src/pages/homePage/home-page.test.tsx b/src/pages/homePage/home-page.test.tsx index dc91e79..bdf4fa6 100644 --- a/src/pages/homePage/home-page.test.tsx +++ b/src/pages/homePage/home-page.test.tsx @@ -1,45 +1,50 @@ import React from 'react'; import { screen, render } from '@testing-library/react'; -import { server } from './../../mocks/server'; -import { rest } from 'msw'; - import HomePage from './homePage'; -import DetailInfo from '../../components/detailInfo'; -import { dataMovie } from '../../data/dataMovie'; +import DetailInfo from '../../components/modal/detailInfo'; +import { dataMovie } from '../../testData/dataMovie'; + +import * as reduxHooks from 'react-redux'; +jest.mock('react-redux'); +const mockedUseSelector = jest.spyOn(reduxHooks, 'useSelector'); +const mockedUseDispatch = jest.spyOn(reduxHooks, 'useDispatch'); + +import { initialState } from '../../store/moviesSlice'; describe('test HomePage component', () => { - test('it renders', () => { + test('it renders with empty list movies', () => { + mockedUseSelector.mockReturnValue(initialState); + const dispatch = jest.fn(); + mockedUseDispatch.mockReturnValue(dispatch); + render(); expect(screen.getByRole('home-page')).toBeInTheDocument(); }); test('show spinner component', async () => { + const dispatch = jest.fn(); + mockedUseDispatch.mockReturnValue(dispatch); + mockedUseSelector.mockReturnValue(initialState); + const { findByTestId } = render(); expect(await findByTestId('spinner')).toBeInTheDocument(); }); - test('render cards from mocks API ', async () => { - render(); - const expectedLength = 3; //length arrMovies in mock API in mocks/server.tsx - const movies = await screen.findAllByRole('card-item'); - expect(movies).toHaveLength(expectedLength); - }); - - test('render error ', async () => { - server.use( - rest.get('https://api.themoviedb.org/3/trending/movie/week', (req, res, ctx) => { - return res(ctx.status(500)); - }) - ); - render(); - const error = await screen.findByTestId('error-indicator'); - expect(error).toBeInTheDocument(); - }); - test('render detail-info on Home Page ', async () => { + mockedUseSelector.mockReturnValue(dataMovie); + render(); const item = screen.getByTestId('detail-info'); expect(item).toBeInTheDocument(); }); + + test('dispatch actions called ', async () => { + mockedUseSelector.mockReturnValue(initialState); + const dispatch = jest.fn(); + mockedUseDispatch.mockReturnValue(dispatch); + + render(); + expect(dispatch).toHaveBeenCalled(); + }); }); diff --git a/src/pages/homePage/homePage.tsx b/src/pages/homePage/homePage.tsx index 0f79631..a481182 100644 --- a/src/pages/homePage/homePage.tsx +++ b/src/pages/homePage/homePage.tsx @@ -1,90 +1,56 @@ -import React, { FC, useEffect, useState } from 'react'; +import React, { FC, useEffect } from 'react'; import './homePage.scss'; -import { IMovie } from '../../components/types'; import CardList from '../../components/cards-list'; import SearchPanel from '../../components/search-panel'; -import { getMovieById, getMoviesBySearch, getTrendingMovies } from '../../services/movies-services'; import Spinner from '../../components/spinner'; import ErrorIndicator from '../../components/errorIndicator'; import Modal from '../../components/modal'; -import DetailInfo from '../../components/detailInfo'; +import DetailInfo from '../../components/modal/detailInfo'; -const HomePage: FC = () => { - const initSearchValue: string = localStorage.getItem('searchValue') || ''; - const [searchValue, setSearchValue] = useState(initSearchValue); - const [trendingMovies, setTrendingMovies] = useState([]); - const [movies, setMovies] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - const [isModalOpen, setIsModalOpen] = useState(false); - const [detailInfo, setDetailInfo] = useState(null); - const [movieId, setMovieId] = useState(null); +import { useAppDispatch, useAppSelector } from '../../hook'; +import { fetchTrendingMovies, fetchMoviesBySearch, fetchMovieById } from './../../store/moviesSlice'; - const onError = () => { - setError(true); - setLoading(false); - }; +const HomePage: FC = () => { + const dispatch = useAppDispatch(); + const searchValue = useAppSelector((state) => state.searchReducer.searchValue); + const { movies, trendingMovies, movieId, movieById, isModalOpen, loading, error } = useAppSelector( + (state) => state.moviesReducer + ); useEffect(() => { - getTrendingMovies('week') - .then((trendingMovies) => { - setTrendingMovies(trendingMovies); - setLoading(false); - }) - .catch(onError); - }, []); + dispatch(fetchTrendingMovies('week')); + }, [dispatch]); useEffect(() => { if (searchValue !== '') { - getMoviesBySearch(searchValue) - .then((movies) => { - setMovies(movies); - setLoading(false); - }) - .catch(onError); + dispatch(fetchMoviesBySearch(searchValue)); } - }, [searchValue]); - - const updateSearchValue = (newValue: string) => { - setSearchValue(newValue); - }; - - function showDetailInfo(id: number) { - setLoading(true); - setMovieId(id); - } + }, [dispatch, searchValue]); useEffect(() => { - if (isModalOpen && movieId) { - getMovieById(movieId) - .then((movie) => { - setDetailInfo(movie); - setLoading(false); - }) - .catch(onError); + if (movieId) { + dispatch(fetchMovieById(movieId)); } - }, [movieId, isModalOpen]); + }, [dispatch, movieId]); const hasData = !(loading || error); const errorMessage = error ? : null; const spinner = loading ? : null; const searchedMovies = searchValue !== '' ? movies : trendingMovies; - const content = hasData ? ( - - ) : null; + const content = hasData ? : null; return (

    HomePage

    - + {spinner} {errorMessage} {content} {isModalOpen && hasData && ( - - + + )}
    diff --git a/src/services/api.test.tsx b/src/services/api.test.tsx index 968910f..7bdaaf8 100644 --- a/src/services/api.test.tsx +++ b/src/services/api.test.tsx @@ -1,4 +1,4 @@ -import { getMovieById, getMoviesBySearch, getTrendingMovies } from './movies-services'; +import { getMovieById, getMoviesBySearch, getTrendingMovies, getResource } from './movies-services'; describe('test API component with mws', () => { it('receives all requested data from Api "https://api.themoviedb.org/3/trending/movie/week" ', async () => { @@ -23,4 +23,10 @@ describe('test API component with mws', () => { const data = await getMoviesBySearch('avatar'); expect(data).toHaveLength(expectedLength); }); + + it('should', async () => { + const testURL = '/movie/550'; + const data = await getResource(testURL); + expect(data).toBeTruthy(); + }); }); diff --git a/src/services/movies-services.tsx b/src/services/movies-services.tsx index 4681415..155d458 100644 --- a/src/services/movies-services.tsx +++ b/src/services/movies-services.tsx @@ -5,7 +5,7 @@ const _apiKey = '75b017a3a227731c05610048a94948e5'; export const _baseImagePath = 'image.tmdb.org/t/p/w300'; const _lang = 'en-US'; -const getResource = async (url: string) => { +export const getResource = async (url: string) => { const res = await fetch(`${_apiBase}${url}?api_key=${_apiKey}`); //handle all answers, except 200 ok diff --git a/src/store/formSlice.ts b/src/store/formSlice.ts new file mode 100644 index 0000000..d1f9a15 --- /dev/null +++ b/src/store/formSlice.ts @@ -0,0 +1,24 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { ICardForm } from '../components/form/cardForm/cardForm'; + +type formState = { + cardsForm: ICardForm[]; +}; + +const initialState: formState = { + cardsForm: [], +}; + +const formSlice = createSlice({ + name: 'form', + initialState, + reducers: { + addCardForm(state, action: PayloadAction) { + state.cardsForm = [...state.cardsForm, action.payload]; + }, + }, +}); + +export const { addCardForm } = formSlice.actions; + +export default formSlice.reducer; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..9eacfab --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,17 @@ +import { configureStore } from '@reduxjs/toolkit'; +import searchReducer from './searchSlice'; +import moviesReducer from './moviesSlice'; +import formReducer from './formSlice'; + +const store = configureStore({ + reducer: { + searchReducer: searchReducer, + moviesReducer: moviesReducer, + formReducer: formReducer, + }, +}); + +export default store; + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/src/store/moviesSlice.ts b/src/store/moviesSlice.ts new file mode 100644 index 0000000..31c3d71 --- /dev/null +++ b/src/store/moviesSlice.ts @@ -0,0 +1,121 @@ +import { createSlice, PayloadAction, createAsyncThunk, AnyAction } from '@reduxjs/toolkit'; +import { IMovie } from '../components/types'; +import { getMovieById, getMoviesBySearch, getTrendingMovies } from '../services/movies-services'; + +type timeByTrendingMovies = 'week' | 'day'; + +export const fetchTrendingMovies = createAsyncThunk( + 'movies/fetchTrendingMovies', + async function (time, { rejectWithValue }) { + try { + const trendingMovies = await getTrendingMovies(time); + return trendingMovies; + } catch (err) { + console.log('error in fetchTrendingMovies: ', (err as Error).message); + return rejectWithValue('fetchTrendingMovies do not work!'); + } + } +); + +export const fetchMoviesBySearch = createAsyncThunk( + 'movies/fetchMoviesBySearch', + async function (searchValue, { rejectWithValue }) { + try { + const movies = await getMoviesBySearch(searchValue); + return movies; + } catch (err) { + console.log('error in fetchMoviesBySearch: ', (err as Error).message); + return rejectWithValue('fetchMoviesBySearch do not work!'); + } + } +); + +export const fetchMovieById = createAsyncThunk( + 'movies/fetchMovieById', + async function (movie_id, { rejectWithValue }) { + try { + const movieById = await getMovieById(movie_id); + return movieById; + } catch (err) { + console.log('error in fetchMovieById: ', (err as Error).message); + return rejectWithValue('fetchMovieById do not work!'); + } + } +); + +export type moviesState = { + movies: IMovie[]; + trendingMovies: IMovie[]; + movieId: number | null; + movieById: IMovie | null; + isModalOpen: boolean; + loading: boolean; + error: string | null; +}; + +export const initialState: moviesState = { + movies: [], + trendingMovies: [], + movieId: null, + movieById: null, + isModalOpen: false, + loading: true, + error: null, +}; + +const moviesSlice = createSlice({ + name: 'movies', + initialState, + reducers: { + updateMovieId(state, action: PayloadAction) { + state.movieId = action.payload; + }, + toggleModal(state) { + state.isModalOpen = !state.isModalOpen; + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchTrendingMovies.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchTrendingMovies.fulfilled, (state, action) => { + state.trendingMovies = action.payload; + state.loading = false; + state.error = null; + }) + .addCase(fetchMoviesBySearch.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchMoviesBySearch.fulfilled, (state, action) => { + state.movies = action.payload; + state.loading = false; + state.error = null; + }) + .addCase(fetchMovieById.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchMovieById.fulfilled, (state, action) => { + state.movieById = action.payload; + state.loading = false; + state.error = null; + }) + .addMatcher(isError, (state, action: PayloadAction) => { + state.error = action.payload; + state.loading = false; + state.isModalOpen = false; + console.log(state.error); + }); + }, +}); + +function isError(action: AnyAction) { + return action.type.endsWith('rejected'); +} + +export const { updateMovieId, toggleModal } = moviesSlice.actions; + +export default moviesSlice.reducer; diff --git a/src/store/searchSlice.ts b/src/store/searchSlice.ts new file mode 100644 index 0000000..d478b8f --- /dev/null +++ b/src/store/searchSlice.ts @@ -0,0 +1,23 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +type searchState = { + searchValue: string; +}; + +const initialState: searchState = { + searchValue: '', +}; + +const searchSlice = createSlice({ + name: 'search', + initialState, + reducers: { + updateSearchValue(state, action: PayloadAction) { + state.searchValue = action.payload; + }, + }, +}); + +export const { updateSearchValue } = searchSlice.actions; + +export default searchSlice.reducer; diff --git a/src/store/tests/extraReducers.test.tsx b/src/store/tests/extraReducers.test.tsx new file mode 100644 index 0000000..8ef555e --- /dev/null +++ b/src/store/tests/extraReducers.test.tsx @@ -0,0 +1,60 @@ +import moviesReducer, { initialState, fetchTrendingMovies, fetchMoviesBySearch, fetchMovieById } from '../moviesSlice'; +import { dataMovie } from '../../testData/dataMovie'; + +describe('test extraReducers', () => { + const mockData = [dataMovie]; + + it('should change status with "fetchTrendingMovies.pending" action', () => { + const state = moviesReducer(initialState, fetchTrendingMovies.pending('', 'week')); + expect(state.loading).toBe(true); + expect(state.error).toBeNull(); + }); + + it('should fetch trendingMovies with "fetchTrendingMovies.fulfilled" action', () => { + const action = { type: fetchTrendingMovies.fulfilled.type, payload: mockData }; + const state = moviesReducer(initialState, action); + expect(state.loading).toBe(false); + expect(state.error).toBeNull(); + expect(state.trendingMovies).toBe(mockData); + }); + + it('should change status with "fetchMoviesBySearch.pending" action', () => { + const action = { type: fetchMoviesBySearch.pending.type }; + const state = moviesReducer(initialState, action); + expect(state.loading).toBe(true); + expect(state.error).toBeNull(); + }); + + it('should fetch movies with "fetchMoviesBySearch.fulfilled" action', () => { + const action = { type: fetchMoviesBySearch.fulfilled.type, payload: mockData }; + const state = moviesReducer(initialState, action); + expect(state.loading).toBe(false); + expect(state.error).toBeNull(); + expect(state.movies).toBe(mockData); + }); + + it('should return status with "fetchMovieById.pending" action', () => { + const action = { type: fetchMovieById.pending.type }; + const state = moviesReducer(initialState, action); + expect(state.loading).toBe(true); + expect(state.error).toBeNull(); + expect(state.movieById).toBeNull(); + }); + + it('should fetch movieById with "fetchMovieById.fulfilled" action', () => { + const action = { type: fetchMovieById.fulfilled.type, payload: dataMovie }; + const state = moviesReducer(initialState, action); + expect(state.loading).toBe(false); + expect(state.error).toBeNull(); + expect(state.movieById).toBe(dataMovie); + expect(state.movieById?.title).toBe('Fight Club'); + }); + + it('should update state.error with "fetchMovieById.rejected" action', () => { + const action = { type: fetchMovieById.rejected.type, payload: 'Server Error' }; + const state = moviesReducer(initialState, action); + expect(state.loading).toBe(false); + expect(state.movieById).toBeNull(); + expect(state.error).toBe('Server Error'); + }); +}); diff --git a/src/store/tests/formSlice.test.tsx b/src/store/tests/formSlice.test.tsx new file mode 100644 index 0000000..ffe06dd --- /dev/null +++ b/src/store/tests/formSlice.test.tsx @@ -0,0 +1,21 @@ +import { testCardForm } from '../../testData/testCardForm'; +import formReducer, { addCardForm } from '../formSlice'; + +const initialState = { + cardsForm: [], +}; + +describe('test formSlice', () => { + it('should return default state when passed an empty action', () => { + const result = formReducer(undefined, { type: '' }); + expect(result).toEqual(initialState); + }); + + it('should add new cardForm with "addCardForm" action', () => { + const action = { type: addCardForm.type, payload: testCardForm }; + + const result = formReducer(initialState, action); + expect(result.cardsForm.length).toBe(1); + expect(result.cardsForm[0].userName).toBe('Anna Sh'); + }); +}); diff --git a/src/store/tests/moviesSlice.test.tsx b/src/store/tests/moviesSlice.test.tsx new file mode 100644 index 0000000..c707238 --- /dev/null +++ b/src/store/tests/moviesSlice.test.tsx @@ -0,0 +1,22 @@ +import moviesReducer, { updateMovieId, initialState, toggleModal } from '../moviesSlice'; + +describe('test moviesSlice', () => { + it('should return default state when passed an empty action', () => { + const result = moviesReducer(undefined, { type: '' }); + expect(result).toEqual(initialState); + }); + + it('should add movieId with "updateMovieId" action', () => { + const action = { type: updateMovieId.type, payload: 355 }; + + const result = moviesReducer(initialState, action); + expect(result.movieId).toBe(355); + }); + + it('should toggle status isModalOpen with "toggleModal" action', () => { + const action = { type: toggleModal.type }; + + const result = moviesReducer(initialState, action); + expect(result.isModalOpen).toBe(true); + }); +}); diff --git a/src/store/tests/searchSlice.test.tsx b/src/store/tests/searchSlice.test.tsx new file mode 100644 index 0000000..caf257b --- /dev/null +++ b/src/store/tests/searchSlice.test.tsx @@ -0,0 +1,19 @@ +import searchReducer, { updateSearchValue } from '../searchSlice'; + +const initialState = { + searchValue: '', +}; + +describe('test searchSlice', () => { + it('should return default state when passed an empty action', () => { + const result = searchReducer(undefined, { type: '' }); + expect(result).toEqual(initialState); + }); + + it('should add new search value with "updateSearchValue" action', () => { + const action = { type: updateSearchValue.type, payload: 'test value' }; + + const result = searchReducer(initialState, action); + expect(result.searchValue).toBe('test value'); + }); +}); diff --git a/src/data/dataMovie.ts b/src/testData/dataMovie.ts similarity index 100% rename from src/data/dataMovie.ts rename to src/testData/dataMovie.ts diff --git a/src/data/dataProduct.ts b/src/testData/dataProduct.ts similarity index 100% rename from src/data/dataProduct.ts rename to src/testData/dataProduct.ts diff --git a/src/data/dataProducts.ts b/src/testData/dataProducts.ts similarity index 100% rename from src/data/dataProducts.ts rename to src/testData/dataProducts.ts diff --git a/src/testData/testCardForm.ts b/src/testData/testCardForm.ts index 36f1695..abe4081 100644 --- a/src/testData/testCardForm.ts +++ b/src/testData/testCardForm.ts @@ -1,7 +1,7 @@ -import { ICardForm } from '../components/cardForm/cardForm'; +import { ICardForm } from '../components/form/cardForm/cardForm'; export const testCardForm: ICardForm = { - id: 100, + id: '100', userName: 'Anna Sh', gender: 'female', birthday: '27.10.1983', diff --git a/src/testData/testCardsForm.ts b/src/testData/testCardsForm.ts index 9be3cf5..711723c 100644 --- a/src/testData/testCardsForm.ts +++ b/src/testData/testCardsForm.ts @@ -1,8 +1,8 @@ -import { ICardForm } from '../components/cardForm/cardForm'; +import { ICardForm } from '../components/form/cardForm/cardForm'; export const testCardsForm: ICardForm[] = [ { - id: 101, + id: '101', userName: 'Ivan Bobrov', gender: 'male', birthday: '21.02.1980', @@ -12,7 +12,7 @@ export const testCardsForm: ICardForm[] = [ imageSrc: 'https://i.dummyjson.com/data/products/25/thumbnail.jpg', }, { - id: 102, + id: '102', userName: 'ALen Koch', gender: 'female', birthday: '21.02.1990', @@ -22,7 +22,7 @@ export const testCardsForm: ICardForm[] = [ imageSrc: 'https://i.dummyjson.com/data/products/25/thumbnail.jpg', }, { - id: 103, + id: '103', userName: 'Anna Sh', gender: 'female', birthday: '21.02.2222',