Написать пост

Создание ToDo-листа бесконечной вложенности на React, TypeScript и MobX

Аватарка пользователя Viktor

Рассказываем, как создать Todo-list с бесконечной вложенностью подзадач. Для создания приложения используются React, TypeScript и Mobx.

Обложка поста Создание ToDo-листа бесконечной вложенности на React, TypeScript и MobX

Всем доброго времени суток! В данной статье я расскажу о том, как создать Todo-list с бесконечной вложенностью подзадач. Для создания приложения я буду использовать React, TypeScript и Mobx. С полным кодом вы можете ознакомиться в репозитории проекта.

Задачи

  1. Возможность добавлять новые задачи в список дел;
  2. Каждая задача состоит из заголовка и опционального описания;
  3. Возможность добавлять неограниченное количество подзадач в любую существующую задачу;
  4. Возможность удалять задачи из списка. Удаление задачи верхнего уровня приводит к удалению всех вложенных подзадач;
  5. Возможность изменять статус задачи. Изменение статуса задачи верхнего уровня устанавливает аналогичный статус для всех вложенных подзадач;
  6. При клике на задачу, можно просматривать подробную информацию о задаче.

Настройка проекта

Создадим React-проект с TypeScript-шаблоном, выполнив в терминале следующую команду: npx create-react-app infinite-todo-list --template typescript.

После завершения установки перейдем в директорию проекта и установим Mobx (npm install mobx mobx-react-lite).

В директории src оставим следующие файлы: index.tsx, App.tsx, index.css, react-app-env.d.ts. Создадим файловую структуру проекта, папки: components, store, utils, types, style, assets. В папку assets сложим необходимые иконки.

Создание UI компонентов

Нам потребуются: Button, Checkbox, Input, TextArea, Header и ModalWindow.

Начнем с компонента Header. В качестве props он будет принимать функцию переключатель для ModalWindow. Возвращать будет header с логотипом, названием приложения и кнопкой добавления новой задачи.

			import { FunctionComponent } from 'react';

import logo from '../../assets/images/logo.svg';
import addIcon from '../../assets/icons/add.svg';

import styles from '../../style/ui/header.module.scss';

type HeaderProps = {
  modalToggler: () => void;
}

const Header: FunctionComponent<HeaderProps> = ( {modalToggler} ) => {
  return (
    <header className={`container ${styles.header}`}>
      <img src={logo} alt='logo' className={styles.logo} />
      <h1 className='largeHeader'>ToDo List</h1>
      <img 
        src={addIcon} 
        onClick={modalToggler}
        alt='add todo icon' 
        className={styles.addIcon} 
        tabIndex={0}
      />
    </header>
  );
};

export default Header;
		

Далее создадим компонент Button. В качестве props он будет принимать только текст для кнопки. Все остальные свойства мы будем получать от html-элемента button, используя встроенный тип ComponentPropsWithoutRef.

			import { FunctionComponent, ComponentPropsWithoutRef } from 'react';

import styles from '../../style/ui/button.module.scss';

interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
  btnText: string;
};

const Button: FunctionComponent<ButtonProps> = ( {btnText, ...props} ) => {
  return (
    <button {...props} className={styles.button}>
      {btnText}
    </button>
  );
};

export default Button;
		

Аналогичным образом создаем остальные компоненты.

Компонент Checkbox.

			import { FunctionComponent, ComponentPropsWithoutRef } from 'react';

import styles from '../../style/ui/input.module.scss';

interface CheckboxProps extends ComponentPropsWithoutRef<'input'> {
  id: string;
};

const Checkbox: FunctionComponent<CheckboxProps> = ( {id, ...props} ) => {
  return (
    <>
      <input 
        {...props} 
        className={styles.checkbox}
        type='checkbox'
        id={`checkbox-${id}`}
      />
      <label htmlFor={`checkbox-${id}`} className={styles.checkboxLabel} />
    </>
  );
};

export default Checkbox;
		

Компонент Input.

			import { FunctionComponent, ComponentPropsWithoutRef } from 'react';

import styles from '../../style/ui/input.module.scss';

const Input: FunctionComponent< ComponentPropsWithoutRef<'input'> > = ( {...props} ) => {
  return (
    <input 
      {...props} 
      className={styles.input} 
      type='text'
    />
  );
};

