From 2315cc25136398bcdc5cf880ccd8a78e75ce58c1 Mon Sep 17 00:00:00 2001 From: Serhii Yefimov Date: Wed, 9 Oct 2024 18:44:58 +0300 Subject: [PATCH 1/4] solution --- src/App.scss | 9 ++ src/App.tsx | 86 ++++++------- src/api/comments.ts | 18 +++ src/api/posts.ts | 7 ++ src/api/users.ts | 5 + src/components/NewCommentForm.tsx | 154 +++++++++++++++++++----- src/components/PostDetails.tsx | 188 ++++++++++++++++------------- src/components/PostsList.tsx | 192 +++++++++++++++++------------- src/components/UserSelector.tsx | 94 ++++++++++++--- src/enums/api-path.enum.ts | 5 + src/enums/enums.ts | 2 + src/enums/query-params.enum.ts | 4 + 12 files changed, 506 insertions(+), 258 deletions(-) create mode 100644 src/api/comments.ts create mode 100644 src/api/posts.ts create mode 100644 src/api/users.ts create mode 100644 src/enums/api-path.enum.ts create mode 100644 src/enums/enums.ts create mode 100644 src/enums/query-params.enum.ts diff --git a/src/App.scss b/src/App.scss index 695435da4..8836a6702 100644 --- a/src/App.scss +++ b/src/App.scss @@ -21,3 +21,12 @@ .message-body { white-space: pre-line; } + +.tile.is-ancestor { + display: flex; +} + +.tile.is-parent { + flex: 1; + padding: 0.75rem; +} diff --git a/src/App.tsx b/src/App.tsx index 017957182..b0de1c8af 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,54 +7,56 @@ import './App.scss'; import { PostsList } from './components/PostsList'; import { PostDetails } from './components/PostDetails'; import { UserSelector } from './components/UserSelector'; -import { Loader } from './components/Loader'; - -export const App = () => ( -
-
-
-
-
-
- -
- -
-

No user selected

- - - -
- Something went wrong! +import { useState } from 'react'; +import { User } from './types/User'; +import { Post } from './types/Post'; + +export const App = () => { + const [selectedUser, setSelectedUser] = useState(null); + const [selectedPost, setSelectedPost] = useState(null); + + return ( +
+
+
+
+
+
+
-
- No posts yet +
+ {selectedUser ? ( + + ) : ( +

No user selected

+ )}
- -
-
-
-
- +
+
+ {selectedPost && } +
-
-
-); +
+ ); +}; diff --git a/src/api/comments.ts b/src/api/comments.ts new file mode 100644 index 000000000..fefe58f3e --- /dev/null +++ b/src/api/comments.ts @@ -0,0 +1,18 @@ +import { ApiPath } from '../enums/api-path.enum'; +import { QueryParams } from '../enums/query-params.enum'; +import { Comment } from '../types/Comment'; +import { client } from '../utils/fetchClient'; + +export const getCommentsByPost = (id: number) => { + return client.get( + `${ApiPath.COMMENTS}?${QueryParams.POST_ID}=${id}`, + ); +}; + +export const addComment = (comment: Omit) => { + return client.post(ApiPath.COMMENTS, comment); +}; + +export const deleteComment = (id: number) => { + return client.delete(`${ApiPath.COMMENTS}/${id}`); +}; diff --git a/src/api/posts.ts b/src/api/posts.ts new file mode 100644 index 000000000..f4bfb2fa4 --- /dev/null +++ b/src/api/posts.ts @@ -0,0 +1,7 @@ +import { client } from '../utils/fetchClient'; +import { Post } from '../types/Post'; +import { ApiPath, QueryParams } from '../enums/enums'; + +export const getPostsByUserId = (id: number) => { + return client.get(`${ApiPath.POSTS}?${QueryParams.USER_ID}=${id}`); +}; diff --git a/src/api/users.ts b/src/api/users.ts new file mode 100644 index 000000000..2f2ade3e3 --- /dev/null +++ b/src/api/users.ts @@ -0,0 +1,5 @@ +import { User } from '../types/User'; +import { client } from '../utils/fetchClient'; +import { ApiPath } from '../enums/api-path.enum'; + +export const getUsers = () => client.get(ApiPath.USERS); diff --git a/src/components/NewCommentForm.tsx b/src/components/NewCommentForm.tsx index 73a8a0b45..5801274c7 100644 --- a/src/components/NewCommentForm.tsx +++ b/src/components/NewCommentForm.tsx @@ -1,8 +1,81 @@ -import React from 'react'; +import cn from 'classnames'; +import React, { useState } from 'react'; +import { CommentData } from '../types/Comment'; + +type Props = { + postId: number; + onSubmit: (data: CommentData & { postId: number }) => void; +}; + +export const NewCommentForm: React.FC = ({ postId, onSubmit }) => { + const [formData, setFormData] = useState({ + name: '', + email: '', + body: '', + }); + + const [errors, setErrors] = useState({ + name: false, + email: false, + body: false, + }); + + const [isLoading, setIsLoading] = useState(false); + + const handleChange = ( + e: React.ChangeEvent, + ) => { + const { name, value } = e.target; + + setFormData(prevData => ({ + ...prevData, + [name]: value, + })); + setErrors(prevErrors => ({ + ...prevErrors, + [name]: false, + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const newErrors = { + name: !formData.name, + email: !formData.email, + body: !formData.body, + }; + + setErrors(newErrors); + + if (Object.values(newErrors).some(error => error)) { + return; + } + + setIsLoading(true); + + try { + await onSubmit({ ...formData, postId }); + + setFormData({ + ...formData, + body: '', + }); + } finally { + setIsLoading(false); + } + }; + + const handleClear = () => { + setFormData({ + name: '', + email: '', + body: '', + }); + }; -export const NewCommentForm: React.FC = () => { return ( -
+
-

- Name is required -

+ {errors.name && ( +

+ Name is required +

+ )}
@@ -45,24 +124,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': errors.email })} + value={formData.email} + onChange={handleChange} /> - - - + {errors.email && ( + + + + )}
-

- Email is required -

+ {errors.email && ( +

+ Email is required +

+ )}
@@ -75,25 +160,36 @@ export const NewCommentForm: React.FC = () => { id="comment-body" name="body" placeholder="Type comment here" - className="textarea is-danger" + className={cn('textarea', { 'is-danger': errors.body })} + value={formData.body} + onChange={handleChange} />
-

- Enter some text -

+ {errors.body && ( +

+ 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..0ec10c157 100644 --- a/src/components/PostDetails.tsx +++ b/src/components/PostDetails.tsx @@ -1,106 +1,130 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Loader } from './Loader'; import { NewCommentForm } from './NewCommentForm'; +import { Post } from '../types/Post'; +import { Comment } from '../types/Comment'; +import { addComment, deleteComment, getCommentsByPost } from '../api/comments'; + +type Props = { + post: Post | null; +}; + +export const PostDetails: React.FC = ({ post }) => { + const [comments, setComments] = useState([]); + + const [isAddingComment, setIsAddingComment] = useState(false); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(false); + + useEffect(() => { + if (!post) { + return; + } + + const loadComments = async () => { + setIsLoading(true); + setError(false); + + try { + const commentsFromServer = await getCommentsByPost(post.id); + + setComments(commentsFromServer); + } catch { + setError(true); + } finally { + setIsLoading(false); + } + }; + + loadComments(); + }, [post]); + + const handleCommentAdd = async (commentData: Omit) => { + try { + const newComment = await addComment(commentData); + + setComments([...(comments || []), newComment]); + } catch { + setError(true); + } + }; + + const handleCommentDelete = async (id: number) => { + setComments(comments.filter(comment => comment.id !== id)); + try { + await deleteComment(id); + } catch { + setError(true); + } + }; -export const PostDetails: React.FC = () => { return (
-

- #18: voluptate et itaque vero tempora molestiae -

- -

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

-
+

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

+

{post?.body}

+
- - -
- Something went wrong -
- -

- No comments yet -

- -

Comments:

- - - - - -
-
- - Misha Hrynko - - - + {error && ( +
+ Something went wrong
+ )} -
- {'Multi\nline\ncomment'} -
-
+ {!isLoading && comments?.length === 0 && ( +

+ No comments yet +

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

Comments:

+ + {comments?.map(({ id, email, name, body }) => ( +
+
+ + {name} + + +
+ +
+ {body} +
+
+ ))} + + )}
- - + {isAddingComment && post && ( + + )}
); diff --git a/src/components/PostsList.tsx b/src/components/PostsList.tsx index cf90f04b0..e09a13472 100644 --- a/src/components/PostsList.tsx +++ b/src/components/PostsList.tsx @@ -1,86 +1,106 @@ -import React from 'react'; - -export const PostsList: React.FC = () => ( -
-

Posts:

- - - - - - - {/* 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 - -
-
-); +import React, { useEffect, useState } from 'react'; +import { Post } from '../types/Post'; +import cn from 'classnames'; +import { getPostsByUserId } from '../api/posts'; +import { User } from '../types/User'; +import { Loader } from './Loader'; + +type Props = { + selectedUser: User | null; + onPostSelect: (post: Post) => void; +}; + +export const PostsList: React.FC = ({ onPostSelect, selectedUser }) => { + const [posts, setPosts] = useState([]); + const [openPost, setOpenPost] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(false); + + const handlePostOpen = (post: Post) => { + setOpenPost(prevState => (prevState === post ? null : post)); + onPostSelect(post); + }; + + useEffect(() => { + const loadPosts = async () => { + if (!selectedUser?.id) { + return; + } + + setIsLoading(true); + setError(false); + + try { + const postsFromServer = await getPostsByUserId(selectedUser.id); + + setPosts(postsFromServer); + } catch { + setError(true); + } finally { + setIsLoading(false); + } + }; + + loadPosts(); + }, [selectedUser]); + + if (isLoading) { + return ; + } + + if (error) { + return ( +
+ Something went wrong! +
+ ); + } + + if (posts.length === 0) { + return ( +
+ No posts yet +
+ ); + } + + return ( +
+

Posts:

+ + + + + + + {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} + + + + + + {posts.map(post => ( + + + + + + + + ))} + +
#Title
{post.id}{post.body} + +
+
+ ); +}; diff --git a/src/components/UserSelector.tsx b/src/components/UserSelector.tsx index c89442841..cf54e3fb7 100644 --- a/src/components/UserSelector.tsx +++ b/src/components/UserSelector.tsx @@ -1,16 +1,74 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { User } from '../types/User'; +import cn from 'classnames'; +import { getUsers } from '../api/users'; + +type Props = { + selectedUser: User | null; + onSelectUser: (user: User) => void; +}; + +export const UserSelector: React.FC = ({ + selectedUser, + onSelectUser, +}) => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [users, setUsers] = useState([]); + const [error, setError] = useState(false); + + const dropdownRef = useRef(null); + + const loadUsers = async () => { + try { + const usersFromServer = await getUsers(); + + setUsers(usersFromServer); + } catch { + setError(true); + } + }; + + useEffect(() => { + loadUsers(); + }, []); + + const handleOutsideCLick = (e: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setIsDropdownOpen(false); + } + }; + + useEffect(() => { + document.addEventListener('mousedown', handleOutsideCLick); + + return () => { + document.removeEventListener('mousedown', handleOutsideCLick); + }; + }, []); + + const handleUserSelect = (user: User) => { + onSelectUser(user); + setIsDropdownOpen(false); + }; -export const UserSelector: React.FC = () => { return ( -
+