diff --git a/src/App.tsx b/src/App.tsx
index a399287bd..c9ff0965a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,157 +1,23 @@
-/* eslint-disable jsx-a11y/control-has-associated-label */
-import React from 'react';
+import React, { useContext } from 'react';
+
+import { Title } from './components/App/Title';
+import { Content } from './components/App/Content';
+import { Header } from './components/Header';
+import { Main } from './components/Main';
+import { Footer } from './components/Footer';
+import { TodoContext } from './context/TodoContext';
export const App: React.FC = () => {
+ const todos = useContext(TodoContext);
+
return (
-
todos
-
-
-
- {/* this button should have `active` class only if all todos are completed */}
-
-
- {/* Add a todo on form submit */}
-
-
-
-
-
- {/* Hide the footer if there are no todos */}
-
-
- 3 items left
-
-
- {/* Active link should have the 'selected' class */}
-
-
- All
-
-
-
- Active
-
-
-
- Completed
-
-
-
- {/* this button should be disabled if there are no completed todos */}
-
- Clear completed
-
-
-
+
+
+
+
+ {todos?.length !== 0 && }
+
);
};
diff --git a/src/components/App/Content.tsx b/src/components/App/Content.tsx
new file mode 100644
index 000000000..4d4593708
--- /dev/null
+++ b/src/components/App/Content.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+
+type Props = {
+ children: React.ReactNode;
+};
+
+const ContentBase: React.FC = ({ children }) => {
+ return {children}
;
+};
+
+export const Content = React.memo(ContentBase);
diff --git a/src/components/App/Title.tsx b/src/components/App/Title.tsx
new file mode 100644
index 000000000..76f7e2555
--- /dev/null
+++ b/src/components/App/Title.tsx
@@ -0,0 +1,7 @@
+import React from 'react';
+
+const TitleBase: React.FC = () => {
+ return todos
;
+};
+
+export const Title = React.memo(TitleBase);
diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx
new file mode 100644
index 000000000..94466ab98
--- /dev/null
+++ b/src/components/Footer/Footer.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import cn from 'classnames';
+
+import { useTodos } from '../../hooks/useTodos';
+import { useTodosView } from '../../hooks/useTodosView';
+
+type Props = {};
+
+const FooterBase: React.FC = () => {
+ const { uncompletedTodos, completedTodos, clearCompletedTodos } = useTodos();
+ const { filter, setFilter } = useTodosView();
+
+ return (
+
+ );
+};
+
+export const Footer = React.memo(FooterBase);
diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts
new file mode 100644
index 000000000..ddcc5a9cd
--- /dev/null
+++ b/src/components/Footer/index.ts
@@ -0,0 +1 @@
+export * from './Footer';
diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx
new file mode 100644
index 000000000..899e56769
--- /dev/null
+++ b/src/components/Header/Header.tsx
@@ -0,0 +1,61 @@
+import React, { useEffect, useRef, useState } from 'react';
+import cn from 'classnames';
+
+import { useTodos } from '../../hooks/useTodos';
+
+type Props = {};
+
+const HeaderBase: React.FC = () => {
+ const { toggleAllTodos, completedTodos, todos, addTodo } = useTodos();
+ const [title, setTitle] = useState('');
+
+ const newTodoFieldRef = useRef(null);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const titleToSave = title.trim();
+
+ if (titleToSave !== '') {
+ addTodo(titleToSave);
+ setTitle('');
+ }
+ };
+
+ const handleTitleChange = (e: React.ChangeEvent) => {
+ setTitle(e.target.value);
+ };
+
+ useEffect(() => {
+ if (newTodoFieldRef.current) {
+ newTodoFieldRef.current.focus();
+ }
+ }, [todos.length]);
+
+ return (
+
+ {todos.length !== 0 && (
+
+ )}
+
+
+ );
+};
+
+export const Header = React.memo(HeaderBase);
diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts
new file mode 100644
index 000000000..266dec8a1
--- /dev/null
+++ b/src/components/Header/index.ts
@@ -0,0 +1 @@
+export * from './Header';
diff --git a/src/components/Main/Main.tsx b/src/components/Main/Main.tsx
new file mode 100644
index 000000000..d54a20446
--- /dev/null
+++ b/src/components/Main/Main.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import { TodoList } from '../TodoList';
+
+type Props = {};
+
+const MainBase: React.FC = () => {
+ return (
+
+ );
+};
+
+export const Main = React.memo(MainBase);
diff --git a/src/components/Main/index.ts b/src/components/Main/index.ts
new file mode 100644
index 000000000..9e3006756
--- /dev/null
+++ b/src/components/Main/index.ts
@@ -0,0 +1 @@
+export * from './Main';
diff --git a/src/components/TodoList/TodoItem.tsx b/src/components/TodoList/TodoItem.tsx
new file mode 100644
index 000000000..192c0ad48
--- /dev/null
+++ b/src/components/TodoList/TodoItem.tsx
@@ -0,0 +1,129 @@
+import React, { useEffect, useRef, useState } from 'react';
+import cn from 'classnames';
+
+import { Todo } from '../../types/todo';
+import { useTodos } from '../../hooks/useTodos';
+
+type Props = {
+ todo: Todo;
+};
+
+const TodoItemBase: React.FC = ({ todo }) => {
+ const { id, title, completed } = todo;
+ const { deleteTodo, toggleTodoStatus, updateTodoTitle } = useTodos();
+
+ const [isEditing, setIsEditing] = useState(false);
+ const [newTitle, setNewTitle] = useState(title);
+
+ const newTitleRef = useRef(null);
+
+ const handleEditing = (e: React.ChangeEvent) => {
+ setNewTitle(e.target.value);
+ };
+
+ const handleTitleDoubleClick = () => {
+ setIsEditing(true);
+ };
+
+ const updateTodo = () => {
+ const titleToSave = newTitle.trim();
+
+ if (titleToSave !== '' && titleToSave !== title) {
+ updateTodoTitle(id, titleToSave);
+ }
+
+ if (titleToSave === '') {
+ deleteTodo(id);
+ }
+
+ setIsEditing(false);
+ };
+
+ const handleInputBlur = () => {
+ updateTodo();
+ };
+
+ const handleSubmitTitle = (e: React.FormEvent) => {
+ e.preventDefault();
+ updateTodo();
+ };
+
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ setNewTitle(title);
+ setIsEditing(false);
+ }
+ };
+
+ if (isEditing) {
+ document.addEventListener('keyup', handleEscape);
+ } else {
+ document.removeEventListener('keyup', handleEscape);
+ }
+
+ return () => {
+ document.removeEventListener('keyup', handleEscape);
+ };
+ }, [isEditing, title]);
+
+ useEffect(() => {
+ if (isEditing) {
+ newTitleRef.current?.focus();
+ }
+ }, [isEditing]);
+
+ return (
+
+
+ toggleTodoStatus(id)}
+ data-cy="TodoStatus"
+ type="checkbox"
+ className="todo__status"
+ checked={completed}
+ />
+
+ {isEditing ? (
+
+ ) : (
+
+ {title}
+
+ )}
+
+ {!isEditing && (
+ deleteTodo(id)}
+ >
+ ×
+
+ )}
+
+ );
+};
+
+export const TodoItem = React.memo(TodoItemBase);
diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx
new file mode 100644
index 000000000..2d921a917
--- /dev/null
+++ b/src/components/TodoList/TodoList.tsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import { TodoItem } from './TodoItem';
+import { useTodosView } from '../../hooks/useTodosView';
+
+type Props = {};
+
+const TodoListBase: React.FC = () => {
+ const { visibleTodos } = useTodosView();
+
+ return (
+ <>
+ {visibleTodos &&
+ visibleTodos.map(todo => )}
+ >
+ );
+};
+
+export const TodoList = React.memo(TodoListBase);
+
+//
+// {/* This is a completed todo */}
+//
+//
+//
+//
+//
+//
+// Completed Todo
+//
+//
+// {/* Remove button appears only on hover */}
+//
+// ×
+//
+//
+//
+// {/* This todo is an active todo */}
+//
+//
+//
+//
+//
+//
+// Not Completed Todo
+//
+//
+//
+// ×
+//
+//
+//
+// {/* This todo is being edited */}
+//
+//
+//
+//
+//
+// {/* This form is shown instead of the title and remove button */}
+//
+//
+//
+// {/* This todo is in loadind state */}
+//
+//
+//
+//
+//
+//
+// Todo is being saved now
+//
+//
+//
+// ×
+//
+//
diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts
new file mode 100644
index 000000000..f239f4345
--- /dev/null
+++ b/src/components/TodoList/index.ts
@@ -0,0 +1 @@
+export * from './TodoList';
diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx
new file mode 100644
index 000000000..9ba39b708
--- /dev/null
+++ b/src/context/TodoContext.tsx
@@ -0,0 +1,100 @@
+/* eslint-disable @typescript-eslint/indent */
+import React, { createContext, useReducer, useEffect, useState } from 'react';
+import { Todo } from '../types/todo';
+import { useTodoLocalStorage } from '../hooks/useTodoLocalStorage';
+
+type Filter = 'all' | 'active' | 'completed';
+
+export const TodoContext = createContext([]);
+export const TodoContextDispatch = createContext | null>(
+ null,
+);
+
+export const TodoFilterContext = createContext(null);
+export const TodoFilterContextDispatch = createContext
+> | null>(null);
+
+type Action =
+ | { type: 'ADD_TODO'; payload: Todo }
+ | { type: 'SET_TODOS'; payload: Todo[] }
+ | { type: 'DELETE_TODO'; payload: { id: Todo['id'] } }
+ | { type: 'TOGGLE_TODO_STATUS'; payload: { id: Todo['id'] } }
+ | { type: 'UPDATE_TODO_TITLE'; payload: { id: Todo['id']; title: string } };
+
+const reducer = (state: Todo[], action: Action) => {
+ if (action.type === 'ADD_TODO') {
+ return [...state, { ...action.payload }];
+ }
+
+ if (action.type === 'SET_TODOS') {
+ return action.payload;
+ }
+
+ if (action.type === 'DELETE_TODO') {
+ return state.filter(todo => todo.id !== action.payload.id);
+ }
+
+ if (action.type === 'TOGGLE_TODO_STATUS') {
+ const targetTodoIndex = state.findIndex(todo => {
+ return todo.id === action.payload.id;
+ });
+ const targetTodo = state[targetTodoIndex];
+
+ if (!targetTodo) {
+ return state;
+ }
+
+ return [
+ ...state.slice(0, targetTodoIndex),
+ { ...targetTodo, completed: !targetTodo?.completed },
+ ...state.slice(targetTodoIndex + 1),
+ ];
+ }
+
+ if (action.type === 'UPDATE_TODO_TITLE') {
+ const targetTodoIndex = state.findIndex(todo => {
+ return todo.id === action.payload.id;
+ });
+ const targetTodo = state[targetTodoIndex];
+
+ if (!targetTodo) {
+ return state;
+ }
+
+ return [
+ ...state.slice(0, targetTodoIndex),
+ { ...targetTodo, title: action.payload.title },
+ ...state.slice(targetTodoIndex + 1),
+ ];
+ }
+
+ return state;
+};
+
+type Props = {
+ children: React.ReactNode;
+};
+
+export const TodoProvider: React.FC = ({ children }) => {
+ const { getTodosFromLS, setTodosToLS } = useTodoLocalStorage();
+
+ const [todos, dispatch] = useReducer(reducer, [], getTodosFromLS);
+ const [filter, setFilter] = useState('all');
+
+ useEffect(() => {
+ setTodosToLS(todos);
+ }, [todos, setTodosToLS]);
+
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+};
diff --git a/src/hooks/useTodoLocalStorage.ts b/src/hooks/useTodoLocalStorage.ts
new file mode 100644
index 000000000..35a11afab
--- /dev/null
+++ b/src/hooks/useTodoLocalStorage.ts
@@ -0,0 +1,32 @@
+import { useCallback } from 'react';
+
+import { isTodosArray, Todo } from '../types/todo';
+
+const TODO_LS_KEY = 'todos';
+
+export const useTodoLocalStorage = () => {
+ const todosFromLSJSON = localStorage.getItem(TODO_LS_KEY);
+
+ const getTodosFromLS = (): Todo[] => {
+ if (!todosFromLSJSON) {
+ return [];
+ }
+
+ const todosFromLSParsed = JSON.parse(todosFromLSJSON);
+
+ if (isTodosArray(todosFromLSParsed)) {
+ return todosFromLSParsed;
+ } else {
+ return [];
+ }
+ };
+
+ const setTodosToLS = useCallback((todos: Todo[]) => {
+ localStorage.setItem(TODO_LS_KEY, JSON.stringify(todos));
+ }, []);
+
+ return {
+ setTodosToLS,
+ getTodosFromLS,
+ };
+};
diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts
new file mode 100644
index 000000000..d10228440
--- /dev/null
+++ b/src/hooks/useTodos.ts
@@ -0,0 +1,84 @@
+import { useContext, useMemo } from 'react';
+import { TodoContext, TodoContextDispatch } from '../context/TodoContext';
+import { Todo } from '../types/todo';
+
+export const useTodos = () => {
+ const todos = useContext(TodoContext);
+ const dispatch = useContext(TodoContextDispatch);
+
+ const uncompletedTodos = useMemo(() => {
+ return todos.filter(todo => !todo.completed);
+ }, [todos]);
+
+ const completedTodos = useMemo(() => {
+ return todos.filter(todo => todo.completed);
+ }, [todos]);
+
+ if (!dispatch) {
+ throw new Error('useTodos must be used within TodoProvider');
+ }
+
+ const addTodo = (title: Todo['title']) => {
+ if (title.trim() === '') {
+ return;
+ }
+
+ dispatch({
+ type: 'ADD_TODO',
+ payload: {
+ id: +new Date(),
+ title: title.trim(),
+ completed: false,
+ },
+ });
+ };
+
+ const deleteTodo = (todoId: Todo['id']) => {
+ dispatch({ type: 'DELETE_TODO', payload: { id: todoId } });
+ };
+
+ const clearCompletedTodos = () => {
+ completedTodos.forEach(todo => {
+ dispatch({ type: 'DELETE_TODO', payload: { id: todo.id } });
+ });
+ };
+
+ const toggleTodoStatus = (todoId: Todo['id']) => {
+ dispatch({ type: 'TOGGLE_TODO_STATUS', payload: { id: todoId } });
+ };
+
+ const toggleAllTodos = () => {
+ if (completedTodos.length === todos.length) {
+ completedTodos.forEach(todo => {
+ dispatch({ type: 'TOGGLE_TODO_STATUS', payload: { id: todo.id } });
+ });
+ } else {
+ uncompletedTodos.forEach(todo => {
+ dispatch({ type: 'TOGGLE_TODO_STATUS', payload: { id: todo.id } });
+ });
+ }
+ };
+
+ const updateTodoTitle = (todoId: Todo['id'], title: Todo['title']) => {
+ if (title.trim() === '') {
+ return;
+ }
+
+ dispatch({
+ type: 'UPDATE_TODO_TITLE',
+ payload: { id: todoId, title: title.trim() },
+ });
+ };
+
+ return {
+ todos,
+ uncompletedTodos,
+ completedTodos,
+ addTodo,
+ deleteTodo,
+ clearCompletedTodos,
+ toggleTodoStatus,
+ toggleAllTodos,
+ updateTodoTitle,
+ };
+};
diff --git a/src/hooks/useTodosView.ts b/src/hooks/useTodosView.ts
new file mode 100644
index 000000000..e1af3307b
--- /dev/null
+++ b/src/hooks/useTodosView.ts
@@ -0,0 +1,39 @@
+import { useContext, useMemo } from 'react';
+
+import {
+ TodoFilterContext,
+ TodoFilterContextDispatch,
+} from '../context/TodoContext';
+import { useTodos } from './useTodos';
+
+export const useTodosView = () => {
+ const { todos, completedTodos, uncompletedTodos } = useTodos();
+ const filter = useContext(TodoFilterContext);
+ const setFilter = useContext(TodoFilterContextDispatch);
+
+ if (!setFilter || !filter) {
+ throw new Error('useTodosView must be used within TodoProvider');
+ }
+
+ const visibleTodos = useMemo(() => {
+ if (filter === 'all') {
+ return todos;
+ }
+
+ if (filter === 'completed') {
+ return completedTodos;
+ }
+
+ if (filter === 'active') {
+ return uncompletedTodos;
+ }
+
+ return todos;
+ }, [todos, filter, completedTodos, uncompletedTodos]);
+
+ return {
+ visibleTodos,
+ setFilter,
+ filter,
+ };
+};
diff --git a/src/index.tsx b/src/index.tsx
index b2c38a17a..a4207b1fc 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -3,7 +3,12 @@ import { createRoot } from 'react-dom/client';
import './styles/index.scss';
import { App } from './App';
+import { TodoProvider } from './context/TodoContext';
const container = document.getElementById('root') as HTMLDivElement;
-createRoot(container).render( );
+createRoot(container).render(
+
+
+ ,
+);
diff --git a/src/styles/filters.scss b/src/styles/filters.scss
index 75b5804e5..fa0a68de7 100644
--- a/src/styles/filters.scss
+++ b/src/styles/filters.scss
@@ -4,13 +4,12 @@
&__link {
margin: 3px;
padding: 3px 7px;
+ border: 1px solid transparent;
+ border-radius: 3px;
color: inherit;
text-decoration: none;
- border: 1px solid transparent;
- border-radius: 3px;
-
&:hover {
border-color: rgba(175, 47, 47, 0.1);
}
diff --git a/src/styles/index.scss b/src/styles/index.scss
index d8d324941..2600b0d83 100644
--- a/src/styles/index.scss
+++ b/src/styles/index.scss
@@ -9,15 +9,15 @@ body {
}
.notification {
- transition-property: opacity, min-height;
- transition-duration: 1s;
min-height: 36px;
+ transition-duration: 1s;
+ transition-property: opacity, min-height;
}
.notification.hidden {
+ pointer-events: none;
min-height: 0;
opacity: 0;
- pointer-events: none;
}
@import './todoapp';
diff --git a/src/styles/todo-list.scss b/src/styles/todo-list.scss
index 4576af434..c125c81f0 100644
--- a/src/styles/todo-list.scss
+++ b/src/styles/todo-list.scss
@@ -5,9 +5,10 @@
grid-template-columns: 45px 1fr;
justify-items: stretch;
+ border-bottom: 1px solid #ededed;
+
font-size: 24px;
line-height: 1.4em;
- border-bottom: 1px solid #ededed;
&:last-child {
border-bottom: 0;
@@ -30,7 +31,6 @@
&__title {
padding: 12px 15px;
-
word-break: break-all;
transition: color 0.4s;
}
@@ -41,24 +41,27 @@
}
&__remove {
+ cursor: pointer;
+
position: absolute;
- right: 12px;
top: 0;
+ right: 12px;
bottom: 0;
+ transform: translateY(-2px);
+
+ float: right;
+
+ border: 0;
- font-size: 120%;
- line-height: 1;
font-family: inherit;
+ font-size: 120%;
font-weight: inherit;
+ line-height: 1;
color: #cc9a9a;
- float: right;
- border: 0;
+ opacity: 0;
background: none;
- cursor: pointer;
- transform: translateY(-2px);
- opacity: 0;
transition: color 0.2s ease-out;
&:hover {
@@ -71,21 +74,22 @@
}
&__title-field {
+ box-sizing: border-box;
width: 100%;
padding: 11px 14px;
+ border: 1px solid #999;
- font-size: inherit;
- line-height: inherit;
font-family: inherit;
+ font-size: inherit;
font-weight: inherit;
+ line-height: inherit;
color: inherit;
- border: 1px solid #999;
box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
&::placeholder {
- font-style: italic;
font-weight: 300;
+ font-style: italic;
color: #e6e6e6;
}
}
@@ -93,7 +97,6 @@
.overlay {
position: absolute;
inset: 0;
-
opacity: 0.5;
}
}
diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss
index e289a9458..1dea05606 100644
--- a/src/styles/todoapp.scss
+++ b/src/styles/todoapp.scss
@@ -1,9 +1,10 @@
.todoapp {
+ margin: 40px 20px;
+
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 24px;
font-weight: 300;
color: #4d4d4d;
- margin: 40px 20px;
&__content {
margin-bottom: 20px;
@@ -16,8 +17,8 @@
&__title {
font-size: 100px;
font-weight: 100;
- text-align: center;
color: rgba(175, 47, 47, 0.15);
+ text-align: center;
-webkit-text-rendering: optimizeLegibility;
-moz-text-rendering: optimizeLegibility;
text-rendering: optimizeLegibility;
@@ -28,52 +29,54 @@
}
&__toggle-all {
- position: absolute;
+ cursor: pointer;
- height: 100%;
- width: 45px;
+ position: absolute;
display: flex;
- justify-content: center;
align-items: center;
+ justify-content: center;
+
+ width: 45px;
+ height: 100%;
+ border: 0;
font-size: 24px;
color: #e6e6e6;
- border: 0;
background-color: transparent;
- cursor: pointer;
-
- &.active {
- color: #737373;
- }
&::before {
content: '❯';
transform: translateY(2px) rotate(90deg);
line-height: 0;
}
+
+ &.active {
+ color: #737373;
+ }
}
&__new-todo {
+ box-sizing: border-box;
width: 100%;
padding: 16px 16px 16px 60px;
+ border: none;
- font-size: 24px;
- line-height: 1.4em;
font-family: inherit;
+ font-size: 24px;
font-weight: inherit;
- color: inherit;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
+ line-height: 1.4em;
+ color: inherit;
- border: none;
background: rgba(0, 0, 0, 0.01);
box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03);
&::placeholder {
- font-style: italic;
font-weight: 300;
+ font-style: italic;
color: #e6e6e6;
}
}
@@ -84,18 +87,17 @@
&__footer {
display: flex;
- justify-content: space-between;
align-items: center;
+ justify-content: space-between;
box-sizing: content-box;
height: 20px;
padding: 10px 15px;
+ border-top: 1px solid #e6e6e6;
font-size: 14px;
-
color: #777;
text-align: center;
- border-top: 1px solid #e6e6e6;
box-shadow:
0 1px 1px rgba(0, 0, 0, 0.2),
@@ -106,22 +108,22 @@
}
&__clear-completed {
+ cursor: pointer;
+
margin: 0;
padding: 0;
border: 0;
font-family: inherit;
font-weight: inherit;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
color: inherit;
text-decoration: none;
- cursor: pointer;
- background: none;
-
-webkit-appearance: none;
appearance: none;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+ background: none;
&:hover {
text-decoration: underline;
diff --git a/src/types/todo.ts b/src/types/todo.ts
new file mode 100644
index 000000000..469eda1cb
--- /dev/null
+++ b/src/types/todo.ts
@@ -0,0 +1,39 @@
+export type Todo = {
+ title: string;
+ completed: boolean;
+ id: number;
+};
+
+export const isTodo = (value: unknown): value is Todo => {
+ if (
+ typeof value !== 'object' ||
+ value === null ||
+ !('title' in value) ||
+ !('completed' in value) ||
+ !('id' in value)
+ ) {
+ return false;
+ }
+
+ return !(
+ typeof value.title !== 'string' ||
+ typeof value.completed !== 'boolean' ||
+ typeof value.id !== 'number'
+ );
+};
+
+export const isTodosArray = (todos: unknown): todos is Todo[] => {
+ if (!Array.isArray(todos)) {
+ return false;
+ }
+
+ let isTodos = true;
+
+ todos.forEach(todo => {
+ if (!isTodo(todo)) {
+ isTodos = false;
+ }
+ });
+
+ return isTodos;
+};