Skip to content
Open

Develop #1124

Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
/build
/node_modules
/src

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ Learn the implemented App and the example and reimplement it with Redux having t
- Implement a solution following the [React task guideline](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline).
- Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript).
- This task does not have tests yet!
- Replace `<your_account>` with your Github username in the [DEMO LINK](https://<your_account>.github.io/react_redux-list-of-posts/) and add it to the PR description.
- Replace `<your_account>` with your Github username in the [DEMO LINK](https://MaksOther.github.io/react_redux-list-of-posts/) and add it to the PR description.
133 changes: 42 additions & 91 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
"@mate-academy/scripts": "^1.9.12",
"@mate-academy/scripts": "^2.1.3",
"@mate-academy/students-ts-config": "*",
"@mate-academy/stylelint-config": "*",
"@types/node": "^20.14.10",
Expand Down
74 changes: 46 additions & 28 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 @@ -10,38 +10,47 @@
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';
import { useAppDispatch, useAppSelector } from './app/hooks';

import {
setAuthor,
setPosts,
setPostsLoading,
setPostsError,
setSelectedPost,
clearSelectedPost,
} from './app/appSlice';

export const App: React.FC = () => {
const [posts, setPosts] = useState<Post[]>([]);
const [loaded, setLoaded] = useState(false);
const [hasError, setError] = useState(false);
const dispatch = useAppDispatch();

const [author, setAuthor] = useState<User | null>(null);
const [selectedPost, setSelectedPost] = useState<Post | null>(null);
const posts = useAppSelector(state => state.app.posts.items);
const loaded = useAppSelector(state => state.app.posts.loaded);
const hasError = useAppSelector(state => state.app.posts.hasError);
const authorId = useAppSelector(state => state.app.author);
const selectedPostId = useAppSelector(state => state.app.selectedPost);

function loadUserPosts(userId: number) {
setLoaded(false);
dispatch(setPostsLoading());

getUserPosts(userId)
.then(setPosts)
.catch(() => setError(true))
// We disable the spinner in any case
.finally(() => setLoaded(true));
.then(data => {
dispatch(setPosts(data));
})
.catch(() => dispatch(setPostsError()));
}

useEffect(() => {
// we clear the post when an author is changed
// not to confuse the user
setSelectedPost(null);
dispatch(clearSelectedPost());

if (author) {
loadUserPosts(author.id);
if (authorId) {
loadUserPosts(authorId);
} else {
setPosts([]);
dispatch(setPosts([]));
}
}, [author]);
}, [authorId, dispatch]);

Check warning on line 51 in src/App.tsx

View workflow job for this annotation

GitHub Actions / run_linter (20.x)

React Hook useEffect has a missing dependency: 'loadUserPosts'. Either include it or remove the dependency array

const selectedPost = posts.find(post => post.id === selectedPostId);

return (
<main className="section">
Expand All @@ -50,15 +59,18 @@
<div className="tile is-parent">
<div className="tile is-child box is-success">
<div className="block">
<UserSelector value={author} onChange={setAuthor} />
<UserSelector
value={authorId}
onChange={userID => dispatch(setAuthor(userID))}
/>
</div>

<div className="block" data-cy="MainContent">
{!author && <p data-cy="NoSelectedUser">No user selected</p>}
{!authorId && <p data-cy="NoSelectedUser">No user selected</p>}

{author && !loaded && <Loader />}
{authorId && !loaded && <Loader />}

{author && loaded && hasError && (
{authorId && loaded && hasError && (
<div
className="notification is-danger"
data-cy="PostsLoadingError"
Expand All @@ -67,17 +79,23 @@
</div>
)}

{author && loaded && !hasError && posts.length === 0 && (
{authorId && loaded && !hasError && posts.length === 0 && (
<div className="notification is-warning" data-cy="NoPostsYet">
No posts yet
</div>
)}

{author && loaded && !hasError && posts.length > 0 && (
{authorId && loaded && !hasError && posts.length > 0 && (
<PostsList
posts={posts}
selectedPostId={selectedPost?.id}
onPostSelected={setSelectedPost}
selectedPostId={selectedPostId || undefined}
onPostSelected={post => {
if (post) {
dispatch(setSelectedPost(post.id));
} else {
dispatch(clearSelectedPost());
}
}}
/>
)}
</div>
Expand Down
114 changes: 114 additions & 0 deletions src/app/appSlice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { User } from '../types/User';
import { Post } from '../types/Post';
import { Comment } from '../types/Comment';

export interface AppState {
users: User[];
author: number | null;
posts: {
loaded: boolean;
hasError: boolean;
items: Post[];
};

selectedPost: number | null;

comments: {
loaded: boolean;
hasError: boolean;
items: Comment[];
};
}

const initialState: AppState = {
users: [],
author: null,
posts: {
loaded: false,
hasError: false,
items: [],
},

selectedPost: null,

comments: {
loaded: false,
hasError: false,
items: [],
},
};

const appSlice = createSlice({
name: 'app',
initialState,
reducers: {
setUsers(state, action: PayloadAction<User[]>) {
state.users = action.payload;

Check failure on line 47 in src/app/appSlice.ts

View workflow job for this annotation

GitHub Actions / run_linter (20.x)

Assignment to property of function parameter 'state'
},
setAuthor(state, action: PayloadAction<number>) {
state.author = action.payload;

Check failure on line 50 in src/app/appSlice.ts

View workflow job for this annotation

GitHub Actions / run_linter (20.x)

Assignment to property of function parameter 'state'
},
setPosts(state, action: PayloadAction<Post[]>) {
state.posts.hasError = false;

Check failure on line 53 in src/app/appSlice.ts

View workflow job for this annotation

GitHub Actions / run_linter (20.x)

Assignment to property of function parameter 'state'
state.posts.loaded = true;

Check failure on line 54 in src/app/appSlice.ts

View workflow job for this annotation

GitHub Actions / run_linter (20.x)

Assignment to property of function parameter 'state'
state.posts.items = action.payload;

Check failure on line 55 in src/app/appSlice.ts

View workflow job for this annotation

GitHub Actions / run_linter (20.x)

Assignment to property of function parameter 'state'
},
setSelectedPost(state, action: PayloadAction<number>) {
state.selectedPost = action.payload;

Check failure on line 58 in src/app/appSlice.ts

View workflow job for this annotation

GitHub Actions / run_linter (20.x)

Assignment to property of function parameter 'state'
},
clearAuthor(state) {
state.author = null;

Check failure on line 61 in src/app/appSlice.ts

View workflow job for this annotation

GitHub Actions / run_linter (20.x)

Assignment to property of function parameter 'state'
},
setPostsLoading(state) {
state.posts.loaded = false;

Check failure on line 64 in src/app/appSlice.ts

View workflow job for this annotation

GitHub Actions / run_linter (20.x)

Assignment to property of function parameter 'state'
state.posts.hasError = false;

Check failure on line 65 in src/app/appSlice.ts

View workflow job for this annotation

GitHub Actions / run_linter (20.x)

Assignment to property of function parameter 'state'
},
setPostsError(state) {
state.posts.hasError = true;

Check failure on line 68 in src/app/appSlice.ts

View workflow job for this annotation

GitHub Actions / run_linter (20.x)

Assignment to property of function parameter 'state'
state.posts.loaded = true;
},
clearSelectedPost(state) {
state.selectedPost = null;
},
setCommentsLoading(state) {
state.comments.loaded = false;
state.comments.hasError = false;
},
setComments(state, action: PayloadAction<Comment[]>) {
state.comments.hasError = false;
state.comments.loaded = true;
state.comments.items = action.payload;
},
setCommentsError(state) {
state.comments.hasError = true;
state.comments.loaded = true;
},
addComment(state, action: PayloadAction<Comment>) {
state.comments.items.push(action.payload);
},
removeComment(state, action: PayloadAction<number>) {
state.comments.items = state.comments.items.filter(
comment => comment.id !== action.payload,
);
},
},
});

export const {
setUsers,
setAuthor,
setPosts,
setSelectedPost,
clearAuthor,
setPostsLoading,
setPostsError,
clearSelectedPost,
setCommentsLoading,
setComments,
setCommentsError,
addComment,
removeComment,
} = appSlice.actions;

export default appSlice.reducer;
Comment on lines +1 to +114

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job refactoring the Redux state into separate slices for users, posts, and comments! This file appears to be a leftover from the previous implementation. Since it's no longer used anywhere in the application, it should be deleted to remove the dead code.

5 changes: 2 additions & 3 deletions src/app/store.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit';
import AppSlice from './appSlice';
// eslint-disable-next-line import/no-cycle
import counterReducer from '../features/counter/counterSlice';

export const store = configureStore({
reducer: {
counter: counterReducer,
app: AppSlice,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task requires creating separate slices for users, author, posts, selectedPost, and comments. Instead, a single large slice (AppSlice) has been created for the entire application state under the app key. You should refactor your Redux state into multiple, more focused slices and combine their reducers here.

},
});

Expand Down
93 changes: 36 additions & 57 deletions src/components/PostDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,61 +1,47 @@
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 {
setComments,
setCommentsLoading,
setCommentsError,
addComment as addCommentAction,
removeComment as removeCommentAction,
} from '../app/appSlice';

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);
const dispatch = useAppDispatch();

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 {
items: comments,
loaded,
hasError,
} = useAppSelector(state => state.app.comments);

useEffect(loadComments, [post.id]);
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(setCommentsLoading());

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
*/
commentsApi
.getPostComments(post.id)
.then(data => {
dispatch(setComments(data));
})
.catch(() => {
dispatch(setCommentsError());
});
}, [post.id, dispatch]);

const addComment = async ({ name, email, body }: CommentData) => {
try {
Expand All @@ -66,27 +52,20 @@ export const PostDetails: React.FC<Props> = ({ post }) => {
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
dispatch(addCommentAction(newComment));
} catch (error) {
// we show an error message in case of any error
setError(true);
dispatch(setCommentsError());
}
};

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);
dispatch(removeCommentAction(commentId));

try {
await commentsApi.deleteComment(commentId);
} catch {
dispatch(setCommentsError());
}
Comment on lines 63 to 79

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good use of an optimistic update for a better user experience. However, consider what happens if the API call in the try block fails. The comment is already removed from the UI by the action on line 62, but it still exists on the server. A more robust implementation would handle this inconsistency, for example, by re-fetching the comments in the catch block to synchronize the UI with the server state.

};

return (
Expand Down
Loading