export default Input;
		

И компонент TextArea.

			import { FunctionComponent, ComponentPropsWithoutRef } from 'react';

import styles from '../../style/ui/input.module.scss';

const TextArea: FunctionComponent< ComponentPropsWithoutRef<'textarea'> > = ( {...props} ) => {
  return (
    <textarea {...props} className={`${styles.input} ${styles.textarea}`} />
  );
};

export default TextArea;
		

Теперь создадим компонент ModalWindow. В качестве props он будет принимать функцию переключатель и children. Возвращать будет блок div с компонентами Input (для заголовка задачи), TextArea (для описания задачи) и Button (для закрытия окна).

			import { FunctionComponent } from 'react';

import styles from '../../style/ui/modalWindow.module.scss';

import Button from './Button';
import Input from './Input';
import TextArea from './TextArea';

type ModalProps = {
  children: JSX.Element | JSX.Element[];
  modalToggler: () => void;
}

const ModalWindow:FunctionComponent<ModalProps> = ( {children, modalToggler} ) => {
  return (
    <div className={styles.blackout}>
      <div className={`${styles.flexColumn} ${styles.controls}`}>
        <div className={styles.flexColumn}>
          <Input 
            placeholder='Todo title...'
          />
          <TextArea 
            placeholder='Todo text...'
          />
        </div>
        {children}
        <Button 
          btnText='close window' 
          onClick={modalToggler}
        />
      </div>
    </div>
  );
};

export default ModalWindow;
		

В App импортируем компоненты ModalWindow, Header, и Button. Создаем локальное состояние isModalShown и функцию-переключатель этого состояния.

Используем условный рендеринг, если isModalShown true, то будем отображать ModalWindow. В качестве children для ModalWindow передадим компонент Button (кнопку для добавления задач).

			import { FunctionComponent, useState } from 'react';

import Button from './ui/Button';
import Header from './ui/Header';
import Footer from './ui/Footer';
import ModalWindow from './ui/ModalWindow';

const App:FunctionComponent = () => {
  const [isModalShown, setIsModalShown] = useState(false);

  function modalWindowToggler() {
    setIsModalShown(prevModalState => !prevModalState);
  }

  return (
    <>
      <Header modalToggler={modalWindowToggler} />
      <main className='container main'>
        {
          isModalShown && 
            <ModalWindow modalToggler={modalWindowToggler}>
              <Button 
                btnText='add todo' 
              />
            </ModalWindow>
        }
      </main>
      <Footer />
    </>
  );
};

export default App;
		

Создание вспомогательных функций

Работу над логикой приложения начнем с описания типа для Todo-листа. В папке types создадим одноименный файл и экспортируем из него TodoType.

			export type TodoType = {
  id: string;
  title: string;
  text: string;
  isCompleted: boolean;
  subTasks: TodoType[];
};
		

Далее в папке utils создаем файл utils.ts, в котором создадим вспомогательные функции. Нам нужны функции для добавления подзадачи, удаления задачи, изменения состояния задачи и выбора задачи. Так как список у нас бесконечной вложенности, то все эти функции будут рекурсивными.

Импортируем тип TodoType и опишем типы для наших функций.

			import { TodoType } from '../types/types';

type SubTaskAddingProps = (
  id: string,
  array: TodoType[],
  task: TodoType,
) => TodoType[];

type RecursionProps = (
  id: string,
  array: TodoType[],
) => TodoType[];

type SearchProps = (
  id: string,
  array: TodoType[],
) => TodoType | null;

type CompleteTogglerProps = (
  array: TodoType[],
  state: boolean,
) => TodoType[];
		

Теперь приступим к созданию самих функций.

Функция subTaskAdding будет использоваться для добавления подзадач. В качестве аргументов она будет принимать id задачи, в которую добавляется подзадача, массив типа TodoType и саму подзадачу. Возвращать будет новый массив типа TodoType. Внутри функции мы будем проходить по массиву, используя метод reduce, и сравнивать id элемента с id задачи. Если id равны, тогда добавляем задачу в массив подзадач данного элемента. Если id не равны, то вызываем эту же функцию subTaskAdding на массиве подзадач этого элемента.

			export const subTaskAdding:SubTaskAddingProps = (id, array, task) => {
  return array.reduce((arr: TodoType[], item) => {
    if (item.id === id) {
        item.subTasks.push(task);
        arr.push(item);
    } else {
        arr.push({...item, subTasks: subTaskAdding(id, item.subTasks, task)});
    }

    return arr;
  }, []);
};
		

