diff --git a/cypress/integration/page.spec.js b/cypress/integration/page.spec.js index d85f6bb7d..9e321b5c4 100644 --- a/cypress/integration/page.spec.js +++ b/cypress/integration/page.spec.js @@ -171,7 +171,7 @@ Cypress.on('fail', (e) => { describe('', () => { beforeEach(() => { - if (failed) Cypress.runner.stop(); + // if (failed) Cypress.runner.stop(); }); describe('Page by default', () => { diff --git a/src/App.tsx b/src/App.tsx index db56b44b0..080d3f0df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,101 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import 'bulma/bulma.sass'; import '@fortawesome/fontawesome-free/css/all.css'; import './App.scss'; +import cn from 'classnames'; -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 * as fetchUsers from './api/users'; +import * as fetchPosts from './api/posts'; +import * as fetchComments from './api/comments'; +import { User } from './types/User'; +import { Post } from './types/Post'; +import { Comment } from './types/Comment'; +import { client } from './utils/fetchClient'; export const App: React.FC = () => { + const [users, setUsers] = useState([]); + const [posts, setPosts] = useState([]); + const [comments, setComments] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedPost, setSelectedPost] = useState(null); + const [isLoadingPosts, setIsLoadingPosts] = useState(false); + const [isLoadingComments, setIsLoadingComments] = useState(false); + const [isShowingComments, setIsShowingComments] = useState(false); + const [isShowingForm, setIsShowingForm] = useState(false); + const [errorPosts, setErrorPosts] = useState(false); + const [errorComments, setErrorComments] = useState(false); + + useEffect(() => { + fetchUsers.getUsers('/users') + .then(res => setUsers(res)); + }, []); + + useEffect(() => { + if (selectedUser) { + setIsLoadingPosts(true); + setErrorPosts(false); + + fetchPosts.getPosts(`/posts?userId=${selectedUser.id}`) + .then(res => setPosts(res)) + .catch(() => setErrorPosts(true)) + .finally(() => setIsLoadingPosts(false)); + } + }, [selectedUser]); + + const showPostDetails = (post: Post) => { + setIsLoadingComments(true); + if (post.id === selectedPost?.id) { + setIsShowingComments(!isShowingComments); + setSelectedPost(null); + } + + if (!selectedPost?.id || selectedPost?.id !== post.id) { + setIsShowingComments(true); + setSelectedPost(post); + setErrorComments(false); + + fetchComments.getComments(`/comments?postId=${post.id}`) + .then(res => { + setComments(res); + setIsLoadingComments(false); + }) + .catch(() => setErrorComments(true)); + } + + setIsShowingForm(false); + }; + + const updateSelectedUser = (user: User | null) => { + setSelectedUser(user); + setIsShowingComments(false); + setSelectedPost(null); + }; + + const handleDeleteComment = (commentId: number) => { + const showingComments = comments + .filter(comment => comment.id !== commentId); + + setComments(showingComments); + + client.delete(`/comments/${commentId}`); + }; + + const createNewComment = (newComment: Comment) => { + setComments([...comments, newComment]); + }; + + const handleChangeErrorState = (el: boolean) => { + setErrorComments(el); + }; + + const isShowingLoader = !errorPosts && isLoadingPosts; + const isShowingNoUserSelect = !errorPosts && !selectedUser && !isLoadingPosts; + const isShowingPosts = !isLoadingPosts && selectedUser && !errorPosts; + return (
@@ -17,45 +103,80 @@ export const App: React.FC = () => {
- +
-

- No user selected -

+ {isShowingLoader + && } - + {isShowingNoUserSelect && ( +

+ No user selected +

+ )} -
- Something went wrong! -
+ {isShowingPosts && posts.length > 0 + && ( + + )} -
- No posts yet -
+ {isShowingPosts && !posts.length + && ( +
+ No posts yet +
+ )} - + {errorPosts && ( +
+ Something went wrong! +
+ )}
-
- -
+ {selectedPost && ( +
+ +
+ )}
diff --git a/src/api/comments.ts b/src/api/comments.ts new file mode 100644 index 000000000..7c4691ab4 --- /dev/null +++ b/src/api/comments.ts @@ -0,0 +1,14 @@ +import { Comment } from '../types/Comment'; +import { client } from '../utils/fetchClient'; + +export const getComments = (url: string) => { + return client.get(url); +}; + +export const addComment = (url: string, newComment: Comment) => { + return client.post(url, newComment); +}; + +export const deleteComment = (url: string) => { + return client.delete(url); +}; diff --git a/src/api/posts.ts b/src/api/posts.ts new file mode 100644 index 000000000..9f911316f --- /dev/null +++ b/src/api/posts.ts @@ -0,0 +1,6 @@ +import { Post } from '../types/Post'; +import { client } from '../utils/fetchClient'; + +export const getPosts = (url: string) => { + return client.get(url); +}; diff --git a/src/api/users.ts b/src/api/users.ts new file mode 100644 index 000000000..8210b7fa5 --- /dev/null +++ b/src/api/users.ts @@ -0,0 +1,6 @@ +import { User } from '../types/User'; +import { client } from '../utils/fetchClient'; + +export const getUsers = (url: string) => { + return client.get(url); +}; diff --git a/src/components/NewCommentForm.tsx b/src/components/NewCommentForm.tsx index 73a8a0b45..b58d50492 100644 --- a/src/components/NewCommentForm.tsx +++ b/src/components/NewCommentForm.tsx @@ -1,8 +1,62 @@ -import React from 'react'; +import React, { useState } from 'react'; +import cn from 'classnames'; + +import { Comment } from '../types/Comment'; +import { addComment } from '../api/comments'; + +interface Props { + postId: number, + addNewComment: (el: Comment) => void, + changeErrorState: (el: boolean) => void, +} + +export const NewCommentForm: React.FC = ({ + postId, + addNewComment, + changeErrorState, +}) => { + const [inputName, setInputName] = useState(''); + const [inputMail, setInputMail] = useState(''); + const [textarea, setTextarea] = useState(''); + const [submitAttempted, setSubmitAttempted] = useState(false); + const [isAdding, setIsAdding] = useState(false); + + const handleReset = () => { + setInputName(''); + setInputMail(''); + setTextarea(''); + setSubmitAttempted(false); + }; + + const handleSumbit = (event: React.FormEvent) => { + event.preventDefault(); + changeErrorState(false); + const newComment = { + id: 0, + postId, + name: inputName, + email: inputMail, + body: textarea, + }; + + if (inputName.trim() && inputMail.trim() && textarea.trim()) { + setIsAdding(true); + addComment('/comments', newComment) + .then(() => { + setTextarea(''); + addNewComment(newComment); + setIsAdding(false); + }) + .catch(() => changeErrorState(true)); + } + + if (!inputName.trim() || !inputMail.trim() || !textarea.trim()) { + setSubmitAttempted(true); + } + }; -export const NewCommentForm: React.FC = () => { return ( -
+
-

- Name is required -

+ {!inputName && submitAttempted && ( +

+ Name is required +

+ )}
@@ -41,28 +104,37 @@ export const NewCommentForm: React.FC = () => {
setInputMail(e.target.value)} /> - - - + {!inputMail && submitAttempted && ( + + + + )}
-

- Email is required -

+ {!inputMail && submitAttempted && ( +

+ Email is required +

+ )}
@@ -75,25 +147,39 @@ export const NewCommentForm: React.FC = () => { id="comment-body" name="body" placeholder="Type comment here" - className="textarea is-danger" + className={cn( + 'textarea', + { 'is-danger': !textarea && submitAttempted }, + )} + value={textarea} + onChange={e => setTextarea(e.target.value)} />
-

- Enter some text -

+ {!textarea && submitAttempted && ( +

+ Enter some text +

+ )}
-
{/* eslint-disable-next-line react/button-has-type */} -
diff --git a/src/components/Post.tsx b/src/components/Post.tsx new file mode 100644 index 000000000..69352a1fb --- /dev/null +++ b/src/components/Post.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import cn from 'classnames'; +import { Post } from '../types/Post'; + +interface Props { + id: number, + title: string, + userId: number, + body: string, + openPostId?: number, + onClick: (el: Post) => void, +} + +export const PostItem: React.FC = ({ + id, + title, + userId, + body, + openPostId, + onClick, +}) => ( + + {id} + + + {title} + + + + + + +); diff --git a/src/components/PostDetails.tsx b/src/components/PostDetails.tsx index ace945f0a..f74b277ec 100644 --- a/src/components/PostDetails.tsx +++ b/src/components/PostDetails.tsx @@ -1,117 +1,122 @@ import React from 'react'; import { Loader } from './Loader'; import { NewCommentForm } from './NewCommentForm'; +import { Comment } from '../types/Comment'; +import { Post } from '../types/Post'; + +interface Props { + comments: Comment[], + selectedPost: Post, + isLoadingComments: boolean, + openPostId: number, + addComment: (el: Comment) => void, + isShowingForm: boolean, + changeIsShowingForm: (el: boolean) => void, + onDelete: (id: number) => void, + error: boolean, + changeErrorState: (el: boolean) => void +} + +export const PostDetails: React.FC = ({ + comments, + selectedPost, + isLoadingComments, + openPostId, + addComment, + isShowingForm, + changeIsShowingForm, + onDelete, + error, + changeErrorState, +}) => { + const isIdle = !error && !isLoadingComments; + const hasComments = isIdle && Boolean(comments.length) && comments; + const hasNoComments = isIdle && !comments.length && comments; -export const PostDetails: React.FC = () => { 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:

+ {!error && isLoadingComments && ( + )} -
-
- - Misha Hrynko - - + {error && ( +
+ Something went wrong
+ )} -
- Some comment -
-
+ {hasNoComments && ( +

+ No comments yet +

+ )} -
-
- - Misha Hrynko - + {hasComments && ( +

Comments:

+ )} - -
-
- One more comment -
-
+
+ + {name} + + +
- + ))} - -
- -
- {'Multi\nline\ncomment'} -
- - - + {isIdle + && (isShowingForm + ? ( + + ) + : ( + + ))}
- -
); diff --git a/src/components/PostsList.tsx b/src/components/PostsList.tsx index cf90f04b0..391060808 100644 --- a/src/components/PostsList.tsx +++ b/src/components/PostsList.tsx @@ -1,86 +1,49 @@ 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 { Post } from '../types/Post'; +import { PostItem } from './Post'; + +interface Props { + posts: Post[], + showPostDetails: (post: Post) => void, + openPostId?: number, +} + +export const PostsList: React.FC = ({ + posts, + showPostDetails, + openPostId, +}) => { + return ( +
+

Posts:

+ + + + + + + {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} + + + + + + {posts.map(({ + id, userId, title, body, + }) => ( + + ))} + +
#Title
+
+ ); +}; diff --git a/src/components/User.tsx b/src/components/User.tsx new file mode 100644 index 000000000..d6feb79bd --- /dev/null +++ b/src/components/User.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import cn from 'classnames'; + +import { User } from '../types/User'; + +interface Props { + id: number, + name: string, + email: string, + phone: string, + selectedUserId?: number, + onClick: (el: User) => void, +} + +export const UserLink: React.FC = ({ + id, + name, + email, + phone, + selectedUserId, + onClick, +}) => ( + onClick({ + id, name, email, phone, + })} + > + {name} + +); diff --git a/src/components/UserSelector.tsx b/src/components/UserSelector.tsx index cb83a8f68..88f065361 100644 --- a/src/components/UserSelector.tsx +++ b/src/components/UserSelector.tsx @@ -1,10 +1,50 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; + +import { User } from '../types/User'; +import { UserLink } from './User'; + +interface Props { + users: User[], + selectedUser: User | null, + updateSelectedUser: (el: User | null) => void, +} + +export const UserSelector: React.FC = ({ + users, + selectedUser, + updateSelectedUser, +}) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const dropdownMenuRef = useRef(null); + + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + + if (!dropdownMenuRef.current?.contains(target)) { + setIsMenuOpen(false); + } + }; + + document.addEventListener('click', handleOutsideClick); + + return () => { + document.removeEventListener('click', handleOutsideClick); + }; + }, []); + + const updateUserAndPosts = (currUser: User) => { + updateSelectedUser(currUser); + setIsMenuOpen(false); + }; -export const UserSelector: React.FC = () => { return (