Skip to content

Commit

Permalink
solution
Browse files Browse the repository at this point in the history
  • Loading branch information
Kostiantyn committed Dec 22, 2024
1 parent 62dbb71 commit f05edcc
Show file tree
Hide file tree
Showing 14 changed files with 3,547 additions and 2,166 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ Implement the ability to edit a todo title on double click:

- Implement a solution following the [React task guideline](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline).
- Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript).
- Replace `<your_account>` with your Github username in the [DEMO LINK](https://<your_account>.github.io/react_todo-app-with-api/) and add it to the PR description.
- Replace `<your_account>` with your Github username in the [DEMO LINK](https://YoNiga7.github.io/react_todo-app-with-api/) and add it to the PR description.
5,174 changes: 3,028 additions & 2,146 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,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",
Expand Down
235 changes: 217 additions & 18 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,225 @@
/* eslint-disable max-len */
/* eslint-disable jsx-a11y/control-has-associated-label */
import React from 'react';
import { UserWarning } from './UserWarning';
import cn from 'classnames';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
import React, { useEffect, useMemo, useRef, useState } from 'react';

const USER_ID = 0;
import {
USER_ID,
addTodo,
getTodos,
deleteTodo,
updateTodo,
} from './api/todos';

import { Todo } from './types/Todo';
import { Status } from './types/Status';
import { TodoFilter } from './components/TodoFilter';
import { TodoItem } from './components/TodoItem';
import { ErrorType } from './types/ErrorType';
import { filterTodos } from './components/TodoFilter/filterTodos';

export const App: React.FC = () => {
if (!USER_ID) {
return <UserWarning />;
}
const [todos, setTodos] = useState<Todo[]>([]);
const [newTodoTitle, setNewTodoTitle] = useState('');
const [tempTodo, setTempTodo] = useState<Todo | null>(null);
const [errorMsg, setErrorMsg] = useState<ErrorType>(ErrorType.Default);
const [filter, setFilter] = useState<Status>(Status.All);
const [processingTodos, setProcessingTodos] = useState<number[]>([]);

const addInputRef = useRef<HTMLInputElement>(null);
const allAreCompleted = useMemo(
() => todos.every(todo => todo.completed),
[todos],
);
const hasTodos = useMemo(() => todos.length > 0, [todos]);

const filteredTodos = useMemo(
() => filterTodos(todos, filter),
[todos, filter],
);

const handleError = (error: ErrorType) => {
setErrorMsg(error);

setTimeout(() => {
setErrorMsg(ErrorType.Default);
}, 3000);
};

const onTodoAdd = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

if (!newTodoTitle.trim()) {
handleError(ErrorType.EmptyTitle);

return;
}

setTempTodo({
title: newTodoTitle.trim(),
id: 0,
userId: USER_ID,
completed: false,
});

addTodo(newTodoTitle.trim())
.then(newTodo => {
setTodos(prevTodos => [...prevTodos, newTodo]);
setNewTodoTitle('');
})
.catch(() => {
handleError(ErrorType.AddTodoFailed);
setTodos(todos);
})
.finally(() => {
setTempTodo(null);
});
};

// eslint-disable-next-line react-hooks/exhaustive-deps
const onTodoDelete = (todoId: number) => {
setProcessingTodos(prevProcessing => [...prevProcessing, todoId]);

return deleteTodo(todoId)
.then(() => {
setTodos(prev => prev.filter(prevTodo => prevTodo.id !== todoId));
})
.catch(() => {
handleError(ErrorType.DeleteTodoFailed);
})
.finally(() => {
setProcessingTodos(prevIds => prevIds.filter(id => id !== todoId));
});
};

const onTodoDeleteCompleted = () => {
const completedTodos = todos.filter(todo => todo.completed);

completedTodos.forEach(todo => onTodoDelete(todo.id));
};

const onTodoEdit = (todoId: number, updatedFields: Partial<Todo>) => {
setProcessingTodos(prev => [...prev, todoId]);

return updateTodo(todoId, updatedFields)
.then(res => {
setTodos(prevTodos =>
prevTodos.map(todo => {
return todo.id === todoId ? res : todo;
}),
);
})
.catch(error => {
handleError(ErrorType.UpdateTodoFailed);
throw error;
})
.finally(() => {
setProcessingTodos(prevIds =>
prevIds.filter(prevId => prevId !== todoId),
);
});
};

const onToggleCompletedAll = () => {
const todosToToggle = allAreCompleted
? [...todos]
: todos.filter(todo => !todo.completed);

todosToToggle.forEach(todo =>
onTodoEdit(todo.id, { completed: !todo.completed }),
);
};

useEffect(() => {
getTodos()
.then(setTodos)
.catch(() => handleError(ErrorType.LoadTodosFailed));
}, []);

useEffect(() => {
addInputRef.current?.focus();
}, [todos, tempTodo]);

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>

<header className="todoapp__header">
{/* this button should have `active` class only if all todos are completed */}
{hasTodos && (
<button
type="button"
className={cn('todoapp__toggle-all', { active: allAreCompleted })}
data-cy="ToggleAllButton"
onClick={onToggleCompletedAll}
/>
)}

{/* Add a todo on form submit */}
<form onSubmit={onTodoAdd}>
<input
disabled={!!tempTodo}
data-cy="NewTodoField"
type="text"
className="todoapp__new-todo"
placeholder="What needs to be done?"
ref={addInputRef}
value={newTodoTitle}
onChange={e => setNewTodoTitle(e.target.value)}
/>
</form>
</header>

<div className="todoapp__content">
<section className="todoapp__main" data-cy="TodoList">
<TransitionGroup>
{filteredTodos.map(todo => (
<CSSTransition key={todo.id} timeout={300} classNames="item">
<TodoItem
key={todo.id}
todo={todo}
onDelete={onTodoDelete}
processingTodos={processingTodos}
onEdit={onTodoEdit}
/>
</CSSTransition>
))}

{tempTodo && (
<CSSTransition key={0} timeout={300} classNames="temp-item">
<TodoItem todo={tempTodo} />
</CSSTransition>
)}
</TransitionGroup>
</section>
{/* Hide the footer if there are no todos */}
{hasTodos && (
<footer className="todoapp__footer" data-cy="Footer">
<TodoFilter
filter={filter}
onFilterChange={setFilter}
todos={todos}
onDelete={onTodoDeleteCompleted}
/>
</footer>
)}
</div>

<div
data-cy="ErrorNotification"
className={cn(
'notification is-danger is-light has-text-weight-normal',
{ hidden: !errorMsg },
)}
>
<button
data-cy="HideErrorButton"
type="button"
className="delete"
onClick={() => setErrorMsg(ErrorType.Default)}
/>
{errorMsg}
</div>
</div>
);
};
25 changes: 25 additions & 0 deletions src/api/todos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Todo } from '../types/Todo';
import { client } from '../utils/fetchClient';