Следующая функция recursionFilter будет использоваться для удаления задачи из списка. На вход данная функция будет принимать массив типа TodoType и id задачи, которую нужно удалить. Возвращать будет отфильтрованный массив типа TodoType. Внутри функции также используем метод reduce и сравниваем id элементов массива с id задачи. Если они не равны, то будем добавлять этот элемент в возвращаемый массив, а для массива подзадач будем вызывать эту же функцию recursionFilter.

			export const recursionFilter:RecursionProps = (id, array) => {
  return array.reduce((arr: TodoType[], item) => {
    if (item.id !== id) {
      arr.push({...item, subTasks: recursionFilter(id, item.subTasks)});
    } 

    return arr;
  }, []);
};
		

Следующая функция recursionSearch будет использоваться для поиска активной (выбранной для подробного просмотра) задачи. На вход эта функция принимает массив типа TodoType и id выбранной задачи. Возвращать эта функция будет найденную задачу, либо null, если ничего не найдено. Внутри функции, воспользуемся циклом for of для прохода по массиву. Внутри цикла мы сравниваем id задачи с id элемента массива. Если они равны, возвращаем найденный элемент. Если не равны, то создаем новую переменную subItem, которой присваиваем результат вызова данной функции recursionSearch на массиве подзадач текущего элемента массива. Если subItem существует, то возвращаем его.

			export const recursionSearch:SearchProps = (id, array) => {
  for (let item of array) {
    if (item.id === id) {
      return item;
    }

    const subItem = recursionSearch(id, item.subTasks);
    
    if (subItem) {
      return subItem;
    }
  }

  return null;
};
		

Изменение статуса задачи мы разделим на две функции. Первая функция recursionCompleteToggler будет изменять статус выбранной задачи. Вторая функция subTasksCompleteTogglerбудет изменять статус всех вложенных подзадач.

Функция recursionCompleteToggler будет принимать на вход массив типа TodoType и id задачи, а возвращать будет новый массив типа TodoType. Внутри функции методом reduce проходим по массиву и сравниваем id. Когда id не равны, вызываем эту же функцию recursionCompleteToggler для массива подзадач текущего элемента. Если id равны, то изменяем значение isCompleted текущего элемента на противоположное, а для массива подзадач вызываем функцию subTasksCompleteToggler.

Функция subTasksCompleteToggler в качестве аргументов принимает массив типа TodoType и значение состояния, которое необходимо установить для всех вложенных подзадач. Возвращает эта функция измененный массив типа TodoType. Внутри функции также будем использовать метод reduce. Проходим по массиву и каждому элементу для значения isCompleted задаем состояние, которое приняли в качестве аргумента. Для массива подзадач также вызываем функцию subTasksCompleteToggler.

			export const recursionCompleteToggler:RecursionProps = (id, array) => {
  return array.reduce((arr: TodoType[], item) => {
    if (item.id !== id) {
        arr.push({...item, subTasks: recursionCompleteToggler(id, item.subTasks)});
    } else {
        arr.push({
          ...item, 
          isCompleted: !item.isCompleted, 
          subTasks: subTasksCompleteToggler(item.subTasks, !item.isCompleted)
        });
    }

    return arr;
  }, []);
};

export const subTasksCompleteToggler:CompleteTogglerProps = (array, state) => {
  return array.reduce((arr: TodoType[], item) => {
    arr.push({
      ...item, 
      isCompleted: state, 
      subTasks: subTasksCompleteToggler(item.subTasks, state)
    });

    return arr;
  }, []);
};
		

Работа с Mobx

Теперь приступим к работе с Mobx. В папке store создадим файл todos.ts, этот файл будет содержать всю логику работы с состоянием приложения. В этот файл импортируем созданные вспомогательные функции и тип TodoType. Для генерации уникальных id для каждой задачи воспользуемся библиотекой uuid.

