diff --git a/README.md b/README.md index c33761fd7..42f9def13 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,6 @@ Install Prettier Extention and use this [VSCode settings](https://mate-academy.g 1. Implement comment deletion - Delete the commnet immediately not waiting for the server response to improve the UX. 1. (*) Handle `Add` and `Delete` errors so the user can retry + data.map((user) => { + id, name, email, phone; + }) diff --git a/package-lock.json b/package-lock.json index 3d91573bb..187b13361 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1183,10 +1183,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", + "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index 4b5c5ff54..9786f26cd 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/src/App.scss b/src/App.scss index 695435da4..7160aea59 100644 --- a/src/App.scss +++ b/src/App.scss @@ -21,3 +21,15 @@ .message-body { white-space: pre-line; } + +.tile.is-ancestor { + display: flex; + flex-wrap: wrap; +} + +.tile.is-parent { + flex-grow: 1; + display: flex; + flex-direction: column; + padding: .75rem; +} diff --git a/src/App.tsx b/src/App.tsx index 017957182..ea55f5c5e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,54 +7,63 @@ import './App.scss'; import { PostsList } from './components/PostsList'; import { PostDetails } from './components/PostDetails'; import { UserSelector } from './components/UserSelector'; -import { Loader } from './components/Loader'; +import { useEffect, useState } from 'react'; +import { User } from './types/User'; +import { Post } from './types/Post'; -export const App = () => ( -
-
-
-
-
-
- -
- -
-

No user selected

+export const App = () => { + const [selectedUser, setSelectedUser] = useState(null); + const [openedPost, setOpenedPost] = useState(null); - + useEffect(() => { + setOpenedPost(null); + }, [selectedUser]); -
- Something went wrong! + return ( +
+
+
+
+
+
+
-
- No posts yet +
+ {selectedUser ? ( + + ) : ( +

No user selected

+ )}
- -
-
-
-
- +
+ {!!openedPost && ( +
+ +
+ )}
-
-
-); +
+ ); +}; diff --git a/src/components/CommentItem.tsx b/src/components/CommentItem.tsx new file mode 100644 index 000000000..c42896da6 --- /dev/null +++ b/src/components/CommentItem.tsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; +import { Comment } from '../types/Comment'; +import { client } from '../utils/fetchClient'; + +interface Props { + comment: Comment; + setComments: ( + updateFn: (prevComments: Comment[] | null) => Comment[] | null, + ) => void; +} + +export const CommentItem: React.FC = ({ + comment: { name, email, body, id }, + setComments, +}) => { + const [isDeleted, setIsDeleted] = useState(false); + const deleteComment = () => { + setIsDeleted(true); + client.delete(`/comments/${id}`); + setComments(prevComments => { + if (!prevComments) { + return prevComments; + } + + return prevComments.filter(comment => comment.id !== id); + }); + }; + + if (isDeleted) { + return; + } + + return ( +
+
+ + {name} + + +
+ +
+ {body} +
+
+ ); +}; diff --git a/src/components/NewCommentForm.tsx b/src/components/NewCommentForm.tsx index 73a8a0b45..75b4586cb 100644 --- a/src/components/NewCommentForm.tsx +++ b/src/components/NewCommentForm.tsx @@ -1,6 +1,130 @@ -import React from 'react'; +/* eslint-disable prettier/prettier */ +import React, { ChangeEventHandler, useState } from 'react'; +import classNames from 'classnames'; +import { client } from '../utils/fetchClient'; +import { Comment } from '../types/Comment'; + +interface Props { + postId: number | undefined; + comments: Comment[] | null; + addNewComment: (comments: Comment[]) => void; + onAddingError: (isError: boolean) => void; +} + +export const NewCommentForm: React.FC = ({ + postId, + comments, + addNewComment, + onAddingError, +}) => { + const [fullname, setFullName] = useState(''); + const [isNameError, setIsNameError] = useState(false); + + const [email, setEmail] = useState(''); + const [isEmailError, setIsEmailError] = useState(false); + + const [comment, setComment] = useState(''); + const [isCommentError, setIsCommentError] = useState(false); + + const [isAdding, setIsAdding] = useState(false); + + const nameHandler: ChangeEventHandler = event => { + setFullName(event.target.value); + + if (isNameError) { + setIsNameError(false); + } + }; + + const emailHandler: ChangeEventHandler = event => { + setEmail(event.target.value); + + if (isEmailError) { + setIsEmailError(false); + } + }; + + const commentHandler: ChangeEventHandler = event => { + setComment(event.target.value); + + if (isCommentError) { + setIsCommentError(false); + } + }; + + const isNotValid = ( + trimmedFullName: string, + trimmedEmail: string, + trimmedComment: string, + ) => { + const emailValidation = + trimmedEmail.includes('@') && email.trim().split('@'); + + if (!trimmedFullName) { + setIsNameError(true); + } + + if ( + !emailValidation || + !emailValidation[0].length || + !emailValidation[1].length || + !emailValidation[1].includes('.com') + ) { + setIsEmailError(true); + } + + if (!trimmedComment) { + setIsCommentError(true); + } + + return ( + isNameError || + isEmailError || + isCommentError || + !trimmedFullName || + !trimmedEmail || + !trimmedComment + ); + }; + + const submitHandler: React.MouseEventHandler = event => { + event.preventDefault(); + const trimmedFullName = fullname.trim(); + const trimmedEmail = email.trim(); + const trimmedComment = comment.trim(); + + if (isNotValid(trimmedFullName, trimmedEmail, trimmedComment)) { + return; + } + + setIsAdding(true); + client + .post('/comments', { + postId, + body: comment, + name: fullname, + email: email, + }) + .then(newComment => { + setComment(''); + addNewComment([...(comments || []), newComment]); + }) + .catch(() => { + onAddingError(true); + }) + .finally(() => setIsAdding(false)); + }; + + const reset = () => { + setFullName(''); + setEmail(''); + setComment(''); + + setIsNameError(false); + setIsEmailError(false); + setIsCommentError(false); + }; -export const NewCommentForm: React.FC = () => { return (
@@ -14,24 +138,29 @@ export const NewCommentForm: React.FC = () => { name="name" id="comment-author-name" placeholder="Name Surname" - className="input is-danger" + value={fullname} + onChange={nameHandler} + className={classNames(`input`, { 'is-danger': isNameError })} /> - - - + {isNameError && ( + + + + )}
- -

- Name is required -

+ {isNameError && ( +

+ Name is required +

+ )}
@@ -45,24 +174,30 @@ export const NewCommentForm: React.FC = () => { name="email" id="comment-author-email" placeholder="email@test.com" - className="input is-danger" + value={email} + onChange={emailHandler} + className={classNames(`input`, { 'is-danger': isEmailError })} /> - - - + {isEmailError && ( + + + + )}
-

- Email is required -

+ {isEmailError && ( +

+ Email is required +

+ )}
@@ -75,25 +210,39 @@ export const NewCommentForm: React.FC = () => { id="comment-body" name="body" placeholder="Type comment here" - className="textarea is-danger" + value={comment} + onChange={commentHandler} + className={classNames(`textarea`, { 'is-danger': isCommentError })} />
-

- Enter some text -

+ {isCommentError && ( +

+ Enter some text +

+ )}
-
{/* eslint-disable-next-line react/button-has-type */} -
diff --git a/src/components/PostDetails.tsx b/src/components/PostDetails.tsx index 2f82db916..b7cd2ca64 100644 --- a/src/components/PostDetails.tsx +++ b/src/components/PostDetails.tsx @@ -1,106 +1,103 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { Post } from '../types/Post'; import { Loader } from './Loader'; +import { Comment } from '../types/Comment'; +import { client } from '../utils/fetchClient'; +import { CommentItem } from './CommentItem'; import { NewCommentForm } from './NewCommentForm'; -export const PostDetails: React.FC = () => { +interface Props { + post: Post | null; +} + +export const PostDetails: React.FC = ({ post }) => { + const [comments, setComments] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + const [isErrorAdding, setIsErrorAdding] = useState(false); + const [isFormOpened, setIsFormOpened] = useState(false); + + useEffect(() => { + if (post === null) { + return; + } + }, []); + + useEffect(() => { + setIsErrorAdding(false); + }, [post?.id]); + + useEffect(() => { + setIsLoading(true); + setIsError(false); + setIsFormOpened(false); + + client + .get(`/comments?postId=${post?.id}`) + .then(data => setComments(data)) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(false)); + }, [post?.id]); + return (

- #18: voluptate et itaque vero tempora molestiae + #{post?.id}: {post?.title}

-

- eveniet quo quis laborum totam consequatur non dolor ut et est - repudiandae est voluptatem vel debitis et magnam -

+

{post?.body}

- - -
- Something went wrong -
- -

- No comments yet -

- -

Comments:

- -
-
- - Misha Hrynko - - + {isErrorAdding ? ( +
+ Unable to add a comment
- -
- Some comment -
-
- -
-
- - Misha Hrynko - - - -
-
- One more comment + ) : isLoading ? ( + + ) : isError ? ( +
+ Something went wrong
-
+ ) : !!comments?.length ? ( + <> +

Comments:

-
-
- - Misha Hrynko - + {comments?.map(comment => ( + + ))} + + ) : ( +

+ No comments yet +

+ )} - -
- -
- {'Multi\nline\ncomment'} -
-
- - + {!isLoading && !isFormOpened && !isError && ( + + )} + {isFormOpened && !isErrorAdding && ( + + )}
- -
); diff --git a/src/components/PostItem.tsx b/src/components/PostItem.tsx new file mode 100644 index 000000000..825704b7f --- /dev/null +++ b/src/components/PostItem.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Post } from '../types/Post'; +import classNames from 'classnames'; + +// import { PostDetails } from './PostDetails'; + +interface Props { + post: Post; + openedPost?: Post | null; + setOpenedPost: (post: Post | null) => void; +} + +export const PostItem: React.FC = ({ + post: { id, title, body, userId }, + openedPost, + setOpenedPost, +}) => { + return ( + + {id} + + {title} + + + + + + ); +}; diff --git a/src/components/PostsList.tsx b/src/components/PostsList.tsx index cf90f04b0..6d5fa3156 100644 --- a/src/components/PostsList.tsx +++ b/src/components/PostsList.tsx @@ -1,86 +1,70 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { PostItem } from './PostItem'; +import { client } from '../utils/fetchClient'; +import { Post } from '../types/Post'; +import { Loader } from './Loader'; -export const PostsList: React.FC = () => ( -
-

Posts:

+interface Props { + userID: number; + openedPost?: Post | null; + setOpenedPost: (post: Post | null) => void; +} - - - - - - {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} - - - +export const PostsList: React.FC = ({ + userID, + openedPost, + setOpenedPost, +}) => { + const [posts, setPosts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); - - - + useEffect(() => { + setIsLoading(true); + client + .get(`/posts?userId=${userID}`) + .then(data => { + setPosts(data); + }) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(false)); + }, [userID]); - + return isLoading ? ( + + ) : isError ? ( +
+ Something went wrong! +
+ ) : !!posts.length ? ( +
+

Posts:

-
- +
#Title
17 - fugit voluptas sed molestias voluptatem provident - - -
+ + + + + {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} + + + - - - - - - - - - - - - - - - - - - - - - - -
#Title
18 - voluptate et itaque vero tempora molestiae - - -
19adipisci placeat illum aut reiciendis qui - -
20doloribus ad provident suscipit at - -
-
-); + + {posts.map(post => ( + + ))} + + +
+ ) : ( +
+ No posts yet +
+ ); +}; diff --git a/src/components/UserSelector.tsx b/src/components/UserSelector.tsx index c89442841..c3f62ffef 100644 --- a/src/components/UserSelector.tsx +++ b/src/components/UserSelector.tsx @@ -1,17 +1,52 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { User } from '../types/User'; +import { client } from '../utils/fetchClient'; +import classNames from 'classnames'; + +interface Props { + selectUser: (user: User | null) => void; + selectedUser: User | null; +} + +export const UserSelector: React.FC = ({ selectUser, selectedUser }) => { + const [users, setUsers] = useState([]); + const [isActive, setIsActive] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + client.get('/users').then(data => setUsers(data)); + }, []); + + const handleUserSelect = (user: User) => { + selectUser(user); + setIsActive(false); + }; + + const handleBlur = (event: React.FocusEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.relatedTarget) + ) { + setIsActive(false); + } + }; -export const UserSelector: React.FC = () => { return ( -
+