From e3b71e02f686b070df7698e149255e6dc4a51c35 Mon Sep 17 00:00:00 2001 From: Valentyna Date: Mon, 10 Feb 2025 18:26:50 +0200 Subject: [PATCH] add react movies list add form --- README.md | 2 +- package-lock.json | 67 +++++++++- package.json | 2 + src/App.tsx | 20 ++- src/api/imdbApi.ts | 13 ++ src/components/MoviesList/MoviesList.tsx | 1 + src/components/NewMovie/NewMovie.tsx | 162 +++++++++++++++++++---- src/components/TextField/TextField.tsx | 37 +++--- 8 files changed, 253 insertions(+), 51 deletions(-) create mode 100644 src/api/imdbApi.ts diff --git a/README.md b/README.md index abe3db5d7..ffb6e06ed 100644 --- a/README.md +++ b/README.md @@ -27,4 +27,4 @@ const pattern = /^((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|( - Implement a solution following the [React task guideline](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline). - Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript). - Open one more terminal and run tests with `npm test` to ensure your solution is correct. -- Replace `` with your Github username in the [DEMO LINK](https://.github.io/react_movies-list-add-form/) and add it to the PR description. +- Replace `` with your Github username in the [DEMO LINK](https://ValentynaD.github.io/react_movies-list-add-form/) and add it to the PR description. diff --git a/package-lock.json b/package-lock.json index b589fc234..517a0f751 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", + "axios": "^1.7.9", "bulma": "^0.9.4", "classnames": "^2.5.1", "react": "^18.3.1", @@ -21,6 +22,7 @@ "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", + "@types/axios": "^0.9.36", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -2104,6 +2106,13 @@ "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, + "node_modules/@types/axios": { + "version": "0.9.36", + "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.9.36.tgz", + "integrity": "sha512-NLOpedx9o+rxo/X5ChbdiX6mS1atE4WHmEEIcR9NLenRVa5HoVjAvjafwU3FPTqnZEstpoqCaW7fagqSoTDNeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2825,8 +2834,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/at-least-node": { "version": "1.0.0", @@ -2876,6 +2884,37 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/axobject-query": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", @@ -3331,7 +3370,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3863,7 +3901,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -5195,6 +5232,26 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -7108,7 +7165,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -7117,7 +7173,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, diff --git a/package.json b/package.json index a41e46d14..3ab5c25fe 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", + "axios": "^1.7.9", "bulma": "^0.9.4", "classnames": "^2.5.1", "react": "^18.3.1", @@ -17,6 +18,7 @@ "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", + "@types/axios": "^0.9.36", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/src/App.tsx b/src/App.tsx index 34be670b0..c66d2163b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,30 @@ +import React, { useState } from 'react'; import './App.scss'; import { MoviesList } from './components/MoviesList'; import { NewMovie } from './components/NewMovie'; import moviesFromServer from './api/movies.json'; -export const App = () => { +interface Movie { + title: string; + imgUrl: string; + imdbUrl: string; + imdbId: string; + description: string; +} + +export const App: React.FC = () => { + const [movies, setMovies] = useState(moviesFromServer); + const handleAddMovie = (newMovie: Movie) => { + setMovies(prevMovies => [...prevMovies, newMovie]); + }; + return (
- +
- {}} */ /> +
); diff --git a/src/api/imdbApi.ts b/src/api/imdbApi.ts new file mode 100644 index 000000000..a19e2f4d1 --- /dev/null +++ b/src/api/imdbApi.ts @@ -0,0 +1,13 @@ +import axios from 'axios'; + +const API_KEY = 'YOUR_OMDB_API_KEY'; // Замінити на ваш ключ API + +export async function searchMovie(query: string) { + try { + const response = await axios.get(`http://www.omdbapi.com/?apikey=${API_KEY}&t=${query}`); + return response.data; + } catch (error) { + console.error('Error fetching movie data:', error); + return null; + } +} diff --git a/src/components/MoviesList/MoviesList.tsx b/src/components/MoviesList/MoviesList.tsx index cf78599b8..5113ae597 100644 --- a/src/components/MoviesList/MoviesList.tsx +++ b/src/components/MoviesList/MoviesList.tsx @@ -15,3 +15,4 @@ export const MoviesList: React.FC = ({ movies }) => ( ))} ); +export default MoviesList; diff --git a/src/components/NewMovie/NewMovie.tsx b/src/components/NewMovie/NewMovie.tsx index 85bace9dd..816ce872f 100644 --- a/src/components/NewMovie/NewMovie.tsx +++ b/src/components/NewMovie/NewMovie.tsx @@ -1,42 +1,158 @@ import { useState } from 'react'; import { TextField } from '../TextField'; -export const NewMovie = () => { - // Increase the count after successful form submission - // to reset touched status of all the `Field`s - const [count] = useState(0); +interface Props { + onAdd: (movie: { + title: string; + imgUrl: string; + imdbUrl: string; + imdbId: string; + description: string; + }) => void; +} + +export const NewMovie: React.FC = ({ onAdd }) => { + const [title, setTitle] = useState(''); + const [imgUrl, setImgUrl] = useState(''); + const [imdbUrl, setImdbUrl] = useState(''); + const [imdbId, setImdbId] = useState(''); + const [description, setDescription] = useState(''); + const [errors, setErrors] = useState({ + title: '', + imgUrl: '', + imdbUrl: '', + imdbId: '', + description: '', + }); + const [successMessage, setSuccessMessage] = useState(''); + const clearForm = () => { + setTitle(''); + setImgUrl(''); + setImdbUrl(''); + setImdbId(''); + setDescription(''); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (title && imgUrl && imdbUrl && imdbId) { + if (typeof onAdd === 'function') { + onAdd({ title, imgUrl, imdbUrl, imdbId, description }); + setSuccessMessage('Movie added successfully!'); + setTimeout(() => setSuccessMessage(''), 3000); + clearForm(); + } + } + }; + + const isValidUrl = (url: string) => { + try { + new URL(url); + + return true; + } catch (_) { + return false; + } + }; + + const isValidImdbUrl = (url: string) => + url.startsWith('https://www.imdb.com/title/'); + + const isValidImdbId = (id: string) => /^tt\d+$/.test(id); + + const handleBlur = (field: string, value: string) => { + if (!value.trim()) { + setErrors(prev => ({ ...prev, [field]: `${field} is required` })); + } else { + if (field === 'imgUrl' && !isValidUrl(value)) { + setErrors(prev => ({ ...prev, [field]: 'Please provide a valid URL' })); + } else if (field === 'imdbUrl' && !isValidImdbUrl(value)) { + setErrors(prev => ({ + ...prev, + [field]: 'Please provide a valid IMDb URL', + })); + } else if (field === 'imdbId' && !isValidImdbId(value)) { + setErrors(prev => ({ + ...prev, + [field]: 'Please provide a valid IMDb ID', + })); + } else { + setErrors(prev => ({ ...prev, [field]: '' })); + } + } + }; return ( -
-

Add a movie

+ +

Add a movie

{}} + value={title} + onChange={e => setTitle(e.target.value)} + onBlur={e => handleBlur('title', e.target.value)} + required + data-cy="movie-title" + /> + + setDescription(e.target.value)} + data-cy="movie-description" + /> + + setImgUrl(e.target.value)} + onBlur={e => handleBlur('imgUrl', e.target.value)} required + data-cy="movie-imgUrl" /> - + setImdbUrl(e.target.value)} + onBlur={e => handleBlur('imdbUrl', e.target.value)} + required + data-cy="movie-imdbUrl" + /> - + setImdbId(e.target.value)} + onBlur={e => handleBlur('imdbId', e.target.value)} + required + data-cy="movie-imdbId" + /> - + - + {Object.entries(errors).map( + ([key, value]) => + value && ( +

+ {value} +

+ ), + )} -
-
- -
-
+ {successMessage &&

{successMessage}

} ); }; diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index e24856c4b..943792216 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ import classNames from 'classnames'; import React, { useState } from 'react'; @@ -7,13 +8,11 @@ type Props = { label?: string; placeholder?: string; required?: boolean; - onChange?: (newValue: string) => void; + onChange?: (event: React.ChangeEvent) => void; + onBlur?: (event: React.FocusEvent) => void; + error?: string; }; -function getRandomDigits() { - return Math.random().toFixed(16).slice(2); -} - export const TextField: React.FC = ({ name, value, @@ -21,36 +20,38 @@ export const TextField: React.FC = ({ placeholder = `Enter ${label}`, required = false, onChange = () => {}, + onBlur, + error, }) => { - // generate a unique id once on component load - const [id] = useState(() => `${name}-${getRandomDigits()}`); - - // To show errors only if the field was touched (onBlur) + const [id] = useState(() => `${name}-${Math.random().toFixed(16).slice(2)}`); const [touched, setTouched] = useState(false); - const hasError = touched && required && !value; + const hasError = !!error || (touched && required && !value); return (
-
onChange(event.target.value)} - onBlur={() => setTouched(true)} + onChange={onChange} + onBlur={e => { + setTouched(true); + onBlur && onBlur(e); + }} + required={required} />
- - {hasError &&

{`${label} is required`}

} + {hasError && ( +

{error || `${label} is required`}

+ )}
); }; +/* eslint-enable @typescript-eslint/no-unused-expressions */