Создадим класс Todos и в конструкторе класса вызовем функцию makeAutoObservable, которой параметром передадим контекст текущего класса. Далее внутри класса создадим переменные состояния: todoArray, activeTask, todoTitle и todoText.

Переменные todoTitle и todoText будут отвечать за заголовок и описание новой задачи, activeTask будет содержать выбранную задачу, либо null, если задача не выбрана.

Массив todoArray будет хранить весь список задач. Сразу реализуем работу с localstorage. При создании массива todoArray, будем проверять, содержит ли localstorage ключ todos, если да, то загружаем значение из localstorage, а если такого ключа нет, то присваиваем todoArray пустой массив.

Далее приступим к созданию методов класса для работы с состоянием. Так как в Mobx состояние является изменяемым, то внутри методов мы будем просто изменять необходимые значения.

Функции titleHandler и textHandler будут в качестве аргумента принимать строку и присваивать эту строку соответствующим переменным.

Функция addTask будет формировать новый объект задачи и добавлять его в массив todoArray. После добавления новой задачи в массив, мы сохраним массив в localStorage и сбросим значения переменных todoTitle и todoText.

Функция addSubtask будет в качестве аргумента принимать id задачи, в которую необходимо добавить подзадачу. Внутри функции мы создаем объект задачи и вызываем функцию subTaskAdding, в которую передаем id, массив todoArray и только что созданный объект задачи. Результат вызова этой функции мы присваиваем в todoArray. Далее, аналогично функции addTask, сохраняем todoArray в localstorage и сбрасываем значения todoTitle и todoText.

Функция removeTask будет принимать id задачи, которую необходимо удалить. Внутри функции мы вызываем функцию recursionFilter, в которую передаем id и todoArray, результат вызова этой функции присваиваем в todoArray и сохраняем его в localstorage. После этого нам необходимо проверить, остались ли еще задачи в todoArray. Если массив пуст, то удаляем ключ todos из localstorage и сбрасываем значение activeTask.

Функция completeToggler также будет принимать id задачи. Внутри функции мы вызываем функцию recursionCompleteToggler, которой передаем id и todoArray. Результат вызова записываем в todoArray и перезаписываем localstorage.

Последняя функция chooseTask также принимает id задачи. Внутри функции мы вызываем recursionSearch, которой передаем id и todoArray. А результат выполнения присваиваем в переменную activeTask.

Осталось экспортировать экземпляр данного класса.

			import { makeAutoObservable } from 'mobx';
import { v4 as uuidv4 } from 'uuid';

import { TodoType } from '../types/types';
import { recursionFilter, recursionCompleteToggler, recursionSearch, subTaskAdding } from '../utils/utils';

class Todos {
  todoArray:TodoType[] = localStorage.todos ? JSON.parse(localStorage.todos) : [];
  activeTask:TodoType | null = null;
  todoTitle = '';
  todoText = '';

  constructor() {
    makeAutoObservable(this)
  }

  titleHandler = (str: string) => {
    this.todoTitle = str;
  }

  textHandler = (str: string) => {
    this.todoText = str;
  }

  addTask = () => {
    if (this.todoTitle.trim().length) {
      this.todoArray.push({
        id: uuidv4(),
        title: this.todoTitle,
        text: this.todoText,
        isCompleted: false,
        subTasks: [],
      });

      localStorage.setItem('todos', JSON.stringify(this.todoArray));
      this.todoTitle = '';
      this.todoText = '';
    }
  }

  addSubtask = (id: string) => {
    if (this.todoTitle.trim().length) {
      const task = {
        id: uuidv4(),
        title: this.todoTitle,
        text: this.todoText,
        isCompleted: false,
        subTasks: [],
      };

      this.todoArray = subTaskAdding(id, this.todoArray, task);
      localStorage.setItem('todos', JSON.stringify(this.todoArray));
      this.todoTitle = '';
      this.todoText = '';
    }
  }

  removeTask = (id: string) => {
    this.todoArray = recursionFilter(id, this.todoArray);
    localStorage.setItem('todos', JSON.stringify(this.todoArray));

    if (!this.todoArray.length) {
      this.activeTask = null;
      localStorage.removeItem('todos');
    }
  }

