diff --git a/README.md b/README.md index 6b712ae0a..bd5c5903e 100644 --- a/README.md +++ b/README.md @@ -34,5 +34,5 @@ Install Prettier Extention and use this [VSCode settings](https://mate-academy.g - Add the `is-loading` class to the submit button while waiting for a response; - Add the new comment received as a response from the `API` to the end of the list; 1. Implement comment deletion - - Delete the commnet immediately not waiting for the server response to improve the UX. + - Delete the comment immediately not waiting for the server response to improve the UX. 1. (*) Handle `Add` and `Delete` errors so the user can retry diff --git a/cypress/support/component-index.html b/cypress/support/component-index.html index 5f9622ae2..a0b0fc7fe 100644 --- a/cypress/support/component-index.html +++ b/cypress/support/component-index.html @@ -1,12 +1,12 @@ - - - - - Components App - - -
- + + + + + Components App + + +
+ diff --git a/package-lock.json b/package-lock.json index efbe6b2a6..b53f0e0b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", - "bulma": "^1.0.1", + "bulma": "^0.9.4", "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -3632,9 +3632,9 @@ } }, "node_modules/bulma": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bulma/-/bulma-1.0.2.tgz", - "integrity": "sha512-D7GnDuF6seb6HkcnRMM9E739QpEY9chDzzeFrHMyEns/EXyDJuQ0XA0KxbBl/B2NTsKSoDomW61jFGFaAxhK5A==", + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.4.tgz", + "integrity": "sha512-86FlT5+1GrsgKbPLRRY7cGDg8fsJiP/jzTqXXVqiUZZ2aZT8uemEOHlU1CDU+TxklPEZ11HZNNWclRBBecP4CQ==", "license": "MIT" }, "node_modules/cachedir": { diff --git a/package.json b/package.json index 4b5c5ff54..f63c94c7e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", - "bulma": "^1.0.1", + "bulma": "^0.9.4", "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index 15369f54d..a8608f654 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,8 @@ -// import classNames from 'classnames'; - import 'bulma/css/bulma.css'; import '@fortawesome/fontawesome-free/css/all.css'; import './App.scss'; import { PostsList } from './components/PostsList'; -// import { PostDetails } from './components/PostDetails'; - import { getUsers } from './servises/user'; import { UserSelector } from './components/UserSelector'; import { useEffect, useState } from 'react'; @@ -14,18 +10,28 @@ import { User } from './types/User'; import { Post } from './types/Post'; import { getPosts } from './servises/post'; import { Loader } from './components/Loader'; +import classNames from 'classnames'; +import { PostDetails } from './components/PostDetails'; export const App = () => { const [users, setUsers] = useState(null); const [selectedUser, setSelectedUser] = useState(null); + const [selectedPost, setSelectedPost] = useState(null); + const [isOpenedNewCommentForm, setIsOpenedNewCommentForm] = useState(false); const [posts, setPosts] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(false); const showNoPost = - selectedUser && !isLoading && !error && posts && posts.length === 0; + selectedUser && !isLoading && !error && posts?.length === 0; + const showPostList = + selectedUser && !isLoading && !error && posts && posts.length > 0; useEffect(() => { - getUsers().then(setUsers); + getUsers() + .then(setUsers) + .catch(usersLoadingError => { + throw usersLoadingError; + }); }, []); useEffect(() => { @@ -33,6 +39,8 @@ export const App = () => { setIsLoading(true); setError(false); setPosts(null); + setSelectedPost(null); + setIsOpenedNewCommentForm(false); getPosts(selectedUser.id) .then(setPosts) @@ -54,7 +62,7 @@ export const App = () => {
- {users && ( + {users && users?.length > 0 && ( {
- {!selectedUser && ( + {users && !selectedUser && (

No user selected

)} @@ -85,63 +93,39 @@ export const App = () => {
)} - {selectedUser && - !isLoading && - !error && - posts && - posts.length > 0 && } - - {/* {!selectedUser ? ( -

No user selected

- ) : ( - <> - {isLoading ? ( - - ) : ( - <> - {error ? ( -
- Something went wrong! -
- ) : ( - <> - {!posts || posts.length === 0 ? ( -
- No posts yet -
- ) : ( - - )} - - )} - - )} - - )} */} + {showPostList && ( + + )}
- {/*
-
- + {selectedPost && ( +
+
+ {selectedPost && ( + + )} +
-
*/} + )}
diff --git a/src/components/NewCommentForm.tsx b/src/components/NewCommentForm.tsx index 73a8a0b45..b340f4c02 100644 --- a/src/components/NewCommentForm.tsx +++ b/src/components/NewCommentForm.tsx @@ -1,8 +1,87 @@ -import React from 'react'; +import classNames from 'classnames'; +import React, { useState } from 'react'; +import { CommentData } from '../types/Comment'; + +interface Props { + addNewComment: (newComment: CommentData) => Promise; + isSubmitting: boolean; +} + +const initialNewComment = { + name: '', + email: '', + body: '', +}; + +const initialErrors = { + nameError: '', + emailError: '', + bodyError: '', +}; + +export const NewCommentForm: React.FC = ({ + addNewComment, + isSubmitting, +}) => { + const [commentData, setCommentData] = useState(initialNewComment); + const [hasErrorMessage, setHasErrorMessage] = useState(initialErrors); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + setHasErrorMessage(initialErrors); + + const normalizedData = { + name: commentData.name.trim(), + email: commentData.email.trim(), + body: commentData.body.trim(), + }; + + if (normalizedData.name.length === 0) { + setHasErrorMessage(errors => ({ + ...errors, + nameError: 'Name is required', + })); + } + + if (normalizedData.email.length === 0) { + setHasErrorMessage(errors => ({ + ...errors, + emailError: 'Email is required', + })); + } + + if (normalizedData.body.length === 0) { + setHasErrorMessage(errors => ({ + ...errors, + bodyError: 'Enter some text', + })); + } + + if ( + normalizedData.name.length === 0 || + normalizedData.email.length === 0 || + normalizedData.body.length === 0 + ) { + return; + } + + addNewComment(normalizedData) + .then(() => { + commentData.body = ''; + }) + .catch(error => { + setCommentData(initialNewComment); + throw error; + }); + }; + + const handleClearButton = () => { + setCommentData(initialNewComment); + setHasErrorMessage(initialErrors); + }; -export const NewCommentForm: React.FC = () => { return ( -
+
-

- Name is required -

+ {hasErrorMessage.nameError.length > 0 && ( +

+ {hasErrorMessage.nameError} +

+ )}
@@ -45,24 +137,37 @@ export const NewCommentForm: React.FC = () => { name="email" id="comment-author-email" placeholder="email@test.com" - className="input is-danger" + className={classNames('input', { + 'is-danger': false, + })} + value={commentData.email} + onChange={event => + setCommentData(prev => ({ + ...prev, + email: event.target.value, + })) + } /> - - - + {hasErrorMessage.emailError && ( + + + + )}
-

- Email is required -

+ {hasErrorMessage.emailError && ( +

+ {hasErrorMessage.emailError} +

+ )}
@@ -75,25 +180,45 @@ export const NewCommentForm: React.FC = () => { id="comment-body" name="body" placeholder="Type comment here" - className="textarea is-danger" + className={classNames('textarea', { + 'is-danger': false, + })} + value={commentData.body} + onChange={event => + setCommentData(prev => ({ + ...prev, + body: event.target.value, + })) + } />
-

- Enter some text -

+ {hasErrorMessage.bodyError && ( +

+ {hasErrorMessage.bodyError} +

+ )}
-
{/* eslint-disable-next-line react/button-has-type */} -
diff --git a/src/components/PostDetails.tsx b/src/components/PostDetails.tsx index 2f82db916..24845c9bc 100644 --- a/src/components/PostDetails.tsx +++ b/src/components/PostDetails.tsx @@ -1,106 +1,163 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { Post } from '../types/Post'; +import { deleteComment, getComments, postComment } from '../servises/post'; import { Loader } from './Loader'; +import { Comment, CommentData } from '../types/Comment'; import { NewCommentForm } from './NewCommentForm'; -export const PostDetails: React.FC = () => { +interface Props { + selectedPost: Post; + isOpenedNewCommentForm: boolean; + onOpenNewCommentForm: (answer: boolean) => void; +} + +export const PostDetails: React.FC = ({ + selectedPost, + isOpenedNewCommentForm, + onOpenNewCommentForm, +}) => { + const [comments, setComments] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(false); + const hasNoComments = + !isLoading && !error && comments && comments.length === 0; + const hasAlreadyComments = + !isLoading && !error && comments && comments.length > 0; + const isShowNewCommentButton = + !isLoading && !isOpenedNewCommentForm && !error; + + const addNewComment = ({ name, email, body }: CommentData) => { + setIsSubmitting(true); + + const postId = selectedPost.id; + + return postComment({ postId, name, email, body }) + .then(newComment => { + setComments(currentComments => { + if (currentComments) { + return [...currentComments, newComment]; + } + + return [newComment]; + }); + }) + .catch(loadingError => { + setError(true); + + throw loadingError; + }) + .finally(() => { + setIsSubmitting(false); + }); + }; + + const onDeleteComment = (commentId: number) => { + setComments(currentComments => + currentComments + ? currentComments.filter(comment => comment.id !== commentId) + : [], + ); + + deleteComment(commentId).catch(() => { + setError(true); + }); + }; + + useEffect(() => { + setIsLoading(true); + onOpenNewCommentForm(false); + setError(false); + + getComments(selectedPost.id) + .then(setComments) + .catch(loadingError => { + setError(true); + + throw loadingError; + }) + .finally(() => { + setIsLoading(false); + }); + }, [selectedPost]); + 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 -

- -

Comments:

- - - - - -
-
- - Misha Hrynko - - - + {!isLoading && error && ( +
+ Something went wrong
+ )} -
- {'Multi\nline\ncomment'} -
-
- - + {hasNoComments && ( +

+ No comments yet +

+ )} + + {hasAlreadyComments && ( + <> +

Comments:

+ + {comments.map(comment => ( +
+
+ + {comment.name} + + +
+ +
+ {comment.body} +
+
+ ))} + + )} + + {isShowNewCommentButton && ( + + )}
- + {isOpenedNewCommentForm && !error && ( + + )}
); diff --git a/src/components/PostsList.tsx b/src/components/PostsList.tsx index 551e5e946..30f81fdcd 100644 --- a/src/components/PostsList.tsx +++ b/src/components/PostsList.tsx @@ -1,111 +1,65 @@ import React from 'react'; import { Post } from '../types/Post'; +import classNames from 'classnames'; interface Props { posts: Post[]; + selectedPost?: Post | null; + onSelectPost: (post: Post | null) => void; } -export const PostsList: React.FC = ({ posts }) => ( -
-

Posts:

- - - - - - - {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} - - - - - - {posts.map(post => ( - - - - - - +export const PostsList: React.FC = ({ + posts, + selectedPost, + onSelectPost, +}) => { + const handleButtonClick = (clickedPost: Post) => { + if (selectedPost?.id === clickedPost.id) { + onSelectPost(null); + + return; + } + + onSelectPost(clickedPost); + }; + + return ( +
+

Posts:

+ +
#Title
17 - fugit voluptas sed molestias voluptatem provident - - -
+ + + + + {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} + - ))} - - {/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - */} - -
#Title
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 - -
-
-); + + + + {posts.map(post => ( + + {post.id} + + {post.body} + + + + + + ))} + + +
+ ); +}; diff --git a/src/components/UserSelector.tsx b/src/components/UserSelector.tsx index a9e8644bd..9fb1e3c31 100644 --- a/src/components/UserSelector.tsx +++ b/src/components/UserSelector.tsx @@ -26,7 +26,7 @@ export const UserSelector: React.FC = ({
0, })} onBlur={handleBlur} > @@ -55,22 +55,21 @@ export const UserSelector: React.FC = ({
diff --git a/src/servises/post.tsx b/src/servises/post.tsx index c249c120c..41c2bd719 100644 --- a/src/servises/post.tsx +++ b/src/servises/post.tsx @@ -1,6 +1,19 @@ +import { Comment } from '../types/Comment'; import { Post } from '../types/Post'; import { client } from '../utils/fetchClient'; export function getPosts(userId: number) { return client.get(`/posts?userId=${userId}`); } + +export function getComments(postId: number) { + return client.get(`/comments?postId=${postId}`); +} + +export function postComment(comment: Omit) { + return client.post(`/comments`, comment); +} + +export function deleteComment(commentId: number) { + return client.delete(`/comments/${commentId}`); +} diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts index feb6915ce..762c85661 100644 --- a/src/utils/fetchClient.ts +++ b/src/utils/fetchClient.ts @@ -35,6 +35,5 @@ function request( export const client = { get: (url: string) => request(url), post: (url: string, data: any) => request(url, 'POST', data), - patch: (url: string, data: any) => request(url, 'PATCH', data), delete: (url: string) => request(url, 'DELETE'), };