-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
11 changed files
with
635 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,26 +1,220 @@ | ||
/* eslint-disable max-len */ | ||
/* eslint-disable jsx-a11y/label-has-associated-control */ | ||
/* eslint-disable jsx-a11y/control-has-associated-label */ | ||
import React from 'react'; | ||
import React, { useEffect, useState, useMemo, useRef } from 'react'; | ||
import { UserWarning } from './UserWarning'; | ||
|
||
const USER_ID = 0; | ||
import { | ||
USER_ID, | ||
getTodos, | ||
createTodo, | ||
deleteTodo, | ||
updateTodo, | ||
} from './api/todos'; | ||
import { TodoHeader } from './Components/TodoHeader'; | ||
import { TodoFooter } from './Components/TodoFooter'; | ||
import { Todo } from './types/Todo'; | ||
import { TodoFilter } from './types/TodoFilter'; | ||
import { TodoItem } from './Components/TodoItem'; | ||
import { Error } from './Components/Error'; | ||
import { TodoErrorType } from './types/TodoErrorType'; | ||
import { CSSTransition, TransitionGroup } from 'react-transition-group'; | ||
|
||
export const App: React.FC = () => { | ||
const [currentFilter, setCurrentFilter] = useState<TodoFilter>( | ||
TodoFilter.All, | ||
); | ||
const [todoList, setTodoList] = useState<Todo[]>([]); | ||
const [tempTodo, setTempTodo] = useState<Todo | null>(null); | ||
const [todoError, setTodoError] = useState<TodoErrorType>(TodoErrorType.None); | ||
const [loadingTodoIds, setLoadingTodoIds] = useState<number[]>([]); | ||
const [isAddingLoading, setIsAddingLoading] = useState(false); | ||
|
||
const todoTitleRef = useRef<HTMLInputElement>(null); | ||
|
||
const uncompletedTodos = useMemo( | ||
() => todoList.filter(todo => !todo.completed), | ||
[todoList], | ||
); | ||
|
||
const completedTodos = useMemo( | ||
() => todoList.filter(todo => todo.completed), | ||
[todoList], | ||
); | ||
|
||
const isAllTodoCompleted = useMemo(() => { | ||
return completedTodos.length === todoList.length; | ||
}, [completedTodos, todoList]); | ||
|
||
const filteredTodos = useMemo(() => { | ||
switch (currentFilter) { | ||
case TodoFilter.Active: | ||
return uncompletedTodos; | ||
case TodoFilter.Completed: | ||
return completedTodos; | ||
default: | ||
return todoList; | ||
} | ||
}, [completedTodos, currentFilter, todoList, uncompletedTodos]); | ||
|
||
const handleAddTodo = async (title: string): Promise<boolean> => { | ||
if (!title) { | ||
setTodoError(TodoErrorType.EmptyTitle); | ||
|
||
return false; | ||
} | ||
|
||
setTempTodo({ | ||
id: 0, | ||
title, | ||
userId: USER_ID, | ||
completed: false, | ||
}); | ||
|
||
setIsAddingLoading(true); | ||
try { | ||
const createdTodo = await createTodo(title); | ||
|
||
setTodoList(prevTodoList => [...prevTodoList, createdTodo]); | ||
|
||
return true; | ||
} catch (error) { | ||
setTodoError(TodoErrorType.UnableToAddTodo); | ||
|
||
return false; | ||
} finally { | ||
setTempTodo(null); | ||
setIsAddingLoading(false); | ||
} | ||
}; | ||
|
||
const handleUpdateTodo = async (todoToUpdate: Todo): Promise<boolean> => { | ||
setLoadingTodoIds(prev => [...prev, todoToUpdate.id]); | ||
|
||
try { | ||
const updatedTodo = await updateTodo(todoToUpdate); | ||
|
||
setTodoList(prevTodoList => | ||
prevTodoList.map(todo => | ||
todo.id === updatedTodo.id ? updatedTodo : todo, | ||
), | ||
); | ||
|
||
return true; | ||
} catch (error) { | ||
setTodoError(TodoErrorType.UnableToUpdateTodo); | ||
|
||
return false; | ||
} finally { | ||
setLoadingTodoIds(prev => | ||
prev.filter(todoId => todoId !== todoToUpdate.id), | ||
); | ||
} | ||
}; | ||
|
||
const handleDeleteTodo = async (id: number): Promise<boolean> => { | ||
setLoadingTodoIds(prev => [...prev, id]); | ||
|
||
try { | ||
await deleteTodo(id); | ||
setTodoList(prevTodoList => prevTodoList.filter(todo => todo.id !== id)); | ||
todoTitleRef.current?.focus(); | ||
|
||
return true; | ||
} catch (error) { | ||
setTodoError(TodoErrorType.UnableToDeleteTodo); | ||
|
||
return false; | ||
} finally { | ||
setLoadingTodoIds(prev => prev.filter(todoId => todoId !== id)); | ||
} | ||
}; | ||
|
||
const handleDeleteCompletedTodos = () => { | ||
completedTodos.forEach(todo => handleDeleteTodo(todo.id)); | ||
}; | ||
|
||
const handleToggleAllTodos = () => { | ||
if (uncompletedTodos.length > 0) { | ||
uncompletedTodos.forEach(todo => { | ||
handleUpdateTodo({ ...todo, completed: true }); | ||
}); | ||
} else { | ||
todoList.forEach(todo => { | ||
handleUpdateTodo({ ...todo, completed: false }); | ||
}); | ||
} | ||
}; | ||
|
||
useEffect(() => { | ||
const loadTodosFromServer = async () => { | ||
try { | ||
const todosFromServer = await getTodos(); | ||
|
||
setTodoList(todosFromServer); | ||
} catch (error) { | ||
setTodoError(TodoErrorType.UnableToLoadTodos); | ||
} | ||
}; | ||
|
||
loadTodosFromServer(); | ||
}, []); | ||
|
||
if (!USER_ID) { | ||
return <UserWarning />; | ||
} | ||
|
||
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> | ||
|
||
<p className="subtitle">Styles are already copied</p> | ||
</section> | ||
<div className="todoapp"> | ||
<h1 className="todoapp__title">todos</h1> | ||
|
||
<div className="todoapp__content"> | ||
<TodoHeader | ||
todoTitleRef={todoTitleRef} | ||
onAddTodo={handleAddTodo} | ||
onToggleAllTodos={handleToggleAllTodos} | ||
isLoading={isAddingLoading} | ||
isAllTodoCompleted={isAllTodoCompleted} | ||
isTodoListNotEmpty={!!todoList.length} | ||
/> | ||
|
||
<section className="todoapp__main" data-cy="TodoList"> | ||
<TransitionGroup> | ||
{filteredTodos.map(todo => ( | ||
<CSSTransition key={todo.id} timeout={300} classNames="item"> | ||
<TodoItem | ||
todo={todo} | ||
key={todo.id} | ||
onUpdateTodo={handleUpdateTodo} | ||
onDeleteTodo={handleDeleteTodo} | ||
isLoading={loadingTodoIds.includes(todo.id)} | ||
/> | ||
</CSSTransition> | ||
))} | ||
|
||
{tempTodo && ( | ||
<CSSTransition key={0} timeout={300} classNames="temp-item"> | ||
<TodoItem | ||
todo={tempTodo} | ||
onUpdateTodo={handleUpdateTodo} | ||
onDeleteTodo={handleDeleteTodo} | ||
isLoading={isAddingLoading} | ||
/> | ||
</CSSTransition> | ||
)} | ||
</TransitionGroup> | ||
</section> | ||
|
||
{!!todoList.length && ( | ||
<TodoFooter | ||
currentFilter={currentFilter} | ||
onFilterChange={setCurrentFilter} | ||
todosLeft={uncompletedTodos.length} | ||
completedTodos={completedTodos.length} | ||
onDeleteCompletedTodos={handleDeleteCompletedTodos} | ||
/> | ||
)} | ||
</div> | ||
|
||
<Error error={todoError} setError={setTodoError} /> | ||
</div> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import React, { Dispatch, SetStateAction, useEffect } from 'react'; | ||
import cn from 'classnames'; | ||
import { TodoErrorType } from '../types/TodoErrorType'; | ||
|
||
type Props = { | ||
error: TodoErrorType; | ||
setError: Dispatch<SetStateAction<TodoErrorType>>; | ||
}; | ||
|
||
export const Error: React.FC<Props> = ({ error, setError }) => { | ||
useEffect(() => { | ||
if (!error) { | ||
return; | ||
} | ||
|
||
const timerId = setTimeout(() => { | ||
setError(TodoErrorType.None); | ||
}, 3000); | ||
|
||
return () => { | ||
clearTimeout(timerId); | ||
}; | ||
}, [error, setError]); | ||
|
||
return ( | ||
<div | ||
data-cy="ErrorNotification" | ||
className={cn('notification is-danger is-light has-text-weight-normal', { | ||
hidden: !error, | ||
})} | ||
> | ||
<button | ||
data-cy="HideErrorButton" | ||
type="button" | ||
className="delete" | ||
onClick={() => setError(TodoErrorType.None)} | ||
/> | ||
{error} | ||
</div> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
import React, { Dispatch, SetStateAction } from 'react'; | ||
import cn from 'classnames'; | ||
import { TodoFilter } from '../types/TodoFilter'; | ||
|
||
type Props = { | ||
currentFilter: TodoFilter; | ||
onFilterChange: Dispatch<SetStateAction<TodoFilter>>; | ||
todosLeft: number; | ||
completedTodos: number; | ||
onDeleteCompletedTodos: () => void; | ||
}; | ||
|
||
export const TodoFooter: React.FC<Props> = ({ | ||
currentFilter, | ||
onFilterChange, | ||
todosLeft, | ||
completedTodos, | ||
onDeleteCompletedTodos, | ||
}) => { | ||
return ( | ||
<footer className="todoapp__footer" data-cy="Footer"> | ||
<span className="todo-count" data-cy="TodosCounter"> | ||
{todosLeft} items left | ||
</span> | ||
|
||
{/* Active link should have the 'selected' class */} | ||
<nav className="filter" data-cy="Filter"> | ||
{Object.values(TodoFilter).map(filter => ( | ||
<a | ||
key={filter} | ||
href={`#/${filter.toLowerCase()}`} | ||
className={cn('filter__link', { | ||
selected: currentFilter === filter, | ||
})} | ||
data-cy={`FilterLink${filter}`} | ||
onClick={() => onFilterChange(filter)} | ||
> | ||
{filter} | ||
</a> | ||
))} | ||
</nav> | ||
|
||
<button | ||
type="button" | ||
className="todoapp__clear-completed" | ||
data-cy="ClearCompletedButton" | ||
onClick={onDeleteCompletedTodos} | ||
disabled={!completedTodos} | ||
> | ||
Clear completed | ||
</button> | ||
</footer> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
import { useState, useEffect } from 'react'; | ||
import cn from 'classnames'; | ||
|
||
type Props = { | ||
todoTitleRef: React.RefObject<HTMLInputElement> | null; | ||
onAddTodo: (title: string) => Promise<boolean>; | ||
onToggleAllTodos: () => void; | ||
isLoading: boolean; | ||
isAllTodoCompleted: boolean; | ||
isTodoListNotEmpty: boolean; | ||
}; | ||
|
||
export const TodoHeader: React.FC<Props> = ({ | ||
todoTitleRef, | ||
onAddTodo, | ||
onToggleAllTodos, | ||
isLoading, | ||
isAllTodoCompleted, | ||
isTodoListNotEmpty, | ||
}) => { | ||
const [todoTitle, setTodoTitle] = useState(''); | ||
|
||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | ||
event.preventDefault(); | ||
const trimmedTitle = todoTitle.trim(); | ||
|
||
const success = await onAddTodo(trimmedTitle); | ||
|
||
if (success) { | ||
setTodoTitle(''); | ||
} | ||
}; | ||
|
||
useEffect(() => { | ||
if (!isLoading) { | ||
todoTitleRef?.current?.focus(); | ||
} | ||
}, [todoTitleRef, isLoading]); | ||
|
||
return ( | ||
<header className="todoapp__header"> | ||
{isTodoListNotEmpty && ( | ||
<button | ||
type="button" | ||
className={cn('todoapp__toggle-all', { active: isAllTodoCompleted })} | ||
data-cy="ToggleAllButton" | ||
onClick={onToggleAllTodos} | ||
/> | ||
)} | ||
|
||
<form onSubmit={onSubmit}> | ||
<input | ||
data-cy="NewTodoField" | ||
type="text" | ||
className="todoapp__new-todo" | ||
placeholder="What needs to be done?" | ||
ref={todoTitleRef} | ||
value={todoTitle} | ||
onChange={event => setTodoTitle(event.target.value)} | ||
disabled={isLoading} | ||
/> | ||
</form> | ||
</header> | ||
); | ||
}; |
Oops, something went wrong.