diff --git a/.eslintrc.cjs b/.eslintrc.cjs index b51149cf5..c3260e6e9 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -52,7 +52,10 @@ module.exports = { 'brace-style': [2, '1tbs'], 'arrow-body-style': 0, 'arrow-parens': 0, - 'no-param-reassign': [2, { props: true }], + "no-param-reassign": ["error", { + "props": true, + "ignorePropertyModificationsFor": ["state"] + }], 'padding-line-between-statements': [ 2, { blankLine: 'always', prev: '*', next: 'return' }, diff --git a/src/App.tsx b/src/App.tsx index d690fe3b3..e01ed04ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import classNames from 'classnames'; import 'bulma/css/bulma.css'; @@ -9,39 +9,48 @@ import { PostsList } from './components/PostsList'; import { PostDetails } from './components/PostDetails'; import { UserSelector } from './components/UserSelector'; import { Loader } from './components/Loader'; -import { getUserPosts } from './api/posts'; -import { User } from './types/User'; -import { Post } from './types/Post'; -export const App: React.FC = () => { - const [posts, setPosts] = useState([]); - const [loaded, setLoaded] = useState(false); - const [hasError, setError] = useState(false); +import { useAppDispatch, useAppSelector } from './app/hooks'; + +import { fetchUsers } from './features/users/usersSlice'; +import { selectAuthor, setAuthor } from './features/author/authorSlice'; +import { + fetchPostsByUser, + clearPosts, + selectPosts, + selectPostsLoaded, + selectPostsHasError, +} from './features/posts/postsSlice'; - const [author, setAuthor] = useState(null); - const [selectedPost, setSelectedPost] = useState(null); +import { + selectSelectedPost, + setSelectedPost, +} from './features/selectedPost/selectedPostSlice'; - function loadUserPosts(userId: number) { - setLoaded(false); +export const App: React.FC = () => { + const dispatch = useAppDispatch(); - getUserPosts(userId) - .then(setPosts) - .catch(() => setError(true)) - // We disable the spinner in any case - .finally(() => setLoaded(true)); - } + const author = useAppSelector(selectAuthor); + const posts = useAppSelector(selectPosts); + const loaded = useAppSelector(selectPostsLoaded); + const hasError = useAppSelector(selectPostsHasError); + const selectedPost = useAppSelector(selectSelectedPost); + + // Load users once for the dropdown + useEffect(() => { + dispatch(fetchUsers()); + }, [dispatch]); + // When author changes: clear selected post and load posts (or clear) useEffect(() => { - // we clear the post when an author is changed - // not to confuse the user - setSelectedPost(null); + dispatch(setSelectedPost(null)); if (author) { - loadUserPosts(author.id); + dispatch(fetchPostsByUser(author.id)); } else { - setPosts([]); + dispatch(clearPosts()); } - }, [author]); + }, [dispatch, author]); return (
@@ -50,7 +59,10 @@ export const App: React.FC = () => {
- + dispatch(setAuthor(user))} + />
@@ -77,7 +89,7 @@ export const App: React.FC = () => { dispatch(setSelectedPost(post))} /> )}
diff --git a/src/app/store.ts b/src/app/store.ts index 1b9178e79..49162b59b 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -1,21 +1,24 @@ -import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit'; +import { configureStore } from '@reduxjs/toolkit'; // eslint-disable-next-line import/no-cycle import counterReducer from '../features/counter/counterSlice'; +import { usersReducer } from '../features/users/usersSlice'; +import { authorReducer } from '../features/author/authorSlice'; +import { postsReducer } from '../features/posts/postsSlice'; +// eslint-disable-next-line max-len +import { selectedPostReducer } from '../features/selectedPost/selectedPostSlice'; +import { commentsReducer } from '../features/comments/commentsSlice'; + export const store = configureStore({ reducer: { counter: counterReducer, + users: usersReducer, + author: authorReducer, + posts: postsReducer, + selectedPost: selectedPostReducer, + comments: commentsReducer, }, }); -export type AppDispatch = typeof store.dispatch; export type RootState = ReturnType; - -/* eslint-disable @typescript-eslint/indent */ -export type AppThunk = ThunkAction< - ReturnType, - RootState, - unknown, - Action ->; -/* eslint-enable @typescript-eslint/indent */ +export type AppDispatch = typeof store.dispatch; diff --git a/src/components/PostDetails.tsx b/src/components/PostDetails.tsx index 55efddd2e..eaa41ef40 100644 --- a/src/components/PostDetails.tsx +++ b/src/components/PostDetails.tsx @@ -2,91 +2,45 @@ import React, { useEffect, useState } from 'react'; import { Loader } from './Loader'; import { NewCommentForm } from './NewCommentForm'; -import * as commentsApi from '../api/comments'; - import { Post } from '../types/Post'; -import { Comment, CommentData } from '../types/Comment'; +import { CommentData } from '../types/Comment'; + +import { useAppDispatch, useAppSelector } from '../app/hooks'; +import { + fetchCommentsByPost, + selectComments, + selectCommentsLoaded, + selectCommentsHasError, + addCommentToPost, + removeCommentOptimistic, + deleteCommentById, +} from '../features/comments/commentsSlice'; type Props = { post: Post; }; export const PostDetails: React.FC = ({ post }) => { - const [comments, setComments] = useState([]); - const [loaded, setLoaded] = useState(false); - const [hasError, setError] = useState(false); - const [visible, setVisible] = useState(false); - - function loadComments() { - setLoaded(false); - setError(false); - setVisible(false); - - commentsApi - .getPostComments(post.id) - .then(setComments) // save the loaded comments - .catch(() => setError(true)) // show an error when something went wrong - .finally(() => setLoaded(true)); // hide the spinner - } + const dispatch = useAppDispatch(); - useEffect(loadComments, [post.id]); + const comments = useAppSelector(selectComments); + const loaded = useAppSelector(selectCommentsLoaded); + const hasError = useAppSelector(selectCommentsHasError); + const [visible, setVisible] = useState(false); - // The same useEffect with async/await - /* - async function loadComments() { - setLoaded(false); + useEffect(() => { setVisible(false); - setError(false); - - try { - const commentsFromServer = await commentsApi.getPostComments(post.id); + dispatch(fetchCommentsByPost(post.id)); + }, [dispatch, post.id]); - setComments(commentsFromServer); - } catch (error) { - setError(true); - } finally { - setLoaded(true); - } - }; - - useEffect(() => { - loadComments(); - }, []); - - useEffect(loadComments, [post.id]); // Wrong! - // effect can return only a function but not a Promise - */ - - const addComment = async ({ name, email, body }: CommentData) => { - try { - const newComment = await commentsApi.createComment({ - name, - email, - body, - postId: post.id, - }); - - setComments(currentComments => [...currentComments, newComment]); - - // setComments([...comments, newComment]); - // works wrong if we wrap `addComment` with `useCallback` - // because it takes the `comments` cached during the first render - // not the actual ones - } catch (error) { - // we show an error message in case of any error - setError(true); - } + const addComment = async (data: CommentData) => { + await dispatch(addCommentToPost({ postId: post.id, data })); }; const deleteComment = async (commentId: number) => { - // we delete the comment immediately so as - // not to make the user wait long for the actual deletion - // eslint-disable-next-line max-len - setComments(currentComments => - currentComments.filter(comment => comment.id !== commentId), - ); - - await commentsApi.deleteComment(commentId); + // same optimistic UX as your current code :contentReference[oaicite:11]{index=11} + dispatch(removeCommentOptimistic(commentId)); + await dispatch(deleteCommentById(commentId)); }; return ( diff --git a/src/components/UserSelector.tsx b/src/components/UserSelector.tsx index 88cf69698..4bda362fe 100644 --- a/src/components/UserSelector.tsx +++ b/src/components/UserSelector.tsx @@ -1,23 +1,20 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; -import { UserContext } from './UsersContext'; import { User } from '../types/User'; +import { useAppSelector } from '../app/hooks'; +import { selectUsers } from '../features/users/usersSlice'; + type Props = { value: User | null; onChange: (user: User) => void; }; export const UserSelector: React.FC = ({ - // `value` and `onChange` are traditional names for the form field - // `selectedUser` represents what actually stored here value: selectedUser, onChange, }) => { - // `users` are loaded from the API, so for the performance reasons - // we load them once in the `UsersContext` when the `App` is opened - // and now we can easily reuse the `UserSelector` in any form - const users = useContext(UserContext); + const users = useAppSelector(selectUsers); const [expanded, setExpanded] = useState(false); useEffect(() => { diff --git a/src/components/UsersContext.tsx b/src/components/UsersContext.tsx deleted file mode 100644 index 9384ba807..000000000 --- a/src/components/UsersContext.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { getUsers } from '../api/users'; -import { User } from '../types/User'; - -export const UserContext = React.createContext([]); - -type Props = { - children: React.ReactNode; -}; - -export const UsersProvider: React.FC = ({ children }) => { - const [users, setUsers] = useState([]); - - useEffect(() => { - getUsers().then(setUsers); - }, []); - - return {children}; -}; diff --git a/src/features/author/authorSlice.ts b/src/features/author/authorSlice.ts new file mode 100644 index 000000000..87f58366c --- /dev/null +++ b/src/features/author/authorSlice.ts @@ -0,0 +1,20 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { User } from '../../types/User'; +import { RootState } from '../../app/store'; + +type AuthorState = User | null; + +const initialState: AuthorState = null; + +const authorSlice = createSlice({ + name: 'author', + initialState, + reducers: { + setAuthor: (_state, action: PayloadAction) => action.payload, + }, +}); + +export const { setAuthor } = authorSlice.actions; +export const authorReducer = authorSlice.reducer; + +export const selectAuthor = (state: RootState) => state.author; diff --git a/src/features/comments/commentsSlice.ts b/src/features/comments/commentsSlice.ts new file mode 100644 index 000000000..41b175cbe --- /dev/null +++ b/src/features/comments/commentsSlice.ts @@ -0,0 +1,87 @@ +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { RootState } from '../../app/store'; +import { Comment, CommentData } from '../../types/Comment'; +import * as commentsApi from '../../api/comments'; + +type CommentsState = { + loaded: boolean; + hasError: boolean; + items: Comment[]; +}; + +const initialState: CommentsState = { + loaded: false, + hasError: false, + items: [], +}; + +export const fetchCommentsByPost = createAsyncThunk( + 'comments/fetchByPost', + async (postId: number) => commentsApi.getPostComments(postId), +); + +export const addCommentToPost = createAsyncThunk( + 'comments/add', + async (payload: { postId: number; data: CommentData }) => { + const { postId, data } = payload; + + return commentsApi.createComment({ ...data, postId }); + }, +); + +export const deleteCommentById = createAsyncThunk( + 'comments/delete', + async (commentId: number) => { + await commentsApi.deleteComment(commentId); + + return commentId; + }, +); + +const commentsSlice = createSlice({ + name: 'comments', + initialState, + reducers: { + clearComments: state => { + state.items = []; + state.loaded = false; + state.hasError = false; + }, + removeCommentOptimistic: (state, action: PayloadAction) => { + state.items = state.items.filter(c => c.id !== action.payload); + }, + }, + extraReducers: builder => { + builder + .addCase(fetchCommentsByPost.pending, state => { + state.loaded = false; + state.hasError = false; + state.items = []; + }) + .addCase(fetchCommentsByPost.fulfilled, (state, action) => { + state.items = action.payload; + state.loaded = true; + }) + .addCase(fetchCommentsByPost.rejected, state => { + state.hasError = true; + state.loaded = true; + }) + .addCase(addCommentToPost.fulfilled, (state, action) => { + state.items.push(action.payload); + }) + .addCase(addCommentToPost.rejected, state => { + state.hasError = true; + }) + .addCase(deleteCommentById.fulfilled, (state, action) => { + state.items = state.items.filter(c => c.id !== action.payload); + }); + }, +}); + +export const { clearComments, removeCommentOptimistic } = commentsSlice.actions; +export const commentsReducer = commentsSlice.reducer; + +export const selectComments = (state: RootState) => state.comments.items; +export const selectCommentsLoaded = (state: RootState) => state.comments.loaded; +export const selectCommentsHasError = (state: RootState) => + state.comments.hasError; diff --git a/src/features/posts/postsSlice.ts b/src/features/posts/postsSlice.ts new file mode 100644 index 000000000..888440cae --- /dev/null +++ b/src/features/posts/postsSlice.ts @@ -0,0 +1,58 @@ +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { getUserPosts } from '../../api/posts'; +import { Post } from '../../types/Post'; +import { RootState } from '../../app/store'; + +type PostsState = { + loaded: boolean; + hasError: boolean; + items: Post[]; +}; + +const initialState: PostsState = { + loaded: false, + hasError: false, + items: [], +}; + +export const fetchPostsByUser = createAsyncThunk( + 'posts/fetchByUser', + async (userId: number) => getUserPosts(userId), +); + +const postsSlice = createSlice({ + name: 'posts', + initialState, + reducers: { + clearPosts: state => { + state.items = []; + state.loaded = true; // matches old UI behavior when no author is selected + state.hasError = false; + }, + setPostsError: (state, action: PayloadAction) => { + state.hasError = action.payload; + }, + }, + extraReducers: builder => { + builder + .addCase(fetchPostsByUser.pending, state => { + state.loaded = false; + state.hasError = false; + }) + .addCase(fetchPostsByUser.fulfilled, (state, action) => { + state.items = action.payload; + state.loaded = true; + }) + .addCase(fetchPostsByUser.rejected, state => { + state.hasError = true; + state.loaded = true; + }); + }, +}); + +export const { clearPosts, setPostsError } = postsSlice.actions; +export const postsReducer = postsSlice.reducer; + +export const selectPosts = (state: RootState) => state.posts.items; +export const selectPostsLoaded = (state: RootState) => state.posts.loaded; +export const selectPostsHasError = (state: RootState) => state.posts.hasError; diff --git a/src/features/selectedPost/selectedPostSlice.ts b/src/features/selectedPost/selectedPostSlice.ts new file mode 100644 index 000000000..fed70b29d --- /dev/null +++ b/src/features/selectedPost/selectedPostSlice.ts @@ -0,0 +1,25 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { Post } from '../../types/Post'; +import { RootState } from '../../app/store'; + +type SelectedPostState = Post | null; + +const initialState: SelectedPostState = null; + +const selectedPostSlice = createSlice({ + name: 'selectedPost', + initialState, + reducers: { + setSelectedPost: (_state, action: PayloadAction) => + action.payload, + clearSelectedPost: () => null, + }, +}); + +export const { setSelectedPost, clearSelectedPost } = selectedPostSlice.actions; + +export const selectedPostReducer = selectedPostSlice.reducer; + +export const selectSelectedPost = (state: RootState) => state.selectedPost; +export const selectSelectedPostId = (state: RootState) => + state.selectedPost?.id; diff --git a/src/features/users/usersSlice.ts b/src/features/users/usersSlice.ts new file mode 100644 index 000000000..96982fb4d --- /dev/null +++ b/src/features/users/usersSlice.ts @@ -0,0 +1,47 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import { getUsers } from '../../api/users'; +import { User } from '../../types/User'; +import { RootState } from '../../app/store'; + +type UserState = { + loaded: boolean; + hasError: boolean; + items: User[]; +}; + +const initialState: UserState = { + loaded: false, + hasError: false, + items: [], +}; + +export const fetchUsers = createAsyncThunk('users/fetch', async () => { + return getUsers(); +}); + +const usersSlice = createSlice({ + name: 'users', + initialState, + reducers: {}, + extraReducers: builder => { + builder + .addCase(fetchUsers.pending, state => { + state.loaded = false; + state.hasError = false; + }) + .addCase(fetchUsers.fulfilled, (state, action) => { + state.items = action.payload; + state.loaded = true; + }) + .addCase(fetchUsers.rejected, state => { + state.hasError = true; + state.loaded = true; + }); + }, +}); + +export const usersReducer = usersSlice.reducer; + +export const selectUsers = (state: RootState) => state.users.items; +export const selectUsersLoaded = (state: RootState) => state.users.loaded; +export const selectUsersHasError = (state: RootState) => state.users.hasError; diff --git a/src/index.tsx b/src/index.tsx index 3b0b19fe3..bb04c3af8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,19 +4,15 @@ import { HashRouter as Router } from 'react-router-dom'; import { store } from './app/store'; import { App } from './App'; -import { UsersProvider } from './components/UsersContext'; const container = document.getElementById('root') as HTMLElement; const root = createRoot(container); const Root = () => ( - {/* Remove UsersProvider when you move users to Redux store */} - - - - - + + + );