diff --git a/package-lock.json b/package-lock.json index 2be6d1de6..34e81e351 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1187,10 +1187,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", + "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index 4f3d32089..7ad4f3696 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/src/App.tsx b/src/App.tsx index e10753461..fb4ae2d30 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,52 @@ +import React, { useEffect, useState } from 'react'; import 'bulma/css/bulma.css'; import '@fortawesome/fontawesome-free/css/all.css'; -import { Loader, TodoFilter, TodoList, TodoModal } from './components'; -export const App = () => ( - <> -
-
-
-

Todos:

+import { TodoList } from './components/TodoList'; +import { TodoFilter } from './components/TodoFilter'; +import { TodoModal } from './components/TodoModal'; +import { Loader } from './components/Loader'; +import { getTodos } from './api'; +import { useAppSelector } from './app/hooks'; +import { useDispatch } from 'react-redux'; +import { setTodos } from './features/todos'; +import { selectTodos, selectCurrentTodo } from './features/selectors'; -
- -
+export const App: React.FC = () => { + const dispatch = useDispatch(); + const todos = useAppSelector(selectTodos); + const currentTodo = useAppSelector(selectCurrentTodo); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + setIsLoading(true); + getTodos() + .then(getTodo => { + dispatch(setTodos(getTodo)); + }) + .finally(() => setIsLoading(false)); + }, [dispatch]); + + return ( + <> +
+
+
+

Todos:

+ +
+ +
-
- - +
+ {isLoading && } + {!isLoading && !!todos.length && } +
-
- - -); + {currentTodo && } + + ); +}; diff --git a/src/app/hooks.ts b/src/app/hooks.ts new file mode 100644 index 000000000..99763399a --- /dev/null +++ b/src/app/hooks.ts @@ -0,0 +1,4 @@ +import { TypedUseSelectorHook, useSelector } from 'react-redux'; +import { RootState } from './store'; + +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/app/store.ts b/src/app/store.ts index 3a9b384be..93d7dfffd 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -1,6 +1,9 @@ import { combineSlices, configureStore } from '@reduxjs/toolkit'; +import { todosSlice } from '../features/todos'; +import { filterSlice } from '../features/filter'; +import { currentTodoSlice } from '../features/currentTodo'; -const rootReducer = combineSlices(); +const rootReducer = combineSlices(todosSlice, filterSlice, currentTodoSlice); export const store = configureStore({ reducer: rootReducer, diff --git a/src/components/TodoFilter/TodoFilter.tsx b/src/components/TodoFilter/TodoFilter.tsx index c1b574ee7..5b90ae283 100644 --- a/src/components/TodoFilter/TodoFilter.tsx +++ b/src/components/TodoFilter/TodoFilter.tsx @@ -1,17 +1,29 @@ import React from 'react'; +import { Status } from '../../types/Status'; +import { useAppSelector } from '../../app/hooks'; +import { useDispatch } from 'react-redux'; +import { setQuery, setStatus } from '../../features/filter'; export const TodoFilter: React.FC = () => { + const dispatch = useDispatch(); + const { query, status } = useAppSelector(state => state.filter); + + const handleQuery = (event: React.ChangeEvent) => { + dispatch(setQuery(event.target.value)); + }; + + const handleStatus = (event: React.ChangeEvent) => { + dispatch(setStatus(event.target.value)); + }; + return ( -
event.preventDefault()} - > +

- + + +

@@ -22,18 +34,22 @@ export const TodoFilter: React.FC = () => { type="text" className="input" placeholder="Search..." + value={query} + onChange={handleQuery} /> - {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} - + + + ); +}; diff --git a/src/components/TodoItem/index.ts b/src/components/TodoItem/index.ts new file mode 100644 index 000000000..21f4abac3 --- /dev/null +++ b/src/components/TodoItem/index.ts @@ -0,0 +1 @@ +export * from './TodoItem'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx index 5089eb030..033a21eed 100644 --- a/src/components/TodoList/TodoList.tsx +++ b/src/components/TodoList/TodoList.tsx @@ -1,224 +1,49 @@ /* eslint-disable */ import React from 'react'; +import { Todo } from '../../types/Todo'; +import { useAppSelector } from '../../app/hooks'; +import { useDispatch } from 'react-redux'; +import { setCurrentTodo } from '../../features/currentTodo'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { filterTodos } from '../../features/filterTodos'; export const TodoList: React.FC = () => { - return ( - <> -

- There are no todos matching current filter criteria -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + const dispatch = useDispatch(); + const todos = useAppSelector(state => state.todos); + const currentTodo = useAppSelector(state => state.currentTodo); + const { query, status } = useAppSelector(state => state.filter); - - + const handleSelectTodo = (todo: Todo) => { + dispatch(setCurrentTodo(todo)); + }; - - - + const filteredTodos = filterTodos(todos, status, query); - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# - - - - Title
1 -

delectus aut autem

-
- -
2 -

- quis ut nam facilis et officia qui -

-
- -
3 -

fugiat veniam minus

-
- -
4 - - - - -

et porro tempora

-
- -
5 -

- laboriosam mollitia et enim quasi adipisci quia provident illum -

-
- -
6 -

- qui ullam ratione quibusdam voluptatem quia omnis -

-
- -
7 -

- illo expedita consequatur quia in -

-
- -
8 - - - - -

quo adipisci enim quam ut ab

-
- -
9 -

molestiae perspiciatis ipsa

-
- -
10 - - - - -

- illo est ratione doloremque quia maiores aut -

-
- -
- + return ( + + + + + + + + + + + + {filteredTodos.map(todo => ( + + ))} + +
# + + + + Title
); }; diff --git a/src/components/TodoModal/TodoModal.tsx b/src/components/TodoModal/TodoModal.tsx index e16a079d5..54e016f9e 100644 --- a/src/components/TodoModal/TodoModal.tsx +++ b/src/components/TodoModal/TodoModal.tsx @@ -1,42 +1,74 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Loader } from '../Loader'; +import { getUser } from '../../api'; +import { User } from '../../types/User'; +import { useAppSelector } from '../../app/hooks'; +import { useDispatch } from 'react-redux'; +import { setCurrentTodo } from '../../features/currentTodo'; export const TodoModal: React.FC = () => { + const dispatch = useDispatch(); + const currentTodo = useAppSelector(state => state.currentTodo); + const [user, setUser] = useState(); + const [isLoading, setIsLoading] = useState(true); + + const handleClose = () => { + dispatch(setCurrentTodo(null)); + }; + + useEffect(() => { + if (currentTodo) { + setIsLoading(true); + getUser(currentTodo.userId) + .then(setUser) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + } + }, [currentTodo]); + return (
- + {isLoading ? ( + + ) : ( +
+
+
+ {`Todo #${currentTodo?.id}`} +
-
-
-
- Todo #3 -
- - {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} -
+
-
-

- fugiat veniam minus -

+
+

+ {currentTodo?.title} +

-

- {/* For not completed */} - Planned +

+ {!currentTodo?.completed ? ( + Planned + ) : ( + Done + )} - {/* For completed */} - Done - {' by '} - Leanne Graham -

+ {' by '} + {user?.name} +

+
-
+ )}
); }; diff --git a/src/components/index.ts b/src/components/index.ts index bce637539..b66ec7293 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,3 +2,4 @@ export * from './Loader'; export * from './TodoList'; export * from './TodoFilter'; export * from './TodoModal'; +export * from './TodoItem'; diff --git a/src/features/currentTodo.ts b/src/features/currentTodo.ts index 9e69e2240..c75d49608 100644 --- a/src/features/currentTodo.ts +++ b/src/features/currentTodo.ts @@ -1,4 +1,4 @@ -import { createSlice } from '@reduxjs/toolkit'; +import { PayloadAction, createSlice } from '@reduxjs/toolkit/react'; import { Todo } from '../types/Todo'; const initialState = null as Todo | null; @@ -6,5 +6,9 @@ const initialState = null as Todo | null; export const currentTodoSlice = createSlice({ name: 'currentTodo', initialState, - reducers: {}, + reducers: { + setCurrentTodo: (_, { payload }: PayloadAction) => payload, + }, }); + +export const { setCurrentTodo } = currentTodoSlice.actions; diff --git a/src/features/filter.ts b/src/features/filter.ts index d0eaf4640..aa7efc311 100644 --- a/src/features/filter.ts +++ b/src/features/filter.ts @@ -1,12 +1,29 @@ -import { createSlice } from '@reduxjs/toolkit'; +import { createSlice, PayloadAction, Slice } from '@reduxjs/toolkit'; +import { Status } from '../types/Status'; -const initialState = { +type FilterState = { + query: string; + status: Status; +}; + +const initialState: FilterState = { query: '', - status: 'all', + status: Status.All, }; -export const filterSlice = createSlice({ +export const filterSlice: Slice = createSlice({ name: 'filter', initialState, - reducers: {}, + reducers: { + setStatus: (state, { payload }: PayloadAction) => ({ + ...state, + status: payload, + }), + setQuery: (state, { payload }: PayloadAction) => ({ + ...state, + query: payload, + }), + }, }); + +export const { setStatus, setQuery } = filterSlice.actions; diff --git a/src/features/filterTodos.ts b/src/features/filterTodos.ts new file mode 100644 index 000000000..5df6e3989 --- /dev/null +++ b/src/features/filterTodos.ts @@ -0,0 +1,23 @@ +import { Todo } from '../types/Todo'; +import { Status } from '../types/Status'; + +export const filterTodos = ( + todos: Todo[], + status: Status, + query: string, +): Todo[] => { + if (status === Status.All && query === '') { + return todos; + } + + return todos.filter(todo => { + const matchesStatus = + status === Status.All || + (status === Status.Active && !todo.completed) || + (status === Status.Completed && todo.completed); + + const matchesQuery = todo.title.toLowerCase().includes(query.toLowerCase()); + + return matchesStatus && matchesQuery; + }); +}; diff --git a/src/features/selectors.ts b/src/features/selectors.ts new file mode 100644 index 000000000..d9deb6308 --- /dev/null +++ b/src/features/selectors.ts @@ -0,0 +1,4 @@ +import { RootState } from '../app/store'; + +export const selectTodos = (state: RootState) => state.todos; +export const selectCurrentTodo = (state: RootState) => state.currentTodo; diff --git a/src/features/todos.ts b/src/features/todos.ts index 4555dea8d..af8decc04 100644 --- a/src/features/todos.ts +++ b/src/features/todos.ts @@ -1,8 +1,14 @@ -import { createSlice } from '@reduxjs/toolkit'; +import { createSlice, PayloadAction, Slice } from '@reduxjs/toolkit'; import { Todo } from '../types/Todo'; -export const todosSlice = createSlice({ +export const todosSlice: Slice = createSlice({ name: 'todos', initialState: [] as Todo[], - reducers: {}, + reducers: { + setTodos(todos, action: PayloadAction) { + todos.push(...action.payload); + }, + }, }); + +export const { setTodos } = todosSlice.actions; diff --git a/src/types/Status.ts b/src/types/Status.ts index 6eb05b9b2..2ec4b8714 100644 --- a/src/types/Status.ts +++ b/src/types/Status.ts @@ -1 +1,5 @@ -export type Status = 'all' | 'active' | 'completed'; +export enum Status { + All = 'all', + Active = 'active', + Completed = 'completed', +}