Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
256 changes: 241 additions & 15 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,252 @@
/* eslint-disable max-len */
/* eslint-disable jsx-a11y/control-has-associated-label */
import React from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { UserWarning } from './UserWarning';

const USER_ID = 0;
import {
USER_ID,
getTodos,
addTodo,
deleteTodo,
updateTodo,
} from './api/todos';
import { Todo } from './types/Todo';
import { Status } from './types/Status';
import { ErrorMessage } from './types/ErrorMessage';
import { TodoList } from './components/TodoList';
import { Header } from './components/Header';
import { Footer } from './components/Footer';
import { ErrorNotification } from './components/ErrorNotification';

export const App: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<Status>(Status.All);
const [errorMessage, setErrorMessage] = useState('');

const [title, setTitle] = useState('');
const [tempTodo, setTempTodo] = useState<Todo | null>(null);
const [processingIds, setProcessingIds] = useState<number[]>([]);

const titleField = useRef<HTMLInputElement>(null);

const showError = (message: string) => {
setErrorMessage(message);
setTimeout(() => {
setErrorMessage('');
}, 3000);
};

useEffect(() => {
if (!USER_ID) {
return;
}

getTodos()
.then(setTodos)
.catch(() => showError(ErrorMessage.Load));
}, []);

useEffect(() => {
if (titleField.current) {
titleField.current.focus();
}
}, [todos.length]);

const handleAddTodo = (e: React.FormEvent) => {
e.preventDefault();
const normalizedTitle = title.trim();

if (!normalizedTitle) {
showError(ErrorMessage.EmptyTitle);

return;
}

setTempTodo({
id: 0,
title: normalizedTitle,
completed: false,
userId: USER_ID,
});

addTodo(normalizedTitle)
.then(newTodo => {
setTodos(currentTodos => [...currentTodos, newTodo]);
setTitle('');
})
.catch(() => {
showError(ErrorMessage.Add);
})
.finally(() => {
setTempTodo(null);
setTimeout(() => {
titleField.current?.focus();
}, 0);
});
};

const handleUpdateTodo = (todoId: number, data: Partial<Todo>) => {
setProcessingIds(current => [...current, todoId]);

return updateTodo(todoId, data)
.then(updatedTodo => {
setTodos(current =>
current.map(todo => (todo.id === todoId ? updatedTodo : todo)),
);
})
.catch(() => {
showError(ErrorMessage.Update);
throw new Error('Update failed');
})
.finally(() => {
setProcessingIds(current => current.filter(id => id !== todoId));
});
};

const handleToggleAll = () => {
const activeTodosCount = todos.filter(todo => !todo.completed).length;
const shouldComplete = activeTodosCount > 0;

const todosToUpdate = todos.filter(
todo => todo.completed !== shouldComplete,
);
const idsToUpdate = todosToUpdate.map(todo => todo.id);

setProcessingIds(current => [...current, ...idsToUpdate]);

Promise.all(
todosToUpdate.map(todo =>
updateTodo(todo.id, { completed: shouldComplete })
.then(updatedTodo => updatedTodo)
.catch(() => {
showError(ErrorMessage.Update);

return null;
}),
),
)
.then(results => {
const successfulUpdates = results.filter((r): r is Todo => r !== null);

setTodos(current =>
current.map(todo => {
const updated = successfulUpdates.find(u => u.id === todo.id);

return updated || todo;
}),
);
})
.finally(() => {
setProcessingIds(current =>
current.filter(id => !idsToUpdate.includes(id)),
);
});
};

const handleDeleteTodo = (todoId: number) => {
setProcessingIds(current => [...current, todoId]);

deleteTodo(todoId)
.then(() => {
setTodos(current => current.filter(todo => todo.id !== todoId));
})
.catch(() => {
showError(ErrorMessage.Delete);
})
.finally(() => {
setProcessingIds(current => current.filter(id => id !== todoId));
});
};

const handleClearCompleted = () => {
const completedTodos = todos.filter(todo => todo.completed);
const idsToDelete = completedTodos.map(todo => todo.id);

setProcessingIds(current => [...current, ...idsToDelete]);

Promise.all(
completedTodos.map(todo =>
deleteTodo(todo.id)
.then(() => todo.id)
.catch(() => {
showError(ErrorMessage.Delete);

return null;
}),
),
)
.then(results => {
const successfulIds = results.filter(id => id !== null);

setTodos(current =>
current.filter(todo => !successfulIds.includes(todo.id)),
);
})
.finally(() => {
setProcessingIds(current =>
current.filter(id => !idsToDelete.includes(id)),
);
});
};