export const USER_ID = 2176;

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

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

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

export const updateTodo = (todoId: number, updatedFields: Partial<Todo>) => {
return client.patch<Todo>(`/todos/${todoId}`, updatedFields);
};
// Add more methods here
72 changes: 72 additions & 0 deletions src/components/TodoFilter/TodoFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/* eslint-disable react/display-name */
import React, { memo } from 'react';
import cn from 'classnames';
import { Status } from '../../types/Status';
import { Todo } from '../../types/Todo';

type Props = {
filter: Status;
onFilterChange: (newFilter: Status) => void;
todos: Todo[];
onDelete: () => void;
};

export const TodoFilter: React.FC<Props> = memo(
({ filter, onFilterChange, todos, onDelete }) => {
const statuses = [
{ key: Status.All, label: 'All', dataCy: 'FilterLinkAll' },
{ key: Status.Active, label: 'Active', dataCy: 'FilterLinkActive' },
{
key: Status.Completed,
label: 'Completed',
dataCy: 'FilterLinkCompleted',
},
];

const handleLinkClick = (
e: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
status: Status,
) => {
e.preventDefault();
onFilterChange(status);
};

const uncompletedTodosCount = todos.reduce((acc, todo) => {
return todo.completed ? acc : acc + 1;
}, 0);

const hasCompleted = todos.some(todo => todo.completed);

return (
<>
<span className="todo-count" data-cy="TodosCounter">
{`${uncompletedTodosCount} items left`}
</span>

<nav className="filter" data-cy="Filter">
{statuses.map(({ key, label, dataCy }) => (
<a
key={key}
href={`#/${key}`}
data-cy={dataCy}
onClick={e => handleLinkClick(e, key)}
className={cn('filter__link', { selected: filter === key })}
>
{label}
</a>
))}
</nav>

<button
disabled={!hasCompleted}
type="button"
className="todoapp__clear-completed"
data-cy="ClearCompletedButton"
onClick={onDelete}
>
Clear completed
</button>
</>
);
},
);
14 changes: 14 additions & 0 deletions src/components/TodoFilter/filterTodos.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Status } from '../../types/Status';
import { Todo } from '../../types/Todo';

export function filterTodos(todos: Todo[], filter: Status) {
switch (filter) {
case Status.Active:
return todos.filter(todo => !todo.completed);
case Status.Completed:
return todos.filter(todo => todo.completed);

default:
return todos;
}
}
1 change: 1 addition & 0 deletions src/components/TodoFilter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './TodoFilter';
Loading

0 comments on commit f05edcc

Please sign in to comment.