From 825b8c0be4139a6c1637b228daed00fbbb127ae4 Mon Sep 17 00:00:00 2001 From: Kriss Kozak Date: Fri, 12 Jan 2024 20:50:28 +0200 Subject: [PATCH 1/4] movies-list-add-form --- src/components/NewMovie/NewMovie.tsx | 101 +++++++++++++++++++++++-- src/components/TextField/TextField.tsx | 38 +++++++--- 2 files changed, 122 insertions(+), 17 deletions(-) diff --git a/src/components/NewMovie/NewMovie.tsx b/src/components/NewMovie/NewMovie.tsx index 34f22fb0a..53f2f639f 100644 --- a/src/components/NewMovie/NewMovie.tsx +++ b/src/components/NewMovie/NewMovie.tsx @@ -2,12 +2,76 @@ 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); + const [count, setCount] = useState(0); + + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [imgUrl, setImgUrl] = useState(''); + const [imdbUrl, setImdbUrl] = useState(''); + const [imdbId, setImdbId] = useState(''); + + const [titleError, setTitleError] = useState(false); + const [imgUrlError, setImgUrlError] = useState(false); + const [imdbUrlError, setImdbUrlError] = useState(false); + const [imdbIdError, setImdbIdError] = useState(false); + + const [isFormSubmitted, setIsFormSubmitted] = useState(false); + + const resetForm = () => { + setTitle(''); + setDescription(''); + setImgUrl(''); + setImdbUrl(''); + setImdbId(''); + + setTitleError(false); + setImgUrlError(false); + setImdbUrlError(false); + setImdbIdError(false); + + setIsFormSubmitted(false); + setCount((prevCount) => prevCount + 1); + }; + + const validateUrl = (url: string): boolean => { + // eslint-disable-next-line max-len + const pattern = /^((([A-Za-z]{3,9}:(?:\/\/)?)?(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www\.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@,.\w_]*)#?(?:[,.!/\\\w]*))?)$/; + + return pattern.test(url); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + setTitleError(!title.trim()); + setImgUrlError(!imgUrl.trim()); + setImdbUrlError(!imdbUrl.trim()); + setImdbIdError(!imdbId.trim()); + + if (title.trim() && imgUrl.trim() && imdbUrl.trim() && imdbId.trim()) { + // Additional validation for URLs + if (!validateUrl(imgUrl)) { + setImgUrlError(true); + setIsFormSubmitted(true); + + return; + } + + if (!validateUrl(imdbUrl)) { + setImdbUrlError(true); + setIsFormSubmitted(true); + + return; + } + + resetForm(); + } + + setIsFormSubmitted(true); + }; return ( -
+

Add a movie

{ value="" onChange={() => {}} required + error={typeof titleError === 'string' ? titleError : undefined} /> setDescription(newValue)} /> setImgUrl(newValue)} + onBlur={() => setImgUrlError(!imgUrl.trim())} + required + showError={isFormSubmitted} + error={ + (imgUrlError && 'Image URL is required') + || (isFormSubmitted && !validateUrl(imgUrl) && 'Invalid Image URL') + || undefined + } /> {}} + onBlur={() => setImdbUrlError(!imdbUrl.trim())} + required + showError={isFormSubmitted} + error={(imdbUrlError && 'IMDb URL is required') + || (isFormSubmitted && !validateUrl(imdbUrl) + && 'Invalid IMDb URL') + || undefined} /> {}} + onBlur={() => setImdbIdError(!imdbId.trim())} + required + showError={isFormSubmitted} + error={imdbIdError ? 'IMDb ID is required' : undefined} />
@@ -48,6 +136,7 @@ export const NewMovie = () => { type="submit" data-cy="submit-button" className="button is-link" + disabled={isFormSubmitted} > Add diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 307b19865..ae31f6c6d 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -2,12 +2,15 @@ import classNames from 'classnames'; import React, { useState } from 'react'; type Props = { - name: string, - value: string, - label?: string, - placeholder?: string, - required?: boolean, - onChange?: (newValue: string) => void, + name: string; + value: string; + label?: string; + placeholder?: string; + required?: boolean; + onChange?: (newValue: string) => void; + onBlur?: () => void; // Add onBlur to the type + showError?: boolean; + error?: string | undefined; }; function getRandomDigits() { @@ -23,8 +26,11 @@ export const TextField: React.FC = ({ placeholder = `Enter ${label}`, required = false, onChange = () => {}, + onBlur = () => {}, // Add onBlur to the props + showError = false, + error, }) => { - // generage a unique id once on component load + // generate a unique id once on component load const [id] = useState(() => `${name}-${getRandomDigits()}`); // To show errors only if the field was touched (onBlur) @@ -43,18 +49,28 @@ export const TextField: React.FC = ({ id={id} data-cy={`movie-${name}`} className={classNames('input', { - 'is-danger': hasError, + 'is-danger': showError && hasError, })} placeholder={placeholder} value={value} - onChange={event => onChange(event.target.value)} - onBlur={() => setTouched(true)} + onChange={(event) => { + setTouched(false); // Reset touched status on change + onChange(event.target.value); + }} + onBlur={() => { + setTouched(true); + onBlur(); // Call onBlur prop + }} />
- {hasError && ( + {showError && hasError && (

{`${label} is required`}

)} + + {showError && error && ( +

{error}

+ )} ); }; From cf2b931a756aeb08186a7f1505e8b904143b0958 Mon Sep 17 00:00:00 2001 From: Kriss Kozak Date: Mon, 15 Jan 2024 17:56:08 +0200 Subject: [PATCH 2/4] movies-list-add-form --- src/App.tsx | 2 +- src/components/NewMovie/NewMovie.scss | 17 +++++++++- src/components/NewMovie/NewMovie.tsx | 46 +++++++++++++++----------- src/components/TextField/TextField.tsx | 11 +++--- 4 files changed, 47 insertions(+), 29 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 34be670b0..c3c2db7e7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,7 @@ export const App = () => {
- {}} */ /> +
); diff --git a/src/components/NewMovie/NewMovie.scss b/src/components/NewMovie/NewMovie.scss index 71bc413aa..da38762ee 100644 --- a/src/components/NewMovie/NewMovie.scss +++ b/src/components/NewMovie/NewMovie.scss @@ -1 +1,16 @@ -// not empty +.new-movie-form { + display: flex; + flex-direction: column; + align-items: flex-end; /* Align items to the right */ +} + +.form-field { + margin-bottom: 10px; /* Add some spacing between fields */ + display: flex; + flex-direction: column; + align-items: flex-end; /* Align items to the right */ +} + +label { + margin-bottom: 5px; +} diff --git a/src/components/NewMovie/NewMovie.tsx b/src/components/NewMovie/NewMovie.tsx index 53f2f639f..6e8d3e686 100644 --- a/src/components/NewMovie/NewMovie.tsx +++ b/src/components/NewMovie/NewMovie.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; import { TextField } from '../TextField'; -export const NewMovie = () => { +export const NewMovie: React.FC = () => { const [count, setCount] = useState(0); const [title, setTitle] = useState(''); @@ -30,7 +30,7 @@ export const NewMovie = () => { setImdbIdError(false); setIsFormSubmitted(false); - setCount((prevCount) => prevCount + 1); + setCount(prevCount => prevCount + 1); }; const validateUrl = (url: string): boolean => { @@ -49,7 +49,6 @@ export const NewMovie = () => { setImdbIdError(!imdbId.trim()); if (title.trim() && imgUrl.trim() && imdbUrl.trim() && imdbId.trim()) { - // Additional validation for URLs if (!validateUrl(imgUrl)) { setImgUrlError(true); setIsFormSubmitted(true); @@ -77,10 +76,11 @@ export const NewMovie = () => { {}} + value={title} + onChange={(newValue) => setTitle(newValue)} required - error={typeof titleError === 'string' ? titleError : undefined} + error={titleError ? 'Title is required' : undefined} + onBlur={() => setIsFormSubmitted(true)} /> { showError={isFormSubmitted} error={ (imgUrlError && 'Image URL is required') - || (isFormSubmitted && !validateUrl(imgUrl) && 'Invalid Image URL') - || undefined + || (isFormSubmitted && !validateUrl(imgUrl) && 'Invalid Image URL') + || undefined } /> {}} + label="IMDb URL" + value={imdbUrl} + onChange={(newValue) => setImdbUrl(newValue)} onBlur={() => setImdbUrlError(!imdbUrl.trim())} required showError={isFormSubmitted} - error={(imdbUrlError && 'IMDb URL is required') - || (isFormSubmitted && !validateUrl(imdbUrl) - && 'Invalid IMDb URL') - || undefined} + error={ + (imdbUrlError && 'IMDb URL is required') + || (isFormSubmitted && !validateUrl(imdbUrl) && 'Invalid IMDb URL') + || undefined + } /> {}} + label="IMDb ID" + value={imdbId} + onChange={(newValue) => setImdbId(newValue)} onBlur={() => setImdbIdError(!imdbId.trim())} required showError={isFormSubmitted} @@ -136,7 +137,12 @@ export const NewMovie = () => { type="submit" data-cy="submit-button" className="button is-link" - disabled={isFormSubmitted} + disabled={ + !title.trim() + || !imgUrl.trim() + || !imdbUrl.trim() + || !imdbId.trim() + } > Add diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index ae31f6c6d..9c2f1ef6a 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -8,7 +8,7 @@ type Props = { placeholder?: string; required?: boolean; onChange?: (newValue: string) => void; - onBlur?: () => void; // Add onBlur to the type + onBlur?: () => void; showError?: boolean; error?: string | undefined; }; @@ -26,14 +26,11 @@ export const TextField: React.FC = ({ placeholder = `Enter ${label}`, required = false, onChange = () => {}, - onBlur = () => {}, // Add onBlur to the props + onBlur = () => {}, showError = false, 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 [touched, setTouched] = useState(false); const hasError = touched && required && !value; @@ -54,12 +51,12 @@ export const TextField: React.FC = ({ placeholder={placeholder} value={value} onChange={(event) => { - setTouched(false); // Reset touched status on change + setTouched(false); onChange(event.target.value); }} onBlur={() => { setTouched(true); - onBlur(); // Call onBlur prop + onBlur(); }} /> From f3c2d77a3e272567588b40f4469f6dd589ecb431 Mon Sep 17 00:00:00 2001 From: Kriss Kozak Date: Mon, 15 Jan 2024 20:40:17 +0200 Subject: [PATCH 3/4] movies-list-add-form --- src/App.tsx | 2 +- src/components/NewMovie/index.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 src/components/NewMovie/index.ts diff --git a/src/App.tsx b/src/App.tsx index c3c2db7e7..bebca072c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import './App.scss'; import { MoviesList } from './components/MoviesList'; -import { NewMovie } from './components/NewMovie'; import moviesFromServer from './api/movies.json'; +import { NewMovie } from './components/NewMovie/NewMovie'; export const App = () => { return ( diff --git a/src/components/NewMovie/index.ts b/src/components/NewMovie/index.ts deleted file mode 100644 index b63e22098..000000000 --- a/src/components/NewMovie/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './NewMovie'; From 443edd8f70de3fb27e83fb9e73d4e41c4de40422 Mon Sep 17 00:00:00 2001 From: Kriss Kozak Date: Mon, 22 Jan 2024 21:46:05 +0200 Subject: [PATCH 4/4] add task solution --- src/App.tsx | 14 +- src/components/MoviesList/MoviesList.tsx | 2 +- src/components/NewMovie/NewMovie.tsx | 160 ++++++++++------------- src/components/TextField/TextField.tsx | 42 +++--- src/components/utsils/UrlValidate.ts | 6 + 5 files changed, 106 insertions(+), 118 deletions(-) create mode 100644 src/components/utsils/UrlValidate.ts diff --git a/src/App.tsx b/src/App.tsx index bebca072c..4107e3e49 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,24 @@ import './App.scss'; +import React, { useState } from 'react'; import { MoviesList } from './components/MoviesList'; +import { Movie } from './types/Movie'; import moviesFromServer from './api/movies.json'; import { NewMovie } from './components/NewMovie/NewMovie'; -export const App = () => { +export const App: React.FC = () => { + const [movies, setMovies] = useState(moviesFromServer); + + const addMovie = (newMovie: Movie) => { + setMovies(currentMovies => [...currentMovies, newMovie]); + }; + return (
- +
- +
); diff --git a/src/components/MoviesList/MoviesList.tsx b/src/components/MoviesList/MoviesList.tsx index e2cf5980e..d75f2c16a 100644 --- a/src/components/MoviesList/MoviesList.tsx +++ b/src/components/MoviesList/MoviesList.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import './MoviesList.scss'; import { MovieCard } from '../MovieCard'; import { Movie } from '../../types/Movie'; +import './MoviesList.scss'; interface Props { movies: Movie[]; diff --git a/src/components/NewMovie/NewMovie.tsx b/src/components/NewMovie/NewMovie.tsx index 6e8d3e686..0b35fc1a8 100644 --- a/src/components/NewMovie/NewMovie.tsx +++ b/src/components/NewMovie/NewMovie.tsx @@ -1,134 +1,120 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { TextField } from '../TextField'; +import { Movie } from '../../types/Movie'; +import { urlValidate } from '../utsils/UrlValidate'; -export const NewMovie: React.FC = () => { - const [count, setCount] = useState(0); - - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [imgUrl, setImgUrl] = useState(''); - const [imdbUrl, setImdbUrl] = useState(''); - const [imdbId, setImdbId] = useState(''); - - const [titleError, setTitleError] = useState(false); - const [imgUrlError, setImgUrlError] = useState(false); - const [imdbUrlError, setImdbUrlError] = useState(false); - const [imdbIdError, setImdbIdError] = useState(false); - - const [isFormSubmitted, setIsFormSubmitted] = useState(false); +interface Props { + onAdd: (movie: Movie) => void +} - const resetForm = () => { - setTitle(''); - setDescription(''); - setImgUrl(''); - setImdbUrl(''); - setImdbId(''); - - setTitleError(false); - setImgUrlError(false); - setImdbUrlError(false); - setImdbIdError(false); +export const NewMovie: React.FC = ({ onAdd }) => { + const [count, setCount] = useState(0); - setIsFormSubmitted(false); - setCount(prevCount => prevCount + 1); + const [newMovie, setNewMovie] = useState({ + title: '', + description: '', + imgUrl: '', + imdbUrl: '', + imdbId: '', + }); + + const { + title, + description, + imgUrl, + imdbUrl, + imdbId, + } = newMovie; + + const isSubmitDisabled = !title.trim() + || !imgUrl.trim() + || !imdbId.trim() + || !imdbUrl.trim(); + + const [hasImgUrlError, setHasImgUrlError] = useState(false); + const [hasImdbUrlError, setHasImdbUrlError] = useState(false); + + const handleInput = (event: React.ChangeEvent): void => { + const { name, value } = event.target; + + setNewMovie(prev => ({ + ...prev, + [name]: value, + })); }; - const validateUrl = (url: string): boolean => { - // eslint-disable-next-line max-len - const pattern = /^((([A-Za-z]{3,9}:(?:\/\/)?)?(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www\.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@,.\w_]*)#?(?:[,.!/\\\w]*))?)$/; - - return pattern.test(url); + const onUrlCheck = () => { + setHasImdbUrlError(urlValidate(imdbUrl)); + setHasImgUrlError(urlValidate(imgUrl)); }; - const handleSubmit = (event: React.FormEvent) => { + const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); - setTitleError(!title.trim()); - setImgUrlError(!imgUrl.trim()); - setImdbUrlError(!imdbUrl.trim()); - setImdbIdError(!imdbId.trim()); - - if (title.trim() && imgUrl.trim() && imdbUrl.trim() && imdbId.trim()) { - if (!validateUrl(imgUrl)) { - setImgUrlError(true); - setIsFormSubmitted(true); - - return; - } - - if (!validateUrl(imdbUrl)) { - setImdbUrlError(true); - setIsFormSubmitted(true); + if (hasImdbUrlError || hasImgUrlError) { + return; + } - return; - } + onAdd(newMovie); - resetForm(); - } + setNewMovie({ + title: '', + description: '', + imgUrl: '', + imdbUrl: '', + imdbId: '', + }); - setIsFormSubmitted(true); + setCount(prevCount => prevCount + 1); }; return ( - +

Add a movie

setTitle(newValue)} + onChange={handleInput} required - error={titleError ? 'Title is required' : undefined} - onBlur={() => setIsFormSubmitted(true)} /> setDescription(newValue)} + onChange={handleInput} /> setImgUrl(newValue)} - onBlur={() => setImgUrlError(!imgUrl.trim())} + onChange={handleInput} required - showError={isFormSubmitted} - error={ - (imgUrlError && 'Image URL is required') - || (isFormSubmitted && !validateUrl(imgUrl) && 'Invalid Image URL') - || undefined - } + hasUrlError={hasImgUrlError} /> setImdbUrl(newValue)} - onBlur={() => setImdbUrlError(!imdbUrl.trim())} + onChange={handleInput} required - showError={isFormSubmitted} - error={ - (imdbUrlError && 'IMDb URL is required') - || (isFormSubmitted && !validateUrl(imdbUrl) && 'Invalid IMDb URL') - || undefined - } + hasUrlError={hasImdbUrlError} /> setImdbId(newValue)} - onBlur={() => setImdbIdError(!imdbId.trim())} + onChange={handleInput} required - showError={isFormSubmitted} - error={imdbIdError ? 'IMDb ID is required' : undefined} />
@@ -137,12 +123,8 @@ export const NewMovie: React.FC = () => { type="submit" data-cy="submit-button" className="button is-link" - disabled={ - !title.trim() - || !imgUrl.trim() - || !imdbUrl.trim() - || !imdbId.trim() - } + disabled={isSubmitDisabled} + onClick={onUrlCheck} > Add diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 9c2f1ef6a..3997972e4 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -1,16 +1,14 @@ -import classNames from 'classnames'; import React, { useState } from 'react'; +import classNames from 'classnames'; type Props = { - name: string; - value: string; - label?: string; - placeholder?: string; - required?: boolean; - onChange?: (newValue: string) => void; - onBlur?: () => void; - showError?: boolean; - error?: string | undefined; + name: string, + value: string, + label?: string, + placeholder?: string, + required?: boolean, + onChange: (newValue: React.ChangeEvent) => void, + hasUrlError?: boolean, }; function getRandomDigits() { @@ -26,11 +24,10 @@ export const TextField: React.FC = ({ placeholder = `Enter ${label}`, required = false, onChange = () => {}, - onBlur = () => {}, - showError = false, - error, + hasUrlError, }) => { const [id] = useState(() => `${name}-${getRandomDigits()}`); + const [touched, setTouched] = useState(false); const hasError = touched && required && !value; @@ -42,31 +39,26 @@ export const TextField: React.FC = ({
{ - setTouched(false); - onChange(event.target.value); - }} - onBlur={() => { - setTouched(true); - onBlur(); - }} + onChange={event => onChange(event)} + onBlur={() => setTouched(true)} />
- {showError && hasError && ( + {hasError && (

{`${label} is required`}

)} - {showError && error && ( -

{error}

+ {hasUrlError && value && ( +

{`${label} is incorrect`}

)}
); diff --git a/src/components/utsils/UrlValidate.ts b/src/components/utsils/UrlValidate.ts new file mode 100644 index 000000000..e853334c5 --- /dev/null +++ b/src/components/utsils/UrlValidate.ts @@ -0,0 +1,6 @@ +// eslint-disable-next-line max-len +const pattern = /^((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www\.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@,.\w_]*)#?(?:[,.!/\\\w]*))?)$/; + +export const urlValidate = (url: string): boolean => { + return !pattern.test(url); +};