  completeToggler = (id: string) => {
    this.todoArray = recursionCompleteToggler(id, this.todoArray);
    localStorage.setItem('todos', JSON.stringify(this.todoArray));
  }

  chooseTask = (id: string) => {
    this.activeTask = recursionSearch(id, this.todoArray);
  }
}

const todos = new Todos();

export default todos;
		

Создание основных компонентов

Начнем с создания компонента отдельной задачи – TodoItem. Импортируем необходимые иконки и ui – компоненты: Checkbox, ModalWindow и Button. Также нам понадобятся тип TodoType, и класс todos. В качестве props этот компонент будет принимать один объект типа TodoType, который мы сразу же деструктурируем.

Этот компонент будет использовать два локальных состояния: isModalShown будет отвечать за отображение окна добавления подзадач, а isSubTasksShown будет отвечать за отображение списка подзадач. Создадим две функции для переключения этих состояний: modalWindowToggler и subTasksToggler.

Далее воспользуемся условным рендерингом и если isModalShown – true, то будем отображать компонент ModalWindow, которому в качестве children передадим компонент Button. Событию onClick этой кнопки мы зададим метод addSubtask класса todos.

Для иконки chevronIcon в событие onClick мы назначим функцию isSubTasksShown, таким образом, при нажатии на эту иконку будет раскрываться список всех вложенных подзадач для данной задачи.

Для заголовка задачи мы также добавим событие onClick, которому зададим метод chooseTask класса todos. Таким образом, при нажатии на заголовок задачи, эта задача выберется как активная и откроется описание данной задачи.

Для иконки addIcon в событие onClick мы зададим функцию modalWindowToggler. При нажатии на эту кнопку будет отображаться окно для добавления подзадачи.

Для компонента Checkbox событию onChange назначим метод completeToggler класса todos.

Для иконки удаления deleteIcon, в событие onClick мы зададим метод removeTask класса todos.

Теперь проверим, есть ли у данной задачи вложенные подзадачи. Если длина массива subTasks больше 0, то будем методом map проходить по массиву подзадач и для каждой подзадачи отрисовывать компонент TodoItem. Таким образом мы получили рекурсивный компонент.

Для того, чтобы сделать этот компонент наблюдаемым для mobx нам необходимо импортировать функцию observer из mobx-react-lite и обернуть компонент в эту функцию.

			import { FunctionComponent, useState } from 'react';
import { observer } from 'mobx-react-lite';

import addIcon from '../assets/icons/add.svg';
import deleteIcon from '../assets/icons/delete.svg';
import chevronIcon from '../assets/icons/chevron.svg';

import styles from '../style/todos.module.scss';

import { TodoType } from '../types/types';
import todos from '../store/todos';

import Checkbox from './ui/Checkbox';
import ModalWindow from './ui/ModalWindow';
import Button from './ui/Button';

type TodoItemProps = {
  todoItem: TodoType;
};

const TodoItem:FunctionComponent<TodoItemProps> = observer(( {todoItem} ) => {
  const {id, title, isCompleted, subTasks} = todoItem;
  const [isModalShown, setIsModalShown] = useState(false);
  const [isSubTasksShown, setIsSubTasksShown] = useState(false);

  function modalWindowToggler() {
    setIsModalShown(prevModalState => !prevModalState);
  }

  function subTasksToggler() {
    setIsSubTasksShown(prevSubTasks => !prevSubTasks);
  }

  return (
    <>
      {
        isModalShown && 
          <ModalWindow modalToggler={modalWindowToggler}>
            <Button 
              btnText='add todo' 
              onClick={() => todos.addSubtask(id)}
            />
          </ModalWindow>
      }
      <div className={styles.todoItem}>
        <img 
          src={chevronIcon} 
          alt='' 
          className={isSubTasksShown ? `${styles.icons} ${styles.rotated}` : styles.icons}
          onClick={subTasksToggler}
        />
        <h3 
          className={`midHeader ${styles.title}`}
          onClick={() => todos.chooseTask(id)}
        >
          {title}
        </h3>
        <img 
          src={addIcon} 
          alt='add subtask' 
          className={styles.icons}
          onClick={modalWindowToggler}
        />
        <Checkbox 
          id={id}
          checked={isCompleted}
          onChange={() => todos.completeToggler(id)}
        />
        <img 
          src={deleteIcon} 
          alt='delete task' 
          className={styles.icons}
          onClick={() => todos.removeTask(id)}
        />
      </div>
      {
        subTasks.length > 0 &&
          <div className={isSubTasksShown ? styles.subTasks : styles.hide}>
            {subTasks.map(subTask => <TodoItem key={subTask.id} todoItem={subTask} />)}
          </div>
      }
    </>
  );
});

