-
Notifications
You must be signed in to change notification settings - Fork 1.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Create todo app using API #767
base: master
Are you sure you want to change the base?
Changes from all commits
74d64c2
cfb8bcb
e8c4b7c
455b1f5
7d0ba51
8865bc7
79a37d1
02c204e
c022282
7d880c9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,24 +1,118 @@ | ||
/* eslint-disable max-len */ | ||
/* eslint-disable jsx-a11y/control-has-associated-label */ | ||
import React from 'react'; | ||
import { UserWarning } from './UserWarning'; | ||
|
||
const USER_ID = 0; | ||
import classNames from 'classnames'; | ||
import React, { useEffect, useMemo, useState } from 'react'; | ||
import { Todo } from './types/Todo'; | ||
import { Filters } from './utils/Filters'; | ||
import { NewTodo } from './components/NewTodo/NewTodo'; | ||
import { TodoList } from './components/TodoList/TodoList'; | ||
import { TodoFilter } from './components/TodoFilter/TodoFilter'; | ||
import { | ||
deleteTodo, | ||
getTodos, | ||
} from './api/todos'; | ||
import { countTodos } from './utils/countTodos'; | ||
import { | ||
useTodos, | ||
} from './components/Contexts/TodosContext'; | ||
import { USER_ID } from './utils/userToken'; | ||
import { | ||
useLoadingTodos, | ||
} from './components/Contexts/LoadingTodosContext'; | ||
import { | ||
useErrorMessage, | ||
} from './components/Contexts/ErrorMessageContext'; | ||
import { TempTodo } from './components/TempTodo/TempTodo'; | ||
|
||
export const App: React.FC = () => { | ||
if (!USER_ID) { | ||
return <UserWarning />; | ||
} | ||
const { todos, setTodos } = useTodos(); | ||
const [tempTodo, setTempTodo] = useState<Todo | null>(null); | ||
const { setLoadingTodos } = useLoadingTodos(); | ||
const { | ||
errorMessage, | ||
isErrorHidden, | ||
setIsErrorHidden, | ||
handleShowError, | ||
} = useErrorMessage(); | ||
const [filterParam, setFilterParam] = useState(Filters.All); | ||
|
||
useEffect(() => { | ||
getTodos(USER_ID) | ||
.then(setTodos) | ||
.catch(() => { | ||
handleShowError('Unable to load todos'); | ||
}); | ||
}, []); | ||
|
||
const visibleTodos = useMemo(() => { | ||
switch (filterParam) { | ||
case Filters.Active: | ||
return todos.filter(({ completed }) => !completed); | ||
|
||
case Filters.Completed: | ||
return todos.filter(({ completed }) => completed); | ||
|
||
case Filters.All: | ||
default: | ||
return todos; | ||
} | ||
}, [filterParam, todos]); | ||
|
||
const clearCompletedTodos = async () => { | ||
const completedTodos = countTodos(todos, true).map(({ id }) => ( | ||
deleteTodo(id))); | ||
|
||
countTodos(todos, true).forEach(({ id }) => { | ||
setLoadingTodos(prev => [...prev, id]); | ||
}); | ||
|
||
try { | ||
await Promise.all(completedTodos); | ||
setTodos(prev => prev.filter(({ completed }) => !completed)); | ||
} catch { | ||
handleShowError('Unable to delete todo'); | ||
} | ||
|
||
setLoadingTodos([]); | ||
}; | ||
|
||
return ( | ||
<section className="section container"> | ||
<p className="title is-4"> | ||
Copy all you need from the prev task: | ||
<br /> | ||
<a href="https://github.com/mate-academy/react_todo-app-add-and-delete#react-todo-app-add-and-delete">React Todo App - Add and Delete</a> | ||
</p> | ||
<div className="todoapp"> | ||
<h1 className="todoapp__title">todos</h1> | ||
|
||
<div className="todoapp__content"> | ||
<NewTodo onWaiting={setTempTodo} /> | ||
|
||
<section className="todoapp__main"> | ||
<TodoList todos={visibleTodos} /> | ||
|
||
<p className="subtitle">Styles are already copied</p> | ||
</section> | ||
{tempTodo && ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can this tempTodo be moved to a separate component? |
||
<TempTodo title={tempTodo.title} /> | ||
)} | ||
</section> | ||
|
||
{!!todos?.length && ( | ||
<TodoFilter | ||
todos={todos} | ||
filterParam={filterParam} | ||
onFilterChange={setFilterParam} | ||
clearCompleted={clearCompletedTodos} | ||
/> | ||
)} | ||
</div> | ||
|
||
<div className={classNames( | ||
'notification is-danger is-light has-text-weight-normal', | ||
{ hidden: isErrorHidden }, | ||
)} | ||
> | ||
<button | ||
type="button" | ||
className="delete" | ||
onClick={() => setIsErrorHidden(true)} | ||
aria-label="Delete Button" | ||
/> | ||
{errorMessage} | ||
<br /> | ||
</div> | ||
Comment on lines
+102
to
+115
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It also can be moved to separate component |
||
</div> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import { Todo } from '../types/Todo'; | ||
import { client } from '../utils/fetchClient'; | ||
|
||
interface UpdateCompleted { | ||
completed: boolean, | ||
} | ||
|
||
interface UpdateTitle { | ||
title: string | ||
} | ||
|
||
export const getTodos = (userId: number) => { | ||
return client.get<Todo[]>(`/todos?userId=${userId}`); | ||
}; | ||
|
||
export const addTodo = (userId: number, newTodo: Todo) => { | ||
return client.post<Todo>(`/todos?userId=${userId}`, newTodo); | ||
}; | ||
|
||
export const deleteTodo = (todoId: number) => { | ||
return client.delete(`/todos/${todoId}`); | ||
}; | ||
|
||
export const updateTodo = (todoId: number, | ||
data: UpdateCompleted | UpdateTitle) => { | ||
return client.patch<Todo>(`/todos/${todoId}`, data); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import React, { useContext, useState } from 'react'; | ||
|
||
interface ErrorContextType { | ||
errorMessage: string, | ||
setErrorMessage: React.Dispatch<React.SetStateAction<string>> | ||
isErrorHidden: boolean, | ||
setIsErrorHidden: React.Dispatch<React.SetStateAction<boolean>>, | ||
handleShowError: (error: string) => void, | ||
} | ||
|
||
export const ErrorMessageContext = React.createContext({} as ErrorContextType); | ||
|
||
type Props = { | ||
children: React.ReactNode, | ||
}; | ||
|
||
export const ErrorMessageContextProvider: React.FC<Props> = ({ children }) => { | ||
const [errorMessage, setErrorMessage] = useState(''); | ||
const [isErrorHidden, setIsErrorHidden] = useState(true); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You don't need There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I want my error to disappear smoothly, if I just setErrorMessage('') it will remove text and after hides the block. Which seems incorrect for me. |
||
|
||
const value = { | ||
errorMessage, | ||
setErrorMessage, | ||
isErrorHidden, | ||
setIsErrorHidden, | ||
handleShowError(error: string) { | ||
setErrorMessage(error); | ||
setIsErrorHidden(false); | ||
setTimeout(() => { | ||
setIsErrorHidden(true); | ||
}, 3000); | ||
}, | ||
}; | ||
|
||
return ( | ||
<ErrorMessageContext.Provider value={value}> | ||
{children} | ||
</ErrorMessageContext.Provider> | ||
); | ||
}; | ||
|
||
export const useErrorMessage = () => useContext(ErrorMessageContext); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
import React, { useContext, useState } from 'react'; | ||
|
||
interface LoadingTodosContextType { | ||
loadingTodos: number[], | ||
setLoadingTodos: React.Dispatch<React.SetStateAction<number[]>> | ||
} | ||
|
||
export const LoadingTodosContext = React.createContext( | ||
{} as LoadingTodosContextType, | ||
); | ||
|
||
type Props = { | ||
children: React.ReactNode, | ||
}; | ||
|
||
export const LoadingTodosContextProvider: React.FC<Props> = ({ children }) => { | ||
const [loadingTodos, setLoadingTodos] = useState<number[]>([]); | ||
|
||
const value = { | ||
loadingTodos, | ||
setLoadingTodos, | ||
}; | ||
|
||
return ( | ||
<LoadingTodosContext.Provider value={value}> | ||
{children} | ||
</LoadingTodosContext.Provider> | ||
); | ||
}; | ||
|
||
export const useLoadingTodos = () => useContext(LoadingTodosContext); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
import React, { useContext, useState } from 'react'; | ||
import { Todo } from '../../types/Todo'; | ||
|
||
interface TodosContextType { | ||
todos: Todo[], | ||
setTodos: React.Dispatch<React.SetStateAction<Todo[]>> | ||
} | ||
|
||
export const TodosContext = React.createContext({} as TodosContextType); | ||
|
||
type Props = { | ||
children: React.ReactNode, | ||
}; | ||
|
||
export const TodosContextProvider: React.FC<Props> = ({ children }) => { | ||
const [todos, setTodos] = useState<Todo[]>([]); | ||
|
||
const value = { | ||
todos, | ||
setTodos, | ||
}; | ||
|
||
return ( | ||
<TodosContext.Provider value={value}> | ||
{children} | ||
</TodosContext.Provider> | ||
); | ||
}; | ||
|
||
export const useTodos = () => useContext(TodosContext); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
import classNames from 'classnames'; | ||
import { useState } from 'react'; | ||
import { countTodos } from '../../utils/countTodos'; | ||
import { useTodos } from '../Contexts/TodosContext'; | ||
import { useErrorMessage } from '../Contexts/ErrorMessageContext'; | ||
import { USER_ID } from '../../utils/userToken'; | ||
import { useLoadingTodos } from '../Contexts/LoadingTodosContext'; | ||
import { addTodo, updateTodo } from '../../api/todos'; | ||
import { Todo } from '../../types/Todo'; | ||
|
||
type Props = { | ||
onWaiting: (tempTodo: Todo | null) => void, | ||
}; | ||
|
||
export const NewTodo: React.FC<Props> = ({ onWaiting }) => { | ||
const { todos, setTodos } = useTodos(); | ||
const { setLoadingTodos } = useLoadingTodos(); | ||
|
||
const [isAdding, setIsAdding] = useState(false); | ||
|
||
const { handleShowError } = useErrorMessage(); | ||
const [newTitle, setNewTitle] = useState(''); | ||
|
||
const uncompletedTodos = countTodos(todos, false); | ||
|
||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | ||
event.preventDefault(); | ||
if (!newTitle.trim()) { | ||
handleShowError('Title can\'t be empty'); | ||
|
||
return; | ||
} | ||
|
||
const tempTodo = { | ||
id: 0, | ||
completed: false, | ||
title: newTitle.trim(), | ||
userId: USER_ID, | ||
}; | ||
|
||
onWaiting(tempTodo); | ||
|
||
setIsAdding(true); | ||
try { | ||
const newTodo = await addTodo(USER_ID, tempTodo); | ||
|
||
setTodos(prev => [...prev, newTodo]); | ||
setNewTitle(''); | ||
} catch { | ||
handleShowError('Unable to add todo'); | ||
} | ||
|
||
onWaiting(null); | ||
setIsAdding(false); | ||
}; | ||
|
||
const completeAll = async () => { | ||
const changingTodos = uncompletedTodos.length | ||
? uncompletedTodos | ||
: todos; | ||
|
||
const todosPromises = changingTodos.map(async ({ id, completed }) => { | ||
return updateTodo(id, { completed: !completed }); | ||
}); | ||
|
||
try { | ||
changingTodos.forEach(({ id }) => { | ||
setLoadingTodos(prev => [...prev, id]); | ||
}); | ||
Comment on lines
+67
to
+69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You add one todoId in every iteration, you can create the ids array with |
||
const updatedTodos = await Promise.all(todosPromises); | ||
|
||
updatedTodos.forEach((updatedTodo) => { | ||
setTodos(prev => prev.map((currentTodo) => { | ||
if (currentTodo.id === updatedTodo.id) { | ||
return updatedTodo; | ||
} | ||
|
||
return currentTodo; | ||
})); | ||
setLoadingTodos(prevLoads => ( | ||
prevLoads.filter(id => id !== updatedTodo.id))); | ||
}); | ||
Comment on lines
+72
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here as well |
||
} catch { | ||
handleShowError('Something went wrong'); | ||
} | ||
}; | ||
|
||
return ( | ||
<header className="todoapp__header"> | ||
<button | ||
type="button" | ||
className={classNames('todoapp__toggle-all', { | ||
active: !uncompletedTodos.length, | ||
})} | ||
aria-label="NewTodo" | ||
onClick={completeAll} | ||
/> | ||
|
||
<form onSubmit={onSubmit}> | ||
<input | ||
type="text" | ||
className="todoapp__new-todo" | ||
placeholder="What needs to be done?" | ||
value={newTitle} | ||
onChange={(event) => setNewTitle(event.target.value)} | ||
disabled={isAdding} | ||
/> | ||
</form> | ||
</header> | ||
); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a good idea to show the loader while fetching something from a server
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still don't see a loader, when user have bad internet connection they don't know is there any todos or no