Skip to content
Open

Develop #2260

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
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://mariiamad.github.io/react_todo-app-with-api/) and add it to the PR description.
6 changes: 3 additions & 3 deletions cypress/integration/page.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,7 @@ describe('', () => {
});

// this test may be flaky
it.skip('should replace loader with a created todo', () => {
it('should replace loader with a created todo', () => {
page.flushJSTimers();
todos.assertCount(6);
todos.assertNotLoading(5);
Expand Down Expand Up @@ -1515,7 +1515,7 @@ describe('', () => {
});

// It depend on your implementation
it.skip('should stay while waiting', () => {
it('should stay while waiting', () => {
page.mockUpdate(257334);

todos.title(0).trigger('dblclick');
Expand Down Expand Up @@ -1694,7 +1694,7 @@ describe('', () => {
});

// this test may be unstable
it.skip('should hide loader on fail', () => {
it('should hide loader on fail', () => {
// to prevent Cypress from failing the test on uncaught exception
cy.once('uncaught:exception', () => false);

Expand Down
26 changes: 22 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
"classnames": "^2.5.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.80.0",
"react-transition-group": "^4.4.5"
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
"@mate-academy/scripts": "^1.8.5",
"@mate-academy/scripts": "^2.1.3",
"@mate-academy/students-ts-config": "*",
"@mate-academy/stylelint-config": "*",
"@types/node": "^20.14.10",
Expand Down
271 changes: 258 additions & 13 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,271 @@
/* eslint-disable max-len */
/* eslint-disable jsx-a11y/control-has-associated-label */
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { UserWarning } from './UserWarning';
import { useForm } from 'react-hook-form';
import { Todo } from './types/Todo';
import classNames from 'classnames';
import {
getTodos,
USER_ID,
addTodo,
deleteTodo,
updateTodo,
} from './api/todos';
import { Footer } from './components/Footer';
import { Error } from './components/Error';
import { TodoList } from './components/TodoList';
import { errorMessages } from './constants/errorMessages';

const USER_ID = 0;
type FormValues = {
title: string;
};

const FILTERS = {
ALL: 'all',
ACTIVE: 'active',
COMPLETED: 'completed',
};

export const App: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState(FILTERS.ALL);
const [error, setError] = useState('');
const { register, handleSubmit, reset } = useForm<FormValues>();
const inputRef = useRef<HTMLInputElement | null>(null);
const [loadingIds, setLoadingIds] = useState<number[]>([]);
const [tempTodo, setTempTodo] = useState<Todo | null>(null);
const [isLoadingTodos, setIsLoadingTodos] = useState(true);
const [editingTodoId, setEditingTodoId] = useState<number | null>(null);

const isLoading = (id: number) => loadingIds.includes(id);

useEffect(() => {
inputRef.current?.focus();
}, []);

const showError = (message: string) => {
setError(message);

setTimeout(() => {
setError('');
}, 3000);
};

const onSubmit = handleSubmit(async data => {
if (!data.title || data.title.trim() === '') {
showError(errorMessages.TITLE_NOT_EMPTY);

return;
}

const title = data.title.trim();

setTempTodo({
id: 0,
title,
completed: false,
userId: USER_ID,
});
try {
setError('');

const newTodo = await addTodo({ title });

setTodos(current => [...current, newTodo]);

reset();
} catch {
showError(errorMessages.ADD_TODO);
} finally {
setTempTodo(null);
}
});

const allCompleted = todos.length > 0 && todos.every(todo => todo.completed);

const visibleTodos = todos.filter(todo => {
switch (filter) {
case FILTERS.ACTIVE:
return !todo.completed;

case FILTERS.COMPLETED:
return todo.completed;

default:
return true;
}
});

useEffect(() => {
setIsLoadingTodos(true);

getTodos()
.then(setTodos)
.catch(() => showError(errorMessages.LOAD_TODOS))
.finally(() => setIsLoadingTodos(false));
}, []);

useEffect(() => {
if (tempTodo === null && inputRef.current) {
inputRef.current.focus();
}
}, [tempTodo]);

if (!USER_ID) {
return <UserWarning />;
}

const toggleTodo = (id: number) => {
const todo = todos.find(t => t.id === id);

if (!todo) {
return;
}

setLoadingIds(prev => (prev.includes(id) ? prev : [...prev, id]));

updateTodo(id, { completed: !todo.completed })
.then(updatedTodo => {
setTodos(current => current.map(t => (t.id === id ? updatedTodo : t)));
})
.catch(() => {
showError(errorMessages.UPDATE_TODO);
})
.finally(() => {
setLoadingIds(ids => ids.filter(todoId => todoId !== id));
});
};

const toggleAll = async () => {
const shouldCompleteAll = !allCompleted;

const updates = todos.map(todo => ({
...todo,
completed: shouldCompleteAll,
}));

setTodos(updates);

const todosToUpdate = todos.filter(
todo => todo.completed !== shouldCompleteAll,
);

setLoadingIds(todosToUpdate.map(todo => todo.id));

try {
await Promise.all(
todosToUpdate.map(todo =>
updateTodo(todo.id, { completed: shouldCompleteAll }),
),
);
} catch {
showError(errorMessages.UPDATE_TODO);
} finally {
setLoadingIds([]);
}
};

const removeTodo = (id: number) => {
setLoadingIds(prev => [...prev, id]);

deleteTodo(id)
.then(() => {
setTodos(current => current.filter(todo => todo.id !== id));
})
.catch(() => {
showError(errorMessages.DELETE_TODO);
})
.finally(() => {
setLoadingIds(ids => ids.filter(todoId => todoId !== id));
inputRef.current?.focus();
});
};

const clearCompleted = () => {
const completedTodo = todos.filter(todo => todo.completed);

completedTodo.forEach(todo => {
removeTodo(todo.id);
});
};

const saveTodo = async (id: number, title: string) => {
setLoadingIds(prev => [...prev, id]);

const trimmedTitle = title?.trim() ?? '';

try {
await updateTodo(id, { title: trimmedTitle });

setTodos(current =>
current.map(t => (t.id === id ? { ...t, title: trimmedTitle } : t)),
);

setEditingTodoId(null);
} catch {
showError(errorMessages.UPDATE_TODO);
} finally {
setLoadingIds(ids => ids.filter(todoId => todoId !== id));
}
};

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 className="todoapp__header">
{!isLoadingTodos && todos.length > 0 && (
<button
type="button"
className={classNames('todoapp__toggle-all', {
active: allCompleted,
})}
data-cy="ToggleAllButton"
onClick={toggleAll}
/>
)}

{/* Add a todo on form submit */}
<form onSubmit={onSubmit}>
<input
{...register('title')}
ref={el => {
register('title').ref(el);
inputRef.current = el;
}}
data-cy="NewTodoField"
type="text"
className="todoapp__new-todo"
placeholder="What needs to be done?"
disabled={tempTodo !== null}
/>
</form>
</header>

<TodoList
visibleTodos={visibleTodos}
toggleTodo={toggleTodo}
removeTodo={removeTodo}
isLoading={isLoading}
tempTodo={tempTodo}
editingTodoId={editingTodoId}
setEditingTodoId={setEditingTodoId}
saveTodo={saveTodo}
/>

{todos.length > 0 && (
<Footer
todos={todos}
filter={filter}
setFilter={setFilter}
clearCompleted={clearCompleted}
/>
)}
</div>

<Error error={error} setError={setError} />
</div>
);
};
Loading
Loading