Skip to content

Commit

Permalink
add task solution
Browse files Browse the repository at this point in the history
  • Loading branch information
zhudim committed Jan 9, 2025
1 parent 62dbb71 commit c94bd37
Show file tree
Hide file tree
Showing 11 changed files with 635 additions and 18 deletions.
224 changes: 209 additions & 15 deletions src/App.tsx
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>
);
};
41 changes: 41 additions & 0 deletions src/Components/Error.tsx
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>
);
};
54 changes: 54 additions & 0 deletions src/Components/TodoFooter.tsx
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>
);
};
65 changes: 65 additions & 0 deletions src/Components/TodoHeader.tsx
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>
);
};
Loading

0 comments on commit c94bd37

Please sign in to comment.