Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
64 changes: 38 additions & 26 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import classNames from 'classnames';

import 'bulma/css/bulma.css';
Expand All @@ -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<Post[]>([]);
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<User | null>(null);
const [selectedPost, setSelectedPost] = useState<Post | null>(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 (
<main className="section">
Expand All @@ -50,7 +59,10 @@ export const App: React.FC = () => {
<div className="tile is-parent">
<div className="tile is-child box is-success">
<div className="block">
<UserSelector value={author} onChange={setAuthor} />
<UserSelector
value={author}
onChange={user => dispatch(setAuthor(user))}
/>
</div>

<div className="block" data-cy="MainContent">
Expand All @@ -77,7 +89,7 @@ export const App: React.FC = () => {
<PostsList
posts={posts}
selectedPostId={selectedPost?.id}
onPostSelected={setSelectedPost}
onPostSelected={post => dispatch(setSelectedPost(post))}
/>
)}
</div>
Expand Down
25 changes: 14 additions & 11 deletions src/app/store.ts
Original file line number Diff line number Diff line change
@@ -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<typeof store.getState>;

/* eslint-disable @typescript-eslint/indent */
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;
/* eslint-enable @typescript-eslint/indent */
export type AppDispatch = typeof store.dispatch;
96 changes: 25 additions & 71 deletions src/components/PostDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Props> = ({ post }) => {
const [comments, setComments] = useState<Comment[]>([]);
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 (
Expand Down
13 changes: 5 additions & 8 deletions src/components/UserSelector.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({
// `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(() => {
Expand Down
19 changes: 0 additions & 19 deletions src/components/UsersContext.tsx

This file was deleted.

20 changes: 20 additions & 0 deletions src/features/author/authorSlice.ts
Original file line number Diff line number Diff line change
@@ -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<User | null>) => action.payload,
},
});

export const { setAuthor } = authorSlice.actions;
export const authorReducer = authorSlice.reducer;

export const selectAuthor = (state: RootState) => state.author;
Loading