diff --git a/README.md b/README.md index c33761fd7..af385c5e3 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 + + +[DEMO LINK](https://Vasyl-Zhyliakov.github.io/react_dynamic-list-of-posts/) \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 017957182..757768c33 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import classNames from 'classnames'; +import { useEffect, useMemo, useState } from 'react'; import 'bulma/css/bulma.css'; import '@fortawesome/fontawesome-free/css/all.css'; @@ -8,53 +9,139 @@ import { PostsList } from './components/PostsList'; import { PostDetails } from './components/PostDetails'; import { UserSelector } from './components/UserSelector'; import { Loader } from './components/Loader'; +import { User } from './types/User'; +import { getUsersFromServer } from './api/users'; +import { Post } from './types/Post'; +import { getPostsFromServer } from './api/posts'; -export const App = () => ( -
-
-
-
-
-
- -
+export const App = () => { + const [isDropDownOpen, setIsDropDownOpen] = useState(false); + const [users, setUsers] = useState([]); + const [selectedUserId, setSelectedUserId] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [posts, setPosts] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [notificationMessage, setNotificationMessage] = useState(''); + const [activePostId, setActivePostId] = useState(null); -
-

No user selected

+ useEffect(() => { + getUsersFromServer() + .then(setUsers) + .catch(() => {}); + }, []); - + useEffect(() => { + if (selectedUserId) { + setIsLoading(true); + setPosts([]); + setErrorMessage(''); + setNotificationMessage(''); + setActivePostId(null); + + getPostsFromServer(selectedUserId) + .then(fetchPosts => { + setPosts(fetchPosts); + if (fetchPosts.length === 0) { + setNotificationMessage('No posts yet'); + } + }) + .catch(() => { + setErrorMessage('Something went wrong!'); + }) + .finally(() => { + setIsLoading(false); + }); + } + }, [selectedUserId]); + + const activePost = useMemo(() => { + if (activePostId) { + return posts.find(post => post.id === activePostId); + } + + return null; + }, [activePostId, posts]); + + useEffect(() => { + const handleClickOutside = () => { + setIsDropDownOpen(false); + }; + + document.addEventListener('click', handleClickOutside); + + return () => { + document.removeEventListener('click', handleClickOutside); + }; + }, []); -
- Something went wrong! -
-
- No posts yet + return ( +
+
+
+
+
+
+
- +
+ {!selectedUserId && ( +

No user selected

+ )} + + {isLoading && } + + {errorMessage && !isLoading && ( +
+ {errorMessage} +
+ )} + + {notificationMessage && !isLoading && !errorMessage && ( +
+ {notificationMessage} +
+ )} + + {posts.length > 0 && ( + + )} +
-
-
-
- +
+ {activePost && ( +
+ +
+ )}
-
-
-); +
+ ); +}; diff --git a/src/api/comments.ts b/src/api/comments.ts new file mode 100644 index 000000000..d5e06897e --- /dev/null +++ b/src/api/comments.ts @@ -0,0 +1,21 @@ +import { Comment } from '../types/Comment'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 1549; + +export const getCommentsFromServer = (postId: number) => { + return client.get(`/comments?postId=${postId}`); +}; + +export const deleteComment = (commentId: number) => { + return client.delete(`/comments/${commentId}`); +}; + +export const createComment = ({ + postId, + name, + email, + body, +}: Omit) => { + return client.post('/comments', { postId, name, email, body }); +}; diff --git a/src/api/posts.ts b/src/api/posts.ts new file mode 100644 index 000000000..7fb06f91e --- /dev/null +++ b/src/api/posts.ts @@ -0,0 +1,6 @@ +import { Post } from '../types/Post'; +import { client } from '../utils/fetchClient'; + +export const getPostsFromServer = (userId: number) => { + return client.get(`/posts?userId=${userId}`); +}; diff --git a/src/api/users.ts b/src/api/users.ts new file mode 100644 index 000000000..32cdfcae8 --- /dev/null +++ b/src/api/users.ts @@ -0,0 +1,6 @@ +import { User } from '../types/User'; +import { client } from '../utils/fetchClient'; + +export const getUsersFromServer = () => { + return client.get(`/users`); +}; diff --git a/src/components/NewCommentForm.tsx b/src/components/NewCommentForm.tsx index 73a8a0b45..a2edea2c6 100644 --- a/src/components/NewCommentForm.tsx +++ b/src/components/NewCommentForm.tsx @@ -1,8 +1,90 @@ -import React from 'react'; +import React, { useState, Dispatch, SetStateAction } from 'react'; +import cn from 'classnames'; +import { Comment } from '../types/Comment'; +import { createComment } from '../api/comments'; + +type Props = { + setComments: Dispatch>; + activePostId: number; + setErrorMessage: (m: string) => void; +}; + +export const NewCommentForm: React.FC = ({ + setComments, + activePostId, + setErrorMessage, +}) => { + const [isLoading, setIsLoading] = useState(false); + const [commentName, setCommentName] = useState(''); + const [isNameHasError, setIsNameHasError] = useState(false); + const [commentEmail, setCommentEmail] = useState(''); + const [isEmailHasError, setIsEmailHasError] = useState(false); + const [commentBody, setCommentBody] = useState(''); + const [isBodyHasError, setIsBodyHasError] = useState(false); + + const handleNameChange = (event: React.ChangeEvent) => { + setIsNameHasError(false); + setCommentName(event.target.value); + }; + + const handleEmailChange = (event: React.ChangeEvent) => { + setIsEmailHasError(false); + setCommentEmail(event.target.value); + }; + + const handleBodyChange = (event: React.ChangeEvent) => { + setIsBodyHasError(false); + setCommentBody(event.target.value); + }; + + function addComment({ postId, name, email, body }: Omit) { + setIsLoading(true); + + createComment({ postId, name, email, body }) + .then(newComment => { + setComments(currentComments => [...currentComments, newComment]); + }) + .catch(() => setErrorMessage('Something went wrong')) + .finally(() => { + setIsLoading(false); + setCommentBody(''); + }); + } + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + setIsNameHasError(!commentName.trim()); + setIsEmailHasError(!commentEmail.trim()); + setIsBodyHasError(!commentBody.trim()); + + if (!commentName.trim() || !commentEmail.trim() || !commentBody.trim()) { + return; + } + + addComment({ + postId: activePostId, + name: commentName, + email: commentEmail, + body: commentBody, + }); + }; + + const handleReset = () => { + setCommentName(''); + setCommentBody(''); + setCommentEmail(''); + setIsNameHasError(false); + setIsEmailHasError(false); + setIsBodyHasError(false); + }; -export const NewCommentForm: React.FC = () => { return ( -
+
-

- Name is required -

+ {isNameHasError && ( +

+ Name is required +

+ )}
@@ -45,24 +133,30 @@ export const NewCommentForm: React.FC = () => { name="email" id="comment-author-email" placeholder="email@test.com" - className="input is-danger" + className={cn('input', { 'is-danger': isEmailHasError })} + value={commentEmail} + onChange={handleEmailChange} /> - - - + {isEmailHasError && ( + + + + )}
-

- Email is required -

+ {isEmailHasError && ( +

+ Email is required +

+ )}
@@ -75,18 +169,25 @@ export const NewCommentForm: React.FC = () => { id="comment-body" name="body" placeholder="Type comment here" - className="textarea is-danger" + className={cn('textarea', { 'is-danger': isBodyHasError })} + value={commentBody} + onChange={handleBodyChange} />
-

- Enter some text -

+ {isBodyHasError && ( +

+ Enter some text +

+ )}
-
diff --git a/src/components/OnePost.tsx b/src/components/OnePost.tsx new file mode 100644 index 000000000..5ac11440d --- /dev/null +++ b/src/components/OnePost.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import cn from 'classnames'; + +import { Post } from '../types/Post'; + +type Props = { + post: Post; + activePostId: number | null; + setActivePostId: (param: number | null) => void; +}; + +export const OnePost: React.FC = ({ + post, + activePostId, + setActivePostId, +}) => { + const handleOpenPost = (currentPostId: number | null) => { + if (activePostId === currentPostId) { + setActivePostId(null); + } else { + setActivePostId(currentPostId); + } + }; + + return ( + + {post.id} + + {post.title} + + + + + + ); +}; diff --git a/src/components/OneUser.tsx b/src/components/OneUser.tsx new file mode 100644 index 000000000..ec68fdedc --- /dev/null +++ b/src/components/OneUser.tsx @@ -0,0 +1,33 @@ +import { User } from '../types/User'; +import cn from 'classnames'; + +type Props = { + user: User; + selectedUserId: number | null; + setSelectedUserId: (id: number | null) => void; + setIsDropDownOpen: (status: boolean) => void; +}; + +export const OneUser: React.FC = ({ + user, + selectedUserId, + setSelectedUserId, + setIsDropDownOpen, +}) => { + const handleUserSelect = () => { + setSelectedUserId(user.id); + setIsDropDownOpen(false); + }; + + return ( + + {user.name} + + ); +}; diff --git a/src/components/PostDetails.tsx b/src/components/PostDetails.tsx index 2f82db916..e17ae49b6 100644 --- a/src/components/PostDetails.tsx +++ b/src/components/PostDetails.tsx @@ -1,106 +1,123 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { Loader } from './Loader'; import { NewCommentForm } from './NewCommentForm'; +import { Post } from '../types/Post'; +import { Comment } from '../types/Comment'; +import { deleteComment, getCommentsFromServer } from '../api/comments'; + +type Props = { + activePost: Post; +}; + +export const PostDetails: React.FC = ({ activePost }) => { + const [comments, setComments] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isFormOpen, setIsFormOpen] = useState(false); + + const handleCommentDelete = (id: number) => { + setComments(currentComments => + currentComments.filter(comment => comment.id !== id), + ); + + deleteComment(id); + }; + + useEffect(() => { + setErrorMessage(''); + setIsFormOpen(false); + setIsLoading(true); + + getCommentsFromServer(activePost.id) + .then(p => { + setComments(p); + }) + .catch(() => { + setErrorMessage('Something went wrong'); + }) + .finally(() => { + setIsLoading(false); + }); + }, [activePost]); -export const PostDetails: React.FC = () => { return (

- #18: voluptate et itaque vero tempora molestiae + {`#${activePost.id}: ${activePost.title}`}

-

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

+

{activePost.body}

- - -
- Something went wrong -
- -

- No comments yet -

- -

Comments:

- -
-
- - Misha Hrynko - - -
+ {isLoading && !errorMessage && } -
- Some comment -
-
- - - -
-
- - Misha Hrynko - - - + {!isLoading && errorMessage && ( +
+ {errorMessage}
+ )} -
- {'Multi\nline\ncomment'} -
-
- - + {comments.length === 0 && !errorMessage && !isLoading && ( +

+ No comments yet +

+ )} + + {comments.length > 0 && !isLoading && ( + <> +

Comments:

+ + {comments.map(comment => ( +
+
+ + {comment.name} + + +
+ +
+ {comment.body} +
+
+ ))} + + )} + + {!isFormOpen && !errorMessage && !isLoading && ( + + )}
- + {isFormOpen && ( + + )}
); diff --git a/src/components/PostsList.tsx b/src/components/PostsList.tsx index cf90f04b0..7f6233467 100644 --- a/src/components/PostsList.tsx +++ b/src/components/PostsList.tsx @@ -1,10 +1,22 @@ import React from 'react'; - -export const PostsList: React.FC = () => ( +import { Post } from '../types/Post'; +import { OnePost } from './OnePost'; + +type Props = { + posts: Post[]; + activePostId: number | null; + setActivePostId: (param: number | null) => void; +}; + +export const PostsList: React.FC = ({ + posts, + activePostId, + setActivePostId, +}) => (

Posts:

- +
@@ -15,71 +27,14 @@ export const PostsList: React.FC = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {posts.map(post => ( + + ))}
#
17 - fugit voluptas sed molestias voluptatem provident - - -
18 - voluptate et itaque vero tempora molestiae - - -
19adipisci placeat illum aut reiciendis qui - -
20doloribus ad provident suscipit at - -
diff --git a/src/components/UserSelector.tsx b/src/components/UserSelector.tsx index c89442841..cc3b4de49 100644 --- a/src/components/UserSelector.tsx +++ b/src/components/UserSelector.tsx @@ -1,16 +1,46 @@ -import React from 'react'; +import cn from 'classnames'; + +import { User } from '../types/User'; +import { OneUser } from './OneUser'; + +type Props = { + users: User[]; + setSelectedUserId: (id: number | null) => void; + selectedUserId: number | null; + isDropDownOpen: boolean; + setIsDropDownOpen: (status: boolean) => void; +}; + +export const UserSelector: React.FC = ({ + isDropDownOpen, + setIsDropDownOpen, + users, + setSelectedUserId, + selectedUserId, +}) => { + + + const activeUser = users.find(user => user.id === selectedUserId); -export const UserSelector: React.FC = () => { return ( -
+
event.stopPropagation()} + >