diff --git a/src/App.scss b/src/App.scss index 695435da4..4002a0577 100644 --- a/src/App.scss +++ b/src/App.scss @@ -18,6 +18,35 @@ } } +.tile { + align-items: stretch; + min-height: min-content; + flex: 1 1 0; + + &.is-ancestor { + margin-left: -0.75rem; + margin-right: -0.75rem; + margin-top: -0.75rem; + + &:last-child { + margin-bottom: -0.75rem; + } + } + + @media (min-width: 769px) { + &:not(.is-child) { + display: flex; + } + } +} + .message-body { white-space: pre-line; } + +.container { + flex-grow: 1; + margin: 0 auto; + position: relative; + width: auto; +} diff --git a/src/App.tsx b/src/App.tsx index 017957182..b064ebfa2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,60 +1,103 @@ -import classNames from 'classnames'; - +/* eslint-disable @typescript-eslint/indent */ import 'bulma/css/bulma.css'; import '@fortawesome/fontawesome-free/css/all.css'; +import cn from 'classnames'; 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 = () => ( -
-
-
-
-
-
- -
+import { PostsList, PostDetails, UserSelector, Loader } from './components'; +import { User } from './types'; +import { usePosts, useUsers } from './hooks'; -
-

No user selected

+export const App = () => { + const { + users, + errorMessage: usersErrorMessage, + selectedUser, + setSelectedUser, + } = useUsers(); + const { + posts, + selectedPost, + setSelectedPost, + isLoading, + errorMessage: postsErrorMessage, + } = usePosts(selectedUser?.id); - + const errorMessage = usersErrorMessage || postsErrorMessage; -
- Something went wrong! -
+ const handleSelectUserId = (user: User) => { + setSelectedUser(user); + setSelectedPost(null); + }; -
- No posts yet + return ( +
+
+
+
+
+
+
- +
+ {!selectedUser && ( +

No user selected

+ )} + + {selectedUser && isLoading && } + + {selectedUser && !isLoading && errorMessage && ( +
+ {errorMessage} +
+ )} + + {selectedUser && + !isLoading && + !errorMessage && + posts?.length === 0 && ( +
+ No posts yet +
+ )} + + {selectedUser && + !isLoading && + !errorMessage && + posts?.length !== 0 && ( + + )} +
-
-
-
- +
+
+ {selectedPost && } +
-
-
-); +
+ ); +}; diff --git a/src/api/comments.ts b/src/api/comments.ts new file mode 100644 index 000000000..87d53b917 --- /dev/null +++ b/src/api/comments.ts @@ -0,0 +1,14 @@ +import { Comment } from '../types/Comment'; +import { client } from '../utils/fetchClient'; + +export const getComments = (postId: number) => { + return client.get(`/comments?postId=${postId}`); +}; + +export const deleteComment = (commentId: number) => { + return client.delete(`/comments/${commentId}`); +}; + +export const addComment = (newComment: Omit) => { + return client.post('/comments', newComment); +}; diff --git a/src/api/posts.ts b/src/api/posts.ts new file mode 100644 index 000000000..db1ecb6c5 --- /dev/null +++ b/src/api/posts.ts @@ -0,0 +1,6 @@ +import { Post } from '../types/Post'; +import { client } from '../utils/fetchClient'; + +export const getUserPosts = (selectedUserId: number) => { + return client.get(`/posts?userId=${selectedUserId}`); +}; diff --git a/src/api/users.ts b/src/api/users.ts new file mode 100644 index 000000000..816c8274b --- /dev/null +++ b/src/api/users.ts @@ -0,0 +1,6 @@ +import { User } from '../types/User'; +import { client } from '../utils/fetchClient'; + +export const getUsers = () => { + return client.get('/users'); +}; diff --git a/src/components/CommentItem.tsx b/src/components/CommentItem.tsx new file mode 100644 index 000000000..5d601542e --- /dev/null +++ b/src/components/CommentItem.tsx @@ -0,0 +1,33 @@ +import { FC } from 'react'; + +import { Comment } from '../types'; + +type Props = { + comment: Comment; + onDelete: (commentId: number) => Promise; +}; + +export const CommentItem: FC = ({ comment, onDelete }) => { + const { id, name, email, body } = comment; + + return ( + + ); +}; diff --git a/src/components/NewCommentForm.tsx b/src/components/NewCommentForm.tsx index 73a8a0b45..44afb5b51 100644 --- a/src/components/NewCommentForm.tsx +++ b/src/components/NewCommentForm.tsx @@ -1,8 +1,85 @@ -import React from 'react'; +import React, { FormEvent, useState } from 'react'; +import cn from 'classnames'; + +import { Comment } from '../types'; + +type Props = { + selectedPostId: number; + onAddComment: (newComment: Omit) => Promise; +}; + +export const NewCommentForm: React.FC = ({ + selectedPostId, + onAddComment, +}) => { + const [name, setName] = useState(''); + const [hasName, setHasName] = useState(true); + const [email, setEmail] = useState(''); + const [hasEmail, setHasEmail] = useState(true); + const [commentBody, setCommentBody] = useState(''); + const [hasCommentBody, setHasCommentBody] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + if (!name) { + setHasName(false); + } + + if (!email) { + setHasEmail(false); + } + + if (!commentBody) { + setHasCommentBody(false); + } + + if (!name || !email || !commentBody) { + return; + } + + setIsLoading(true); + + const newComment = { + postId: selectedPostId, + name: name.trim(), + email: email.trim(), + body: commentBody.trim(), + }; + + onAddComment(newComment) + .then(() => setCommentBody('')) + .finally(() => setIsLoading(false)); + }; + + const handleChangeName = (authorName: string) => { + setName(authorName); + setHasName(true); + }; + + const handleChangeEmail = (authorEmail: string) => { + setEmail(authorEmail); + setHasEmail(true); + }; + + const handleChangeComment = (comment: string) => { + setCommentBody(comment); + setHasCommentBody(true); + }; + + const handleReset = () => { + setName(''); + setEmail(''); + setCommentBody(''); + + setHasName(true); + setHasEmail(true); + setHasCommentBody(true); + }; -export const NewCommentForm: React.FC = () => { return ( -
+
-

- Name is required -

+ {!hasName && ( +

+ Name is required +

+ )}
@@ -45,24 +128,32 @@ export const NewCommentForm: React.FC = () => { name="email" id="comment-author-email" placeholder="email@test.com" - className="input is-danger" + className={cn('input', { 'is-danger': !hasEmail })} + value={email} + onChange={event => + handleChangeEmail(event.target.value.trimStart()) + } /> - - - + {!hasEmail && ( + + + + )}
-

- Email is required -

+ {!hasEmail && ( +

+ Email is required +

+ )}
@@ -75,25 +166,38 @@ export const NewCommentForm: React.FC = () => { id="comment-body" name="body" placeholder="Type comment here" - className="textarea is-danger" + className={cn('textarea', { 'is-danger': !hasCommentBody })} + value={commentBody} + onChange={event => + handleChangeComment(event.target.value.trimStart()) + } />
-

- Enter some text -

+ {!hasCommentBody && ( +

+ 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..70b8c0d0e 100644 --- a/src/components/PostDetails.tsx +++ b/src/components/PostDetails.tsx @@ -1,107 +1,75 @@ -import React from 'react'; -import { Loader } from './Loader'; -import { NewCommentForm } from './NewCommentForm'; +import React, { useEffect, useState } from 'react'; -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 -

-
- -
- +import { Loader, CommentItem, NewCommentForm } from './index'; +import { Post } from '../types'; +import { useComments } from '../hooks/useComments'; -
- Something went wrong -
+type Props = { + selectedPost: Post; +}; -

- No comments yet -

+export const PostDetails: React.FC = ({ selectedPost }) => { + const { id, title, body } = selectedPost; -

Comments:

+ const [isFormOpened, setIsFormOpened] = useState(false); + const { comments, errorMessage, isLoading, deleteComment, addComment } = + useComments(id); -
-
- - Misha Hrynko - - -
+ useEffect(() => setIsFormOpened(false), [selectedPost]); -
- Some comment -
-
+ return ( +
+
+

{`#${id}: ${title}`}

-
-
- One more comment -
- +
+ {isLoading && } -
-
- - Misha Hrynko - + {!isLoading && errorMessage && ( +
+ {errorMessage} +
+ )} - -
+ {!isLoading && !errorMessage && !comments.length && ( + <> +

+ No comments yet +

+ + )} -
- {'Multi\nline\ncomment'} -
-
+ {!isLoading && !errorMessage && !!comments.length && ( + <> +

Comments:

+ {comments.map(comment => ( + + ))} + + )} +
- -
+ {!isLoading && !errorMessage && !isFormOpened && ( + + )} - -
+ {!isLoading && !errorMessage && isFormOpened && ( + + )}
); }; diff --git a/src/components/PostItem.tsx b/src/components/PostItem.tsx new file mode 100644 index 000000000..3ee91a72e --- /dev/null +++ b/src/components/PostItem.tsx @@ -0,0 +1,43 @@ +import { Dispatch, FC, SetStateAction } from 'react'; +import cn from 'classnames'; + +import { Post } from '../types'; + +type Props = { + post: Post; + selectedPost: Post | null; + onSelectPost: Dispatch>; +}; + +export const PostItem: FC = ({ post, selectedPost, onSelectPost }) => { + const { id, title } = post; + + const isSelected = id === selectedPost?.id; + + const handleSelectPost = () => { + if (selectedPost?.id === id) { + onSelectPost(null); + } else { + onSelectPost(post); + } + }; + + return ( + + {id} + + {title} + + + + + + ); +}; diff --git a/src/components/PostsList.tsx b/src/components/PostsList.tsx index cf90f04b0..9c22e8b34 100644 --- a/src/components/PostsList.tsx +++ b/src/components/PostsList.tsx @@ -1,6 +1,19 @@ -import React from 'react'; - -export const PostsList: React.FC = () => ( +import React, { Dispatch, SetStateAction } from 'react'; + +import { Post } from '../types'; +import { PostItem } from './index'; + +type Props = { + posts: Post[] | null; + selectedPost: Post | null; + onSelectPost: Dispatch>; +}; + +export const PostsList: React.FC = ({ + posts, + selectedPost, + onSelectPost, +}) => (

Posts:

@@ -15,71 +28,14 @@ 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 - - - - - + {posts?.map(post => ( + + ))}
diff --git a/src/components/UserSelector.tsx b/src/components/UserSelector.tsx index c89442841..995591801 100644 --- a/src/components/UserSelector.tsx +++ b/src/components/UserSelector.tsx @@ -1,16 +1,36 @@ -import React from 'react'; +import React, { useState } from 'react'; +import cn from 'classnames'; + +import { User } from '../types'; + +type Props = { + users: User[]; + selectedUser: User | null; + onSelectUser: (user: User) => void; +}; + +export const UserSelector: React.FC = ({ + users, + selectedUser, + onSelectUser, +}) => { + const [isOpened, setIsOpened] = useState(false); -export const UserSelector: React.FC = () => { return ( -
+