From 4c1970be14b2f9bd1dd98c465bd971af81d61b0e Mon Sep 17 00:00:00 2001 From: Kirill Gasan Date: Tue, 15 Oct 2024 17:38:26 +0300 Subject: [PATCH] add task solution --- README.md | 2 + src/App.tsx | 207 ++++++++++++++++++++++++------ src/api/post.ts | 22 ++++ src/components/NewCommentForm.tsx | 169 +++++++++++++++++++----- src/components/PostDetails.tsx | 193 +++++++++++++++------------- src/components/PostsList.tsx | 144 ++++++++++----------- src/components/UserSelector.tsx | 101 +++++++++++---- src/types/Comment.ts | 2 +- 8 files changed, 577 insertions(+), 263 deletions(-) create mode 100644 src/api/post.ts diff --git a/README.md b/README.md index c33761fd7..881e56696 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,5 @@ 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 + +[DEMO LINK](https://Kirill1908.github.io/react_dynamic-list-of-posts/) diff --git a/src/App.tsx b/src/App.tsx index 017957182..da4c16b54 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,60 +1,187 @@ -import classNames from 'classnames'; +/* eslint-disable @typescript-eslint/indent */ +import classNames from 'classnames'; import 'bulma/css/bulma.css'; import '@fortawesome/fontawesome-free/css/all.css'; import './App.scss'; +import { useEffect, useState } from 'react'; -import { PostsList } from './components/PostsList'; -import { PostDetails } from './components/PostDetails'; import { UserSelector } from './components/UserSelector'; +import { PostDetails } from './components/PostDetails'; +import { PostsList } from './components/PostsList'; import { Loader } from './components/Loader'; +import { Comment, CommentData } from './types/Comment'; +import { User } from './types/User'; +import { Post } from './types/Post'; +import { + addNewComment, + deleteComment, + getUserPostComments, + getUserPosts, + getUsers, +} from './api/post'; -export const App = () => ( -
-
-
-
-
-
- -
+export const App = () => { + const [users, setUsers] = useState([]); + const [userSelected, setUserSelected] = useState(null); + const [showPostLoader, setShowPostLoader] = useState(false); + const [showCommentLoader, setShowCommentLoader] = useState(false); + const [isAddingComment, setIsAddingComment] = useState(false); + const [userPosts, setUserPosts] = useState([]); + const [selectedPost, setSelectedPost] = useState(null); + const [isSidebarVisible, setIsSidebarVisible] = useState(null); + const [postComments, setPostComments] = useState([]); + const [hasPostError, setHasPostError] = useState(false); + const [hasCommentError, setHasCommentError] = useState(false); + const [isCommentFormVisible, setIsCommentFormVisible] = useState(false); -
-

No user selected

+ useEffect(() => { + getUsers() + .then(res => { + setUsers(res as User[]); + }) + .catch(() => new Error('Failed to fetch users')); + }, []); - + const fetchUserPosts = (user: User) => { + setShowPostLoader(true); + getUserPosts(user.id) + .then(res => { + setUserPosts(res as Post[]); + setHasPostError(false); + }) + .catch(() => { + setHasPostError(true); + }) + .finally(() => { + setShowPostLoader(false); + }); + }; -
- Something went wrong! -
+ const fetchPostComments = (currentPost: Post) => { + setIsSidebarVisible(currentPost.id); + setShowCommentLoader(true); + + setSelectedPost(currentPost); -
- No posts yet + getUserPostComments(currentPost.id) + .then(res => { + setPostComments(res as Comment[]); + setHasCommentError(false); + }) + .catch(() => { + setHasCommentError(true); + }) + .finally(() => { + setShowCommentLoader(false); + }); + }; + + const removeComment = (commentId: number) => { + deleteComment(commentId) + .then(() => { + setPostComments(prevPostComments => + prevPostComments.filter( + (comment: Comment) => comment.id !== commentId, + ), + ); + }) + .catch(() => new Error('Failed to delete comment')); + }; + + const createComment = (newComment: CommentData) => { + setIsAddingComment(true); + addNewComment(newComment) + .then(res => { + setPostComments([...postComments, res as Comment]); + setIsAddingComment(false); + }) + .catch(() => new Error('Failed to add comment')); + }; + + return ( +
+
+
+
+
+
+
- +
+ {!userSelected && ( +

No user selected

+ )} + + {showPostLoader && } + + {hasPostError && ( +
+ Something went wrong! +
+ )} + + {userPosts.length > 0 && !showPostLoader && ( + + )} + {userSelected && + userPosts.length === 0 && + !showPostLoader && + !hasPostError && ( +
+ No posts yet +
+ )} +
-
-
-
- +
+
+ +
-
-
-); +
+ ); +}; diff --git a/src/api/post.ts b/src/api/post.ts new file mode 100644 index 000000000..e7dc0bcd9 --- /dev/null +++ b/src/api/post.ts @@ -0,0 +1,22 @@ +import { CommentData } from '../types/Comment'; +import { client } from '../utils/fetchClient'; + +export const getUsers = () => { + return client.get('/users'); +}; + +export const getUserPosts = (userId: number) => { + return client.get(`/posts?userId=${userId}`); +}; + +export const getUserPostComments = (postId: number) => { + return client.get(`/comments?postId=${postId}`); +}; + +export const addNewComment = (newComment: CommentData) => { + return client.post('/comments', newComment); +}; + +export const deleteComment = (commentId: number) => { + return client.delete(`/comments/${commentId}`); +}; diff --git a/src/components/NewCommentForm.tsx b/src/components/NewCommentForm.tsx index 73a8a0b45..d70acfcc8 100644 --- a/src/components/NewCommentForm.tsx +++ b/src/components/NewCommentForm.tsx @@ -1,8 +1,80 @@ -import React from 'react'; +import classNames from 'classnames'; +import React, { useState } from 'react'; +import { CommentData } from '../types/Comment'; +import { Post } from '../types/Post'; + +type Props = { + addComment: (newComment: CommentData) => void; + buttonAddLoading: boolean; + selectedPost: Post | null; +}; + +export const NewCommentForm: React.FC = ({ + addComment, + buttonAddLoading, + selectedPost, +}) => { + const [name, setName] = useState(''); + const [nameError, setNameError] = useState(false); + const [email, setEmail] = useState(''); + const [emailError, setEmailError] = useState(false); + const [bodyComment, setBodyComment] = useState(''); + const [bodyCommentError, setBodyCommentError] = useState(false); + + let newComment: CommentData; + + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + setNameError(false); + setEmailError(false); + setBodyCommentError(false); + + let hasError = false; + + if (name.trim().length === 0) { + setNameError(true); + hasError = true; + } + + if (email.trim().length === 0) { + setEmailError(true); + hasError = true; + } + + if (bodyComment.trim().length === 0) { + setBodyCommentError(true); + hasError = true; + } + + if (hasError) { + return; + } + + if (selectedPost) { + newComment = { + postId: selectedPost.id, + name: name, + email: email, + body: bodyComment, + }; + + addComment(newComment); + setBodyComment(''); + } + }; + + const handleFormClear = () => { + setName(''); + setEmail(''); + setBodyComment(''); + setNameError(false); + setEmailError(false); + setBodyCommentError(false); + }; -export const NewCommentForm: React.FC = () => { return ( -
+
- -

- Name is required -

@@ -45,24 +125,32 @@ export const NewCommentForm: React.FC = () => { name="email" id="comment-author-email" placeholder="email@test.com" - className="input is-danger" + className={classNames('input', { 'is-danger': emailError })} + value={email} + onChange={e => { + setEmail(e.target.value); + setEmailError(false); + }} /> - - - + {emailError && ( + <> + + + +

+ Email is required +

+ + )}
- -

- Email is required -

@@ -75,25 +163,42 @@ export const NewCommentForm: React.FC = () => { id="comment-body" name="body" placeholder="Type comment here" - className="textarea is-danger" + className={classNames('textarea', { + 'is-danger': bodyCommentError, + })} + value={bodyComment} + onChange={e => { + setBodyComment(e.target.value); + setBodyCommentError(false); + }} />
-

- Enter some text -

+ {bodyCommentError && ( +

+ 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..4b89dd3d3 100644 --- a/src/components/PostDetails.tsx +++ b/src/components/PostDetails.tsx @@ -1,106 +1,121 @@ -import React from 'react'; -import { Loader } from './Loader'; +import React, { useEffect } from 'react'; import { NewCommentForm } from './NewCommentForm'; +import { Loader } from './Loader'; +import { Comment, CommentData } from '../types/Comment'; +import { User } from '../types/User'; +import { Post } from '../types/Post'; + +type Props = { + isCommentLoading: boolean; + postComments: Comment[]; + selectedPost: Post | null; + errorsComment: boolean; + onDeleteComment: (commentId: number) => void; + onAddComment: (newComment: CommentData) => void; + isButtonAddLoading: boolean; + onShowCommentFormChange: (item: boolean) => void; + isCommentFormVisible: boolean; + userSelected: User | null; +}; + +export const PostDetails: React.FC = ({ + isCommentLoading, + postComments, + selectedPost, + errorsComment, + onDeleteComment, + onAddComment, + isButtonAddLoading, + onShowCommentFormChange, + isCommentFormVisible, + userSelected, +}) => { + useEffect(() => { + onShowCommentFormChange(false); + }, [selectedPost]); + + if (!selectedPost || userSelected?.id !== selectedPost.userId) { + return null; + } -export const PostDetails: React.FC = () => { return (
-

- #18: voluptate et itaque vero tempora molestiae -

+

{`#${selectedPost?.id}: ${selectedPost?.title}`}

-

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

+

{selectedPost?.body}

- - -
- Something went wrong -
- -

- No comments yet -

+ {isCommentLoading ? ( + + ) : ( + <> + {errorsComment ? ( +
+ Something went wrong +
+ ) : ( + <> + {postComments.length === 0 ? ( +

+ No comments yet +

+ ) : ( +

Comments:

+ )} -

Comments:

+ {postComments.map((comment: Comment) => ( + - - - - - - + {!isCommentFormVisible && ( + + )} + + )} + + )}
- - + {isCommentFormVisible && ( + + )}
); diff --git a/src/components/PostsList.tsx b/src/components/PostsList.tsx index cf90f04b0..b20a36c69 100644 --- a/src/components/PostsList.tsx +++ b/src/components/PostsList.tsx @@ -1,86 +1,72 @@ import React from 'react'; +import { Post } from '../types/Post'; -export const PostsList: React.FC = () => ( -
-

Posts:

+type Props = { + userPosts: Post[]; + handleShowComment: (post: Post) => void; + showSideBar: number | null; + setShowSideBar: (id: number | null) => void; + setShowCommentForm: (item: boolean) => void; +}; - - - - - - {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} - - - +export const PostsList: React.FC = ({ + userPosts, + handleShowComment, + showSideBar, + setShowSideBar, + setShowCommentForm, +}) => { + return ( +
+

Posts:

-
- - +
#Title
17
+ + + + + {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} + + + - + + {userPosts.map((post: Post) => ( + + - - + - - - - - - - - - - - - - - - - - - - - - - -
#Title
- fugit voluptas sed molestias voluptatem provident -
{post.id} - -
{post.title}
18 - voluptate et itaque vero tempora molestiae - - -
19adipisci placeat illum aut reiciendis qui - -
20doloribus ad provident suscipit at - -
-
-); + + {showSideBar !== post.id ? ( + + ) : ( + + )} + + + ))} + + +
+ ); +}; diff --git a/src/components/UserSelector.tsx b/src/components/UserSelector.tsx index c89442841..368a740c0 100644 --- a/src/components/UserSelector.tsx +++ b/src/components/UserSelector.tsx @@ -1,16 +1,63 @@ -import React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { User } from '../types/User'; + +type Props = { + users: User[]; + setUserSelected: (user: User) => void; + userSelected: User | null; + handleUserPosts: (user: User) => void; + setShowSideBar: (item: number | null) => void; +}; + +export const UserSelector: React.FC = ({ + users, + setUserSelected, + userSelected, + handleUserPosts, + setShowSideBar, +}) => { + const [showUsers, setShowUsers] = useState(false); + const dropdownMenuRef = useRef(null); + const dropdownTriggerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownMenuRef.current && + !dropdownMenuRef.current.contains(event.target as Node) && + dropdownTriggerRef.current && + !dropdownTriggerRef.current.contains(event.target as Node) + ) { + setShowUsers(false); + } + }; + + document.addEventListener('click', handleClickOutside); + + return () => { + document.removeEventListener('click', handleClickOutside); + }; + }, []); -export const UserSelector: React.FC = () => { return ( -
-
+
+