const visibleTodos = todos.filter(todo => {
switch (filter) {
case Status.Active:
return !todo.completed;
case Status.Completed:
return todo.completed;
case Status.All:
default:
return true;
}
});

const activeTodosCount = todos.filter(todo => !todo.completed).length;
const hasCompletedTodos = todos.some(todo => todo.completed);

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">
<Header
title={title}
setTitle={setTitle}
onAddTodo={handleAddTodo}
activeTodosCount={activeTodosCount}
todosLength={todos.length}
tempTodo={tempTodo}
titleField={titleField}
onToggleAll={handleToggleAll}
/>

{(todos.length > 0 || tempTodo !== null) && (
<TodoList
todos={visibleTodos}
tempTodo={tempTodo}
processingIds={processingIds}
onDelete={handleDeleteTodo}
onUpdate={handleUpdateTodo}
/>
)}

{todos.length > 0 && (
<Footer
activeTodosCount={activeTodosCount}
filter={filter}
onFilterChange={setFilter}
hasCompletedTodos={hasCompletedTodos}
onClearCompleted={handleClearCompleted}
/>
)}
</div>

<ErrorNotification
errorMessage={errorMessage}
onClose={() => setErrorMessage('')}
/>
</div>
);
};
24 changes: 24 additions & 0 deletions src/api/todos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Todo } from '../types/Todo';
import { client } from '../utils/fetchClient';

export const USER_ID = 4213;

export const getTodos = () => {
return client.get<Todo[]>(`/todos?userId=${USER_ID}`);
};

export const addTodo = (title: string) => {
return client.post<Todo>('/todos', {
title,
completed: false,
userId: USER_ID,
});
};

export const deleteTodo = (todoId: number) => {
return client.delete(`/todos/${todoId}`);
};

export const updateTodo = (todoId: number, data: Partial<Todo>) => {
return client.patch<Todo>(`/todos/${todoId}`, data);
};
32 changes: 32 additions & 0 deletions src/components/ErrorNotification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import classNames from 'classnames';

type Props = {
errorMessage: string;
onClose: () => void;
};

export const ErrorNotification: React.FC<Props> = ({
errorMessage,
onClose,
}) => {
return (
<div
data-cy="ErrorNotification"
className={classNames(
'notification is-danger is-light has-text-weight-normal',
{
hidden: !errorMessage,
},
)}
>
<button
data-cy="HideErrorButton"
type="button"
className="delete"
onClick={onClose}
/>
{errorMessage}
</div>
);
};
28 changes: 28 additions & 0 deletions src/components/Filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';
import classNames from 'classnames';
import { Status } from '../types/Status';

type Props = {
filter: Status;
onFilterChange: (status: Status) => void;
};

export const Filter: React.FC<Props> = ({ filter, onFilterChange }) => {
return (
<nav className="filter" data-cy="Filter">
{Object.values(Status).map(statusValue => (
<a
key={statusValue}
href={`#/${statusValue === Status.All ? '' : statusValue}`}
className={classNames('filter__link', {
selected: filter === statusValue,
})}
data-cy={`FilterLink${statusValue.charAt(0).toUpperCase() + statusValue.slice(1)}`}
onClick={() => onFilterChange(statusValue)}
>
{statusValue.charAt(0).toUpperCase() + statusValue.slice(1)}
</a>
))}
</nav>
);
};
39 changes: 39 additions & 0 deletions src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from 'react';
import { Filter } from './Filter';
import { Status } from '../types/Status';

type Props = {
activeTodosCount: number;
filter: Status;
onFilterChange: (status: Status) => void;
hasCompletedTodos: boolean;
onClearCompleted: () => void;
};

export const Footer: React.FC<Props> = ({
activeTodosCount,
filter,
onFilterChange,
hasCompletedTodos,
onClearCompleted,
}) => {
return (
<footer className="todoapp__footer" data-cy="Footer">
<span className="todo-count" data-cy="TodosCounter">
{activeTodosCount} items left
</span>

<Filter filter={filter} onFilterChange={onFilterChange} />

<button
type="button"
className="todoapp__clear-completed"
data-cy="ClearCompletedButton"
disabled={!hasCompletedTodos}
onClick={onClearCompleted}
>
Clear completed
</button>
</footer>
);
};
Loading
Loading