From 6d769da4aa35e122347430b2d9cdc15f113eb92d Mon Sep 17 00:00:00 2001 From: Artem Kravcov Date: Sun, 23 Jul 2023 18:09:51 +0300 Subject: [PATCH 1/4] React_dynamic-list-of-posts --- README.md | 2 + cypress.json | 1 - cypress/support/index.d.ts | 8 -- cypress/support/index.js | 18 --- package-lock.json | 6 +- package.json | 2 +- src/App.tsx | 186 +++++++++++++++++++++++++----- src/api/api.ts | 34 ++++++ src/components/NewCommentForm.tsx | 171 ++++++++++++++++++++++----- src/components/PostDetails.tsx | 177 ++++++++++++++-------------- src/components/PostsList.tsx | 100 ++++++---------- src/components/UserSelector.tsx | 54 ++++++--- src/utils/fetchClient.ts | 6 +- 13 files changed, 500 insertions(+), 265 deletions(-) create mode 100644 src/api/api.ts diff --git a/README.md b/README.md index b8767524f..c7213b4e0 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,5 @@ form to add new comments. 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://ArtemKravcov85.github.io/react_dynamic-list-of-posts/) \ No newline at end of file diff --git a/cypress.json b/cypress.json index 9090ff06c..8c162de1f 100644 --- a/cypress.json +++ b/cypress.json @@ -1,6 +1,5 @@ { "baseUrl": "http://localhost:3000", - "defaultCommandTimeout": 2000, "video": true, "viewportHeight": 1920, "viewportWidth": 1080, diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts index 3dcae91c6..6d5052517 100644 --- a/cypress/support/index.d.ts +++ b/cypress/support/index.d.ts @@ -1,9 +1 @@ import '@mate-academy/cypress-tools/support'; - -declare global { - namespace Cypress { - interface Chainable { - byDataCy(name: string, text: string): Chainable; - } - } -} \ No newline at end of file diff --git a/cypress/support/index.js b/cypress/support/index.js index b02de611c..98b942506 100644 --- a/cypress/support/index.js +++ b/cypress/support/index.js @@ -1,19 +1 @@ require('@mate-academy/cypress-tools/support'); - -Cypress.Commands.add( - 'byDataCy', - { prevSubject: 'optional' }, - - (subject, name, text = '') => { - const target = subject || cy; - const selector = `[data-cy="${name}"]`; - - if (text) { - return target.contain(selector, text); - } - - return subject - ? subject.find(selector) - : cy.get(selector); - }, -); diff --git a/package-lock.json b/package-lock.json index a9f5edd43..b5faaf0b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2013,9 +2013,9 @@ } }, "@mate-academy/cypress-tools": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@mate-academy/cypress-tools/-/cypress-tools-1.0.4.tgz", - "integrity": "sha512-FWNg0VKxxAvRbGriU3MLG2WVpJjc74OERKOggA+Se+KvxeDP8xQ4Y1Zmi1uhZS2onRoCAr478AO2gZ0mf+YYCw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@mate-academy/cypress-tools/-/cypress-tools-1.0.5.tgz", + "integrity": "sha512-4pKueJR5Sc/1abFdC5dKOFtliC5QhT22ej290btJRrY5jOV9SQgwYBlhMoFXqLilTJMceLC7DqrRDPMdqYl89A==", "dev": true, "requires": { "@cypress/react": "^5.12.4", diff --git a/package.json b/package.json index 25a798d8e..6666effb3 100755 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@cypress/webpack-dev-server": "^1.8.4", "@mate-academy/cypress-tools": "^1.0.5", "@mate-academy/eslint-config-react-typescript": "^1.0.5", - "@mate-academy/scripts": "^1.2.3", + "@mate-academy/scripts": "^1.2.8", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^17.0.23", diff --git a/src/App.tsx b/src/App.tsx index db56b44b0..992125174 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,115 @@ -import React from 'react'; -import 'bulma/bulma.sass'; -import '@fortawesome/fontawesome-free/css/all.css'; -import './App.scss'; +import React, { useEffect, useState } from 'react'; +import cn from 'classnames'; -import classNames from 'classnames'; +import { UserSelector } from './components/UserSelector'; 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 { + addComment, + deleteComment, + getComments, + getUserPosts, + getUsers, +} from './api/api'; +import { Post } from './types/Post'; +import { Comment } from './types/Comment'; + +import './App.scss'; +import 'bulma/bulma.sass'; +import '@fortawesome/fontawesome-free/css/all.css'; export const App: React.FC = () => { + const [users, setUsers] = useState([]); + const [userPosts, setUserPosts] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedPost, setSelectedPost] = useState(null); + const [comments, setComments] = useState([]); + + const [isCommentsLoading, setIsCommentsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + const [isCommentError, setIsCommentError] = useState(false); + const [canWriteComment, setCanWriteComment] = useState(false); + + useEffect(() => { + getUsers().then(setUsers); + }, []); + + const getUserPostsFromServer = (userId: number) => { + setIsLoading(true); + + getUserPosts(userId) + .then((data) => { + setUserPosts(data); + setIsError(false); + }) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(false)); + }; + + const getCommentsFromServer = (postId: number) => { + setIsCommentsLoading(true); + + getComments(postId) + .then((data) => { + setComments(data); + setIsCommentError(false); + }) + .catch(() => setIsCommentError(true)) + .finally(() => { + setIsLoading(false); + setIsCommentsLoading(false); + }); + }; + + const handleUserSelect = ( + event: React.MouseEvent, + user: User, + ) => { + event.preventDefault(); + + getUserPostsFromServer(user.id); + setSelectedUser(user); + setSelectedPost(null); + }; + + const handleSelectPost = (post: Post) => { + setSelectedPost((currentPost) => { + if (currentPost?.id === post.id) { + return null; + } + + return post; + }); + + getCommentsFromServer(post.id); + setCanWriteComment(false); + }; + + const handleAddNewComment = async ( + postId: number, + name: string, + email: string, + body: string, + ) => { + const newComment = await addComment(postId, name, email, body); + + const filteredComments + = comments.filter(comment => selectedPost?.id === comment.postId); + + setComments([...filteredComments, newComment]); + }; + + const handleDeleteComment = (commentId: number) => { + const filteredComments + = comments.filter(comment => comment.id !== commentId); + + setComments(filteredComments); + deleteComment(commentId); + }; + return (
@@ -17,44 +117,72 @@ export const App: React.FC = () => {
- +
-

- No user selected -

+ {!selectedUser && ( +

No user selected

+ )} - + {isLoading && } -
- Something went wrong! -
+ {isError && !isLoading && ( +
+ Something went wrong! +
+ )} -
- No posts yet -
+ {!userPosts.length + && selectedUser + && !isLoading + && !isError + && ( +
+ No posts yet +
+ )} - + {userPosts.length > 0 && !isLoading && ( + + )}
- + {selectedPost && ( + + )}
diff --git a/src/api/api.ts b/src/api/api.ts new file mode 100644 index 000000000..20ea6ae8d --- /dev/null +++ b/src/api/api.ts @@ -0,0 +1,34 @@ +import { Comment } from '../types/Comment'; +import { Post } from '../types/Post'; +import { User } from '../types/User'; +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 getComments = (postId: number) => { + return client.get(`/comments?postId=${postId}`); +}; + +export const addComment = ( + postId: number, + name: string, + email: string, + body: string, +) => { + return client.post('/comments', { + postId, + name, + email, + body, + }); +}; + +export const deleteComment = (commentId: number) => { + return client.delete(`/comments/${commentId}`); +}; diff --git a/src/components/NewCommentForm.tsx b/src/components/NewCommentForm.tsx index 73a8a0b45..38b51e86f 100644 --- a/src/components/NewCommentForm.tsx +++ b/src/components/NewCommentForm.tsx @@ -1,8 +1,79 @@ -import React from 'react'; +import React, { useState } from 'react'; +import cn from 'classnames'; + +type Props = { + selectedPostId: number; + handleAddNewComment: ( + postId: number, + name: string, + email: string, + body: string, + ) => void; +}; + +export const NewCommentForm: React.FC = ({ + selectedPostId, + handleAddNewComment, +}) => { + const [name, setName] = useState(''); + const [isNameError, setIsNameError] = useState(false); + const [email, setEmail] = useState(''); + const [isEmailError, setIsEmailError] = useState(false); + const [commentText, setCommentText] = useState(''); + const [isCommentTextError, setIsCommentTextError] = useState(false); + + const [isLoading, setIsLoading] = useState(false); + + const handleClear = () => { + setName(''); + setIsNameError(false); + setEmail(''); + setIsEmailError(false); + setCommentText(''); + setIsCommentTextError(false); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!name.trim().length) { + setIsNameError(true); + } else { + setIsNameError(false); + } + + if (!email.trim().length) { + setIsEmailError(true); + } else { + setIsEmailError(false); + } + + if (!commentText.length) { + setIsCommentTextError(true); + } else { + setIsCommentTextError(false); + } + + if ( + name.trim().length + && email.trim().length + && commentText.trim().length + ) { + try { + setIsLoading(true); + + await handleAddNewComment(selectedPostId, name, email, commentText); + setCommentText(''); + } catch { + throw new Error(); + } finally { + setIsLoading(false); + } + } + }; -export const NewCommentForm: React.FC = () => { return ( -
+
-

- Name is required -

+ {isNameError && ( +

+ Name is required +

+ )}
@@ -41,28 +123,39 @@ export const NewCommentForm: React.FC = () => {
{ + setEmail(event.target.value); + setIsEmailError(false); + }} /> - - - + {isEmailError && ( + + + + )}
-

- Email is required -

+ {isEmailError && ( +

+ Email is required +

+ )}
@@ -75,25 +168,43 @@ export const NewCommentForm: React.FC = () => { id="comment-body" name="body" placeholder="Type comment here" - className="textarea is-danger" + className={cn('textarea', { + 'is-danger': isCommentTextError, + })} + value={commentText} + onChange={(event) => { + setCommentText(event.target.value); + setIsCommentTextError(false); + }} />
-

- Enter some text -

+ {isCommentTextError && ( +

+ Enter some text +

+ )}
-
{/* eslint-disable-next-line react/button-has-type */} -
diff --git a/src/components/PostDetails.tsx b/src/components/PostDetails.tsx index ace945f0a..7c61f0d51 100644 --- a/src/components/PostDetails.tsx +++ b/src/components/PostDetails.tsx @@ -1,117 +1,112 @@ import React from 'react'; +import { Post } from '../types/Post'; +import { Comment } from '../types/Comment'; import { Loader } from './Loader'; import { NewCommentForm } from './NewCommentForm'; -export const PostDetails: React.FC = () => { +type Props = { + selectedPost: Post; + comments: Comment[] | null; + isCommentError: boolean; + canWriteComment: boolean; + setCanWriteComment: React.Dispatch>; + handleAddNewComment: ( + postId: number, + name: string, + email: string, + body: string, + ) => void; + handleDeleteComment: (commentId: number) => void; + isCommentsLoading: boolean; +}; + +export const PostDetails: React.FC = ({ + selectedPost, + comments, + isCommentError, + canWriteComment, + setCanWriteComment, + handleAddNewComment, + handleDeleteComment, + isCommentsLoading, +}) => { 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 - - -
+ {isCommentsLoading && !isCommentError && } -
- Some comment + {isCommentError && ( +
+ Something went wrong
-
+ )} -
-
- - Misha Hrynko - + {comments?.length === 0 && ( +

+ No comments yet +

+ )} - -
-
- One more comment -
-
+ {comments && !isCommentsLoading && ( + <> +

Comments:

+ {comments.map((comment) => ( + + ))} + + )} -
- {'Multi\nline\ncomment'} -
- - - + {!canWriteComment && ( + + )}
- + {canWriteComment && ( + + )}
); diff --git a/src/components/PostsList.tsx b/src/components/PostsList.tsx index 6e6efc0f3..ccc03c9bc 100644 --- a/src/components/PostsList.tsx +++ b/src/components/PostsList.tsx @@ -1,6 +1,19 @@ import React from 'react'; +import cn from 'classnames'; -export const PostsList: React.FC = () => ( +import { Post } from '../types/Post'; + +type Props = { + userPosts: Post[]; + handleSelectPost: (post: Post) => void; + selectedPost: Post | null; +}; + +export const PostsList: React.FC = ({ + userPosts, + handleSelectPost, + selectedPost, +}) => (

Posts:

@@ -14,71 +27,26 @@ export const PostsList: React.FC = () => ( - - 17 - - - fugit voluptas sed molestias voluptatem provident - - - - - - - - - 18 - - - voluptate et itaque vero tempora molestiae - - - - - - - - - 19 - adipisci placeat illum aut reiciendis qui - - - - - - - - 20 - doloribus ad provident suscipit at - - - - - + {userPosts.map((post) => ( + + {post.id} + + {post.title} + + + + + + ))}
diff --git a/src/components/UserSelector.tsx b/src/components/UserSelector.tsx index cb83a8f68..7c269a27b 100644 --- a/src/components/UserSelector.tsx +++ b/src/components/UserSelector.tsx @@ -1,19 +1,33 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { User } from '../types/User'; + +type Props = { + users: User[]; + handleUserSelect: ( + event: React.MouseEvent, + user: User, + ) => void; + selectedUser: User | null; +}; + +export const UserSelector: React.FC = ({ + users, + handleUserSelect, + selectedUser, +}) => { + const [isDropDownOpened, setIsDropDownOpened] = useState(false); -export const UserSelector: React.FC = () => { return ( -
+