From 8d3abc52b683f1a8aaa6ac3464431f498557b2d9 Mon Sep 17 00:00:00 2001 From: Oleksii Bidiak Date: Mon, 8 Jul 2024 15:37:28 +0300 Subject: [PATCH 1/3] solution --- src/App.tsx | 103 +++++++++++++--- src/components/Field.tsx | 70 +++++++++++ src/components/NewCommentForm.tsx | 191 ++++++++++++++++++++---------- src/components/PostDetails.tsx | 188 ++++++++++++++++------------- src/components/PostsList.tsx | 147 ++++++++++------------- src/components/UserSelector.tsx | 81 +++++++++---- src/services/comments.ts | 10 ++ src/services/posts.ts | 5 + src/services/user.ts | 4 + 9 files changed, 527 insertions(+), 272 deletions(-) create mode 100644 src/components/Field.tsx create mode 100644 src/services/comments.ts create mode 100644 src/services/posts.ts create mode 100644 src/services/user.ts diff --git a/src/App.tsx b/src/App.tsx index cad8e12c6..c5ac00b9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,58 @@ -import React from 'react'; +/* eslint-disable @typescript-eslint/indent */ +import { FC, useEffect, useMemo, useState } from 'react'; import 'bulma/bulma.sass'; import '@fortawesome/fontawesome-free/css/all.css'; import './App.scss'; -import classNames from 'classnames'; 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 { getUsers } from './services/user'; +import { Post } from './types/Post'; +import { getPostsByUserId } from './services/posts'; +import classNames from 'classnames'; + +export const App: FC = () => { + const [users, setUsers] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [userPosts, setUserPosts] = useState([]); + const [selectedPostId, setSelectedPostId] = useState(null); + const [postError, setPostError] = useState(false); + const [isLoadingPosts, setIsLoadingPosts] = useState(false); + + const selectedPost = useMemo( + () => userPosts.find(post => post.id === selectedPostId), + [selectedPostId, userPosts], + ); + + const setSelectUser = (user: User) => { + setSelectedUser(user); + setUserPosts([]); + }; + + const setSelectPostId = (id: number | null) => { + setSelectedPostId(id); + }; + + useEffect(() => { + if (selectedUser) { + setIsLoadingPosts(true); + setPostError(false); + getPostsByUserId(selectedUser.id) + .then(setUserPosts) + .catch(() => setPostError(true)) + .finally(() => setIsLoadingPosts(false)); + } else { + setUserPosts([]); + } + }, [selectedUser]); + + useEffect(() => { + getUsers().then(setUsers); + }, []); -export const App: React.FC = () => { return (
@@ -17,26 +60,48 @@ export const App: React.FC = () => {
- +
-

No user selected

+ {!selectedUser && ( +

No user selected

+ )} - + {isLoadingPosts && } -
- Something went wrong! -
+ {userPosts.length > 0 && !isLoadingPosts && ( + + )} -
- No posts yet -
+ {!isLoadingPosts && + userPosts.length === 0 && + !postError && + selectedUser && ( +
+ No posts yet +
+ )} - + {postError && ( +
+ Something went wrong! +
+ )}
@@ -48,11 +113,11 @@ export const App: React.FC = () => { 'is-parent', 'is-8-desktop', 'Sidebar', - 'Sidebar--open', + { 'Sidebar--open': selectedPost !== undefined }, )} > -
- +
+ {selectedPost && }
diff --git a/src/components/Field.tsx b/src/components/Field.tsx new file mode 100644 index 000000000..94a6f7c2e --- /dev/null +++ b/src/components/Field.tsx @@ -0,0 +1,70 @@ +import classNames from 'classnames'; +import { ChangeEvent } from 'react'; + +interface Props { + id: string; + label: string; + type?: string; + placeholder: string; + value: string; + onChange: (e: ChangeEvent) => void; + lIcon: string; + error: string | undefined; + dataCy: string; + name: string; +} +export const Field = (props: Props) => { + const { + id, + label, + type, + placeholder, + value, + onChange, + lIcon, + error, + dataCy, + name, + } = props; + + return ( +
+ + +
+ + + + + + + {error && ( + + + + )} +
+ + {error && ( +

+ Name is required +

+ )} +
+ ); +}; diff --git a/src/components/NewCommentForm.tsx b/src/components/NewCommentForm.tsx index 73a8a0b45..a89a4fb60 100644 --- a/src/components/NewCommentForm.tsx +++ b/src/components/NewCommentForm.tsx @@ -1,69 +1,129 @@ -import React from 'react'; +import { ChangeEvent, FormEvent, useState } from 'react'; +import { CommentData } from '../types/Comment'; +import { Field } from './Field'; +import classNames from 'classnames'; -export const NewCommentForm: React.FC = () => { - return ( -
-
- +const defaultValues: CommentData = { + body: '', + email: '', + name: '', +}; -
- +type FormValues = typeof defaultValues; - - - +type FormErrors = Partial>; - - - -
+function validate({ body, email, name }: FormValues): FormErrors { + const errors: FormErrors = {}; -

- Name is required -

-
+ if (name.length === 0) { + errors.name = 'Name is required'; + } -
- + if (email.length === 0) { + errors.email = 'Email is required'; + } -
- + if (body.length === 0) { + errors.body = 'Enter some text'; + } - - - + return errors; +} - - - -
+interface Props { + addComment: (comment: CommentData) => Promise; +} -

- Email is required -

-
+export const NewCommentForm = ({ addComment }: Props) => { + const [values, setValues] = useState(defaultValues); + const [errors, setErrors] = useState({}); + const [isLoading, setIsLoading] = useState(false); + // const [isFormValid, setIsFormValid] = useState(false); + + const [count, setCount] = useState(0); + + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + const newErrors = validate(values); + + setErrors(newErrors); + + if (Object.values(newErrors).length > 0) { + setIsLoading(false); + + return; + } + + const newComment: CommentData = { + ...values, + }; + + addComment({ ...newComment }) + .then(() => { + setValues({ name: values.name, email: values.email, body: '' }); + setCount(prev => prev + 1); + }) + .finally(() => setIsLoading(false)); + }; + + const onReset = () => { + setValues(defaultValues); + setErrors({}); + }; + + function handleChange( + e: ChangeEvent | ChangeEvent, + ) { + const { name, value } = e.target; + + setValues(currentValues => ({ + ...currentValues, + [name]: value, + })); + + setErrors(currentErrors => { + const copy = { ...currentErrors }; + + delete copy[name as keyof FormValues]; + + return copy; + }); + } + + return ( + + +
-

- Enter some text -

+ {errors.body && ( +

+ Enter some text +

+ )}
-
diff --git a/src/components/PostDetails.tsx b/src/components/PostDetails.tsx index 2f82db916..c8ea0db7d 100644 --- a/src/components/PostDetails.tsx +++ b/src/components/PostDetails.tsx @@ -1,107 +1,125 @@ -import React from 'react'; +/* eslint-disable react/display-name */ +import { memo, useCallback, useEffect, useState } from 'react'; import { Loader } from './Loader'; import { NewCommentForm } from './NewCommentForm'; +import { Post } from '../types/Post'; +import { + getCommentsByPostId, + postComment, + removeComment, +} from '../services/comments'; +import { Comment, CommentData } from '../types/Comment'; -export const PostDetails: React.FC = () => { - return ( -
-
-
-

- #18: voluptate et itaque vero tempora molestiae -

+interface Props { + post: Post; +} -

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

-
+export const PostDetails = memo((props: Props) => { + const { post } = props; + const { id: postId, body, title } = post; + const [comments, setComments] = useState([]); + const [isCommentsLoading, setIsCommentsLoading] = useState(false); + const [errorComment, setErrorComment] = useState(false); + const [isAddFormActive, setIsAddFormActive] = useState(false); -
- + const addComment = useCallback( + async (data: CommentData) => { + return postComment({ ...data, postId }).then(comment => + setComments(prev => [...prev, comment]), + ); + }, + [postId], + ); -
- Something went wrong -
+ const onDeleteComment = (id: number) => { + removeComment(id); + setComments(prev => prev.filter(item => item.id !== id)); + }; -

- No comments yet -

+ useEffect(() => { + setComments([]); + setIsAddFormActive(false); + }, [postId]); -

Comments:

+ useEffect(() => { + setErrorComment(false); + setIsCommentsLoading(true); + getCommentsByPostId(postId) + .then(setComments) + .catch(() => setErrorComment(true)) + .finally(() => setIsCommentsLoading(false)); + }, [postId]); -
-
- - Misha Hrynko - - -
+ return ( +
+
+
+

{`#${postId}: ${title}`}

-
- Some comment -
-
+

{body}

+
-
-
- - Misha Hrynko - +
+ {isCommentsLoading && } - -
-
- One more comment + {errorComment && ( +
+ Something went wrong
-
+ )} -
-
- - Misha Hrynko - + {comments.length > 0 && !isCommentsLoading && ( + <> +

Comments:

+ {comments.map(comment => ( +
+
+ + {comment.name} + + +
- -
+
+ {comment.body} +
+
+ ))} + + )} -
- {'Multi\nline\ncomment'} -
- + {comments.length === 0 && !isCommentsLoading && !errorComment && ( +

+ No comments yet +

+ )} - + {!isCommentsLoading && !isAddFormActive && !errorComment && ( + + )}
- + {isAddFormActive && }
); -}; +}); diff --git a/src/components/PostsList.tsx b/src/components/PostsList.tsx index cf90f04b0..3ec283864 100644 --- a/src/components/PostsList.tsx +++ b/src/components/PostsList.tsx @@ -1,86 +1,61 @@ -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 - -
-
-); +/* eslint-disable react/display-name */ +import { memo } from 'react'; +import { Post } from '../types/Post'; +import classNames from 'classnames'; + +interface Props { + userPosts: Post[]; + selectedPostId: number | null; + setSelectPostId: (id: number | null) => void; +} + +export const PostsList = memo((props: Props) => { + const { userPosts, setSelectPostId, selectedPostId } = props; + + const selectPostHandler = (id: number) => { + if (id === selectedPostId) { + setSelectPostId(null); + } else { + setSelectPostId(id); + } + }; + + return ( +
+

Posts:

+ + + + + + {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} + + + + + + {userPosts.map(post => ( + + + + + + + + ))} + +
#Title
{post.id}{post.title} + +
+
+ ); +}); diff --git a/src/components/UserSelector.tsx b/src/components/UserSelector.tsx index c89442841..46a0b6da2 100644 --- a/src/components/UserSelector.tsx +++ b/src/components/UserSelector.tsx @@ -1,16 +1,58 @@ -import React from 'react'; +/* eslint-disable react/display-name */ +import { memo, useEffect, useState } from 'react'; +import { User } from '../types/User'; +import classNames from 'classnames'; + +interface Props { + users: User[]; + selectedUser: User | null; + setSelectUser: (user: User) => void; +} + +export const UserSelector = memo((props: Props) => { + const { users, selectedUser, setSelectUser } = props; + + const [selectValue, setSelectValue] = useState('Choose a user'); + const [isSelectActive, setIsSelectActive] = useState(false); + + const clickSelectHandler = (user: User) => { + setSelectUser(user); + setSelectValue(user.name); + setIsSelectActive(false); + }; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + + if (target.closest('.dropdown') === null) { + setIsSelectActive(false); + } + }; + + useEffect(() => { + document.addEventListener('click', handleClickOutside); + + return () => { + document.removeEventListener('click', handleClickOutside); + }; + }, []); -export const UserSelector: React.FC = () => { return ( -
-
+
+
setIsSelectActive(prev => !prev)} + >