export default TodoItem;
		

Теперь создадим компонент TodoList, который будет служить для отображения всего списка задач. Импортируем компонент TodoItem и класс todos.

Внутри компонента просто проходим по массиву todoArray, который мы берем из класса todos и на каждый элемент массива отрисовываем компонент TodoItem. Этот компонент нам также необходимо обернуть в функцию observer из mobx-react-lite.

			import { FunctionComponent } from 'react';
import { observer } from 'mobx-react-lite';

import styles from '../style/todos.module.scss';

import todos from '../store/todos';

import TodoItem from './TodoItem';

const TodoList:FunctionComponent = observer(() => {
  return (
    <div className={styles.todoList}>
      {
        todos.todoArray.map(todoItem => 
          <TodoItem 
            key={todoItem.id} 
            todoItem={todoItem} 
          />
        )
      }
    </div>
  );
});

export default TodoList;
		

И создадим еще один компонент TodoDetails, который будет служить для отображения подробной информации о выбранной задаче.

Импортируем класс todos и функцию observer. Внутри компонента мы используем условный рендеринг. Будем проверять значение переменной activeTask класса todos и если значение переменной не null, то отрисовываем блок с подробной информацией о задаче.

			import { FunctionComponent } from 'react';
import { observer } from 'mobx-react-lite';

import styles from '../style/todos.module.scss';

import todos from '../store/todos';

const TodoDetails:FunctionComponent = observer(() => {
  return (
    <>
      {
        todos.activeTask &&
          <div className={styles.todoDetails}>
            <h2 className='bigHeader'>Task Info</h2>
            <h3 className='midHeader'>{todos.activeTask.title}</h3>
            <p>{todos.activeTask.text}</p>
          </div>
      }
    </>
  );
});

export default TodoDetails;
		

Теперь внесем некоторые изменения в компонент ModalWindow. Импортируем функцию observer и класс todos.

Для компонентов Input и TextArea устанавливаем свойство value как todos.todoTitle и todos.todoText соответственно. Также для обоих этих компонентов зададим обработчик события onChange: для Input это будет метод titleHandler класса todos, а для компонента TextArea метод textHandler.

			......

<Input 
  value={todos.todoTitle}
  onChange={(e) => todos.titleHandler(e.target.value)}
  placeholder='Todo title...'
/>
<TextArea 
  value={todos.todoText}
  onChange={(e) => todos.textHandler(e.target.value)}
  placeholder='Todo text...'
/>

......
		

Осталось добавить компоненты TodoList и TodoDetails в App. А для компонента Button, который мы передаем в ModalWindow добавить событие onClick, которому задать метод addTask класса todos.

			import { FunctionComponent, useState } from 'react';

import todos from '../store/todos';

import Button from './ui/Button';
import Header from './ui/Header';
import Footer from './ui/Footer';
import ModalWindow from './ui/ModalWindow';
import TodoList from './TodoList';
import TodoDetails from './TodoDetails';

const App:FunctionComponent = () => {
  const [isModalShown, setIsModalShown] = useState(false);

  function modalWindowToggler() {
    setIsModalShown(prevModalState => !prevModalState);
  }

  return (
    <>
      <Header modalToggler={modalWindowToggler} />
      <main className='container main'>
        {
          isModalShown && 
            <ModalWindow modalToggler={modalWindowToggler}>
              <Button 
                btnText='add todo' 
                onClick={() => todos.addTask()}
              />
            </ModalWindow>
        }
        <TodoList />
        <TodoDetails />
      </main>
      <Footer />
    </>
  );
};

export default App;
		

Приложение полностью готово. С полным кодом проекта вы можете ознакомиться тут, а посмотреть live-версию тут.

Следите за новыми постами
Следите за новыми постами по любимым темам
3К открытий4К показов