diff --git a/src/App.tsx b/src/App.tsx index cad8e12c6..7087e19f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,60 +3,30 @@ 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 { SharedProvider } from './SharedContext'; +import { Sidebar } from './components/Sidebar'; +import { MainContent } from './components/MainContent'; export const App: React.FC = () => { return ( -
-
-
-
-
-
- -
- -
-

No user selected

- - - -
- Something went wrong! + +
+
+
+
+
+
+
- -
- No posts yet -
- - +
-
-
-
- -
+
-
-
+ + ); }; diff --git a/src/SharedContext.tsx b/src/SharedContext.tsx new file mode 100644 index 000000000..269728484 --- /dev/null +++ b/src/SharedContext.tsx @@ -0,0 +1,208 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { SharedContextValue } from './types/ContextValues'; +import { getUsers } from './api/users'; +import { User } from './types/User'; +import { Post } from './types/Post'; +import { getPosts } from './api/posts'; +import { Comment, CommentData } from './types/Comment'; +import { createComment, deleteComment, getComments } from './api/comments'; + +export const SharedContext = createContext(null); + +type Props = { + children: React.ReactNode; +}; + +export const SharedProvider: React.FC = ({ children }) => { + const [users, setUsers] = useState([]); + const [posts, setPosts] = useState([]); + const [comments, setComments] = useState([]); + const [selectedUserId, setSelectedUserId] = useState(null); + const [selectedPostId, setSelectedPostId] = useState(null); + const [isLoadingPosts, setIsLoadingPosts] = useState(false); + const [isLoadingComments, setIsLoadingComments] = useState(false); + const [isSumbitting, setIsSumbitting] = useState(false); + const [isError, setIsError] = useState(false); + + const handleCreateComment = useCallback( + async ({ name, email, body }: Omit) => { + setIsSumbitting(true); + + const trimmedName = name.trim(); + const trimmedEmail = email.trim(); + const trimmedBody = body.trim(); + + if (trimmedName && trimmedEmail && trimmedBody && selectedPostId) { + try { + const newComment = await createComment({ + postId: selectedPostId, + name: trimmedName, + email: trimmedEmail, + body: trimmedBody, + }); + + setComments(prevComments => [...prevComments, newComment]); + } catch { + setIsError(true); + } finally { + setIsSumbitting(false); + } + } + }, + [selectedPostId, setIsError], + ); + + const handleDeleteComment = useCallback( + async (commentId: number) => { + try { + await deleteComment(commentId); + + setComments(currentComments => + currentComments.filter(comm => comm.id !== commentId), + ); + } catch { + setIsError(true); + } + }, + [setIsError], + ); + + const handleSelectUser = useCallback( + async (userId: number) => { + setSelectedUserId(userId); + setIsLoadingPosts(true); + + try { + const loadedPosts = await getPosts(userId); + + setPosts(loadedPosts); + } catch { + setIsError(true); + } finally { + setIsLoadingPosts(false); + } + }, + [setIsError], + ); + + const handleSelectPost = useCallback( + async (postId: number) => { + setSelectedPostId(postId); + + try { + const loadedUsers = await getUsers(); + + setUsers(loadedUsers); + } catch { + setIsError(true); + } + }, + [setIsError], + ); + + const handleLoadComments = useCallback( + async (postId: number) => { + setIsLoadingComments(true); + // setIsError(false); + + try { + const loadedComments = await getComments(postId); + + setComments(loadedComments); + } catch { + setIsError(true); + } finally { + setIsLoadingComments(false); + } + }, + [setIsError], + ); + + const handleClosePostDetails = useCallback(() => { + setSelectedPostId(null); + }, []); + + const selectedUser = useMemo( + () => users?.find(user => user.id === selectedUserId) || null, + [users, selectedUserId], + ); + + const selectedPost = useMemo( + () => posts?.find(post => post.id === selectedPostId) || null, + [posts, selectedPostId], + ); + + const sharedValue = useMemo( + () => ({ + users, + posts, + comments, + isError, + selectedUser, + selectedPost, + isLoadingPosts, + isLoadingComments, + isSumbitting, + handleCreateComment, + handleDeleteComment, + handleLoadComments, + handleSelectUser, + handleSelectPost, + handleClosePostDetails, + }), + [ + users, + posts, + comments, + isError, + selectedUser, + selectedPost, + isLoadingPosts, + isLoadingComments, + isSumbitting, + handleCreateComment, + handleDeleteComment, + handleLoadComments, + handleSelectUser, + handleSelectPost, + handleClosePostDetails, + ], + ); + + useEffect(() => { + const fetchUsers = async () => { + try { + const loadedUsers = await getUsers(); + + setUsers(loadedUsers); + } catch { + setIsError(true); + } + }; + + fetchUsers(); + }, []); + + return ( + + {children} + + ); +}; + +export const useValues = () => { + const value = useContext(SharedContext); + + if (!value) { + throw new Error('Something is wrong with provider SharedContext'); + } + + return value; +}; diff --git a/src/api/comments.ts b/src/api/comments.ts new file mode 100644 index 000000000..824910a7d --- /dev/null +++ b/src/api/comments.ts @@ -0,0 +1,14 @@ +import { Comment, CommentData } from '../types/Comment'; +import { client } from '../utils/fetchClient'; + +export const getComments = (postId: number) => { + return client.get(`/comments?postId=${postId}`); +}; + +export const createComment = (comment: CommentData) => { + return client.post(`/comments`, comment); +}; + +export const deleteComment = (commentId: number) => { + return client.delete(`/comments/${commentId}`); +}; diff --git a/src/api/posts.ts b/src/api/posts.ts new file mode 100644 index 000000000..a6a1739ec --- /dev/null +++ b/src/api/posts.ts @@ -0,0 +1,6 @@ +import { Post } from '../types/Post'; +import { client } from '../utils/fetchClient'; + +export const getPosts = (userId: number) => { + return client.get(`/posts?userId=${userId}`); +}; diff --git a/src/api/users.ts b/src/api/users.ts new file mode 100644 index 000000000..386bfacd5 --- /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/CommentsList.tsx b/src/components/CommentsList.tsx new file mode 100644 index 000000000..a5d8e726b --- /dev/null +++ b/src/components/CommentsList.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { useValues } from '../SharedContext'; +import { CommentsListItem } from './CommentsListItem'; + +export const CommentsList = React.memo(() => { + const { comments } = useValues(); + + return ( + <> +

Comments:

+ + {comments?.map(comment => ( + + ))} + + ); +}); + +CommentsList.displayName = 'CommentsList'; diff --git a/src/components/CommentsListItem.tsx b/src/components/CommentsListItem.tsx new file mode 100644 index 000000000..fdcf2daa5 --- /dev/null +++ b/src/components/CommentsListItem.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Comment } from '../types/Comment'; +import { useValues } from '../SharedContext'; + +type Props = { + comment: Comment; +}; + +export const CommentsListItem: React.FC = ({ comment }) => { + const { id, name, email, body } = comment; + const { handleDeleteComment } = useValues(); + + const handleSubmitDeleteComment = () => { + handleDeleteComment(id); + }; + + return ( + + ); +}; diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx new file mode 100644 index 000000000..1838d3b65 --- /dev/null +++ b/src/components/MainContent.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { useValues } from '../SharedContext'; +import { Loader } from './Loader'; +import { PostsList } from './PostsList'; +import { Messages } from './constants/Messages'; +import { Notification } from './Notification'; + +export const MainContent = () => { + const { posts, selectedUser, isLoadingPosts, isError } = useValues(); + + let mainContent: React.JSX.Element; + + if (isLoadingPosts) { + mainContent = ; + } else if (!selectedUser) { + mainContent =

No user selected

; + } else if (isError) { + mainContent = ( + + ); + } else if (!posts.length) { + mainContent = ( + + ); + } else { + mainContent = ; + } + + return ( +
+ {mainContent} +
+ ); +}; diff --git a/src/components/NewCommentForm.tsx b/src/components/NewCommentForm.tsx index 73a8a0b45..a9034bbba 100644 --- a/src/components/NewCommentForm.tsx +++ b/src/components/NewCommentForm.tsx @@ -1,99 +1,211 @@ -import React from 'react'; +import React, { useReducer, useState } from 'react'; +import { useValues } from '../SharedContext'; +import cn from 'classnames'; +import { Messages } from './constants/Messages'; + +type Action = + | { type: 'changeName'; payload: { newName: string } } + | { type: 'changeEmail'; payload: { newEmail: string } } + | { type: 'changeBody'; payload: { newBody: string } }; + +interface State { + name: string; + email: string; + body: string; +} + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'changeName': + return { ...state, name: action.payload.newName }; + case 'changeEmail': + return { ...state, email: action.payload.newEmail }; + case 'changeBody': + return { ...state, body: action.payload.newBody }; + default: + return state; + } +} + +const initialValue: State = { + name: '', + email: '', + body: '', +}; export const NewCommentForm: React.FC = () => { + const { isSumbitting, handleCreateComment } = useValues(); + const [state, dispatch] = useReducer(reducer, initialValue); + const [errors, setErrors] = useState({ + name: '', + email: '', + body: '', + }); + + const { name, email, body } = state; + + const handleChangeName = (newName: string) => { + dispatch({ type: 'changeName', payload: { newName } }); + setErrors(prevErrors => ({ ...prevErrors, name: '' })); + }; + + const handleChangeEmail = (newEmail: string) => { + dispatch({ type: 'changeEmail', payload: { newEmail } }); + setErrors(prevErrors => ({ ...prevErrors, email: '' })); + }; + + const handleChangeText = (newBody: string) => { + dispatch({ type: 'changeBody', payload: { newBody } }); + setErrors(prevErrors => ({ ...prevErrors, body: '' })); + }; + + const reset = () => { + dispatch({ type: 'changeBody', payload: { newBody: '' } }); + }; + + const handleSubmitNewComment = async (event: React.FormEvent) => { + event.preventDefault(); + + const newErrors = { name: '', email: '', body: '' }; + + if (!name.trim().length) { + newErrors.name = Messages.nameRequiredMessage; + } + + if (!email.trim().length) { + newErrors.email = Messages.emailRequiredMessage; + } + + if (!body.trim().length) { + newErrors.body = Messages.bodyRequiredMessage; + } + + setErrors(newErrors); + + if (Object.values(newErrors).some(err => err)) { + return; + } + + try { + await handleCreateComment({ name, email, body }); + + reset(); + } catch { } + }; + + const handleResetButton = () => { + dispatch({ type: 'changeName', payload: { newName: '' } }); + dispatch({ type: 'changeEmail', payload: { newEmail: '' } }); + dispatch({ type: 'changeBody', payload: { newBody: '' } }); + setErrors({ name: '', email: '', body: '' }); + }; + return ( -
+
-
handleChangeName(e.target.value)} /> - - - - - + {errors.name && ( + + + + )}
- -

- Name is required -

+ {errors.name && ( +

+ {errors.name} +

+ )}
-
handleChangeEmail(e.target.value)} /> - - - - - + {errors.email && ( + + + + )}
- -

- Email is required -

+ {errors.email && ( +

+ {errors.email} +

+ )}
-