В этой статье основное внимание будет уделено созданию простого ванильного приложения JavaScript, подключенного к серверу node.js. Бэкенд-часть приложения будет рассмотрена в этой статье. Процесс будет разделен на три основные части: разметка и стилизация, хранение данных и обработка данных. Для первого пункта потребуется код HTML и CSS, хотя стилизация не является большой частью этого руководства. Существует множество возможных подходов к архитектуре внешнего интерфейса и кодовым решениям. Тот, который был выбран для этого проекта, соответствует его требованиям, но может быть не идеальным в других случаях. Обычно для создания больших приложений используется библиотека или фреймворк, этот проект — просто упражнение.

1. HTML и CSS

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

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>To Do Lists</title>
    <style></style>
  </head>
  <body>
    <form onsubmit="createList(event)" method="post" class="create-form">
      <input type="text" name="list" id="list" placeholder="List" />
      <input type="submit" value="Submit" />
    </form>
    <form onsubmit="createTask(event)" method="post" class="create-form">
      <input type="text" name="task" id="task" placeholder="Task" />
      <input type="submit" value="Submit" />
    </form>
    <div id="task-list">Loading...</div>
    <script src="dataStore.js"></script>
    <script src="scripts.js"></script>
  </body>
</html>

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

Добавление поля выбора в новую форму задачи может быть улучшением или простым упражнением.

Ниже вы можете найти весь CSS, который используется в базовой версии приложения. Стиль не является основной частью этого проекта, и в приведенном ниже коде представлены только самые необходимые изменения, облегчающие просмотр содержимого приложения.

.create-form {
  margin: 3px 0;
}
#task-list {
  margin-top: 10px;
}
button,
input[type="submit"] {
  cursor: pointer;
  background-color: white;
  font-size: 0.9rem;
  border-radius: 3px;
  border: 1px solid gray;
  padding: 1px 10px;
  margin: 0 2px;
}
button:hover,
input[type="submit"]:hover {
  background-color: lightgray;
}
.task-wrapper {
  margin: 1px 0;
  height: 25px;
}
.task-name {
  width: 200px;
  display: inline-block;
}
.list > form > h1 {
  display: inline-block;
  width: 220px;
}
.list-name {
  font-size: inherit;
  width: 200px;
  height: 45px;
  box-sizing: border-box;
}
.list-form button,
.list-form input[type="submit"] {
  font-size: 1.2rem;
}

3. Подготовка данных

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

3.1. Создание сервера и конечных точек

Чтобы подготовить сервер, вы можете просто зайти в мой проект GitHub и вытащить репозиторий (приложение для интерфейса должно быть помещено в папку static) или следовать учебнику, чтобы собрать сервер с нуля.

Для полноценно работающего приложения вам также потребуется база данных, которую вы можете создать локально с помощью данного туториала.

3.2. Структура данных

Подготовив бэкенд, мы должны познакомиться со структурой данных элементов, с которыми будем работать.

У нас будет два вида предметов: tasks и lists. lists будет более простым элементом, только с параметрами id и name.

list = {
	id: number;
	name: string;
}

С другой стороны, задача будет иметь еще несколько параметров, в основном checked, которые будут указывать на то, что задача завершена, и list_id, которые будут показывать, подключена ли задача к какому-либо списку, и указывать в направлении id связанного списка. .

task = {
	id: number;
	name: string;
	checked: boolean;
	list_id: number
}

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

3.3. Создание хранилища данных

Первый шаг, который мы рассмотрим в отношении JavaScript-части приложения, — это создание места для локального хранения всех задач и списков. Поскольку часть HTML выше уже импортирует все необходимые файлы, нам нужно только их создать. В той же папке, что и файл HTML, создайте файл dataStore.js, который будет содержать логику, используемую для хранения всех данных, полученных с сервера.

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

const DataStore = () => {
  tasks = [];
  sortedTasksByList = {};
  lists = [];
  sortedLists = {};
};

Теперь давайте добавим к этой функции три основных метода. Поскольку мы не инициировали указанные выше переменные с помощью const, теперь мы можем ссылаться на них с помощью ключевого слова this. Это не обязательно, но в случае методов set и getAll это позволяет нам вызывать переменные напрямую, без использования условных операторов или switch.

Для нашей цели set просто обновляет всю переменную, и аналогично, getAll возвращает всю переменную. С другой стороны, get возвращает только один элемент из массива, либо task, либо list, в зависимости от переданного id.

set = (name, values) => {
  this[name] = values;
};
get = (listName, id) => {
  if (listName === "task") return tasks.find((task) => task.id === id);
  else if (listName === "list") return sortedLists[id];
};
getAll = (name) => {
  return this[name];
};

Для более крупных случаев использования может быть полезно создать новый экземпляр DataStore для каждого типа данных, чтобы мы не получали несколько операторов if в каждом методе, который принимает listName в качестве параметра, но в случае этого проекта, поскольку существует только либо list, либо task, это не кажется существенным.

Следующие три метода будут обрабатывать добавление одиночных задач и списков. Методы нельзя было сделать универсальными из-за разного формата некоторых объектов для tasks и lists. sortedTasksByList — это объект, в котором list_id — ключ, а значение — массив всех задач, которые есть в списке.

Добавление новых элементов довольно просто, новый list просто помещается в конец массива, а новая запись создается в sortedLists. Поскольку невозможно выбрать список при создании новой задачи, каждая задача в начале добавляется в массив nolist внутри sortedTasksByList.

add = (listName, item) => {
  if (listName === "task") addTask(item);
  else if (listName === "list") addList(item);
};
addList = (list) => {
  lists.push(list);
  sortedLists[list.id] = list;
};
addTask = (task) => {
  tasks.push(task);
  sortedTasksByList.nolist.push(task);
};

Последние методы в DataStore будут обрабатывать обновление данных и удаление элементов.

В обоих случаях, update и remove, аналогично методу add, методы разделяются операторами if, которые разделяют их на основе переданного аргумента listName. Второй аргумент, updatedItem, содержит весь элемент task или list.

В этом проекте мы могли бы также использовать updatedItem, чтобы определить, передается ли task или list, например, путем проверки свойства list_id, которое присутствует только в объекте task. Однако это может быть рискованно, если структура данных или свойства изменятся в будущем.

update = (listName, updatedItem) => {
  if (listName === "task") updateTask(updatedItem);
  else if (listName === "list") updateList(updatedItem);
};
updateList = (updatedList) => {
  lists = lists.map((list) => {
    if (list.id === updatedList.id) return updatedList;
    return list;
  });
  sortedLists[updatedList.id] = updatedList;
};

Обновление задачи намного сложнее по сравнению с другими методами в этой функции. Основную трудность здесь создает объект sortedTasksByList, который требует как удаления обновленной задачи из старого списка, так и добавления ее в новый, при условии, что list_id был изменен.

Первый шаг — найти индекс старой задачи, но мы также должны сохранить старый идентификатор списка, если он понадобится для удаления задачи из списка. Если у старой задачи не было идентификатора списка, мы назначим ей nolist, так как он будет использоваться в качестве ключа к объекту sortedTasksByList на следующем шаге.

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

updateTask = (updatedTask) => {
  let oldTaskListId = "";
  const updatedListId = updatedTask.list_id;
  const index = tasks.findIndex((task) => {
    if (task.id === updatedTask.id) {
      oldTaskListId = task.list_id || "nolist";
      return true;
    }
    return false;
  });
  tasks[index] = updatedTask;
  if (updatedListId !== oldTaskListId) {
    const oldSortedTasksByListIndex = sortedTasksByList[
      oldTaskListId
    ].findIndex((task) => task.id === updatedTask.id);
    sortedTasksByList[oldTaskListId].splice(oldSortedTasksByListIndex, 1);
    if (sortedTasksByList[updatedListId]) {
      sortedTasksByList[updatedListId].push(task);
    } else {
      sortedTasksByList[updatedListId] = [task];
    }
  } else {
    const inListIndex = sortedTasksByList[updatedListId].findIndex(
      (task) => task.id === updatedTask.id
    );
    sortedTasksByList[updatedListId][inListIndex] = updatedTask;
  }
};

Теперь мы можем проверить, изменился ли идентификатор списка. Если это так, мы можем найти индекс старой задачи в правом списке и удалить ее полностью. Мы также должны рассмотреть ситуацию, в которой объект sortedTasksByList еще не имеет идентификатора списка, используемого в обновленной задаче. Это возможно, потому что идентификаторы списка добавляются к объекту только тогда, когда в списке есть существующие задачи.

И последним шагом в этом методе будет просто найти и обновить список в sortedTasksByList, если идентификатор списка не был изменен.

Последней операцией, которая должна быть реализована в DataStore, является remove. В приведенных ниже методах используются шаблоны, очень похожие на update. В случае removeTask нам нужно найти индекс задачи как в массиве tasks, так и в объекте sortedTasksByList. Получив необходимые индексы, мы можем просто удалить задачу из массивов.

В случае removeList мы выбираем другой подход, вместо того, чтобы искать индекс списка в массиве, мы просто фильтруем список из массива lists и присваиваем новый отфильтрованный массив переменной lists.

remove = (listName, id) => {
  if (listName === "task") removeTask(id);
  else if (listName === "list") removeList(id);
};
removeTask = (id) => {
  let listId = "";
  const index = tasks.findIndex((task) => {
    if (task.id === updatedTask.id) {
      listId = task.list_id || "nolist";
      return true;
    }
    return false;
  });
  tasks.splice(index, 1);
  const inListIndex = sortedTasksByList[listId].findIndex(
    (task) => task.id === id
  );
  sortedTasksByList[listId].splice(inListIndex, 1);
};
removeList = (id) => {
  delete sortedLists[id];
  lists = lists.filter((list) => list.id !== id);
};

Разным людям может быть легче читать разные подходы, поскольку этот проект — просто упражнение, оба могут быть представлены, чтобы было легче оценить плюсы и минусы каждого из них.

Подготовив все методы, теперь мы можем решить, какой из них мы хотим выставить за пределы DataStore, экспортировав их.

return { set, get, getAll, add, update, remove };

3. Визуализация данных

Перед извлечением и визуализацией данных в верхней части нового файла scripts.js (который уже импортирован в файл HTML) мы должны инициализировать DataStore, который позволит нам хранить и получать доступ ко всей информации с сервера.

const dataStore = DataStore();

3.1. Получение данных

Функция renderData будет точкой входа во все функции приложения. Сначала просто вызовем функции для получения и сохранения tasks и lists, на следующих шагах сосредоточимся на отображении этой информации пользователю.

const renderData = async () => {
  const { sortedTasksByList } = await loadTable("/task", sortTasks);
  const { lists, sortedLists } = await loadTable("/list", sortLists);
  // Update HTML
};

Поскольку получение данных для всех конечных точек выглядит очень похоже, одна общая функция будет принимать аргумент endpoint, а для форматирования данных для DataStore — аргумент sortFunc.

const loadTable = async (endpoint, sortFunc) => {
  try {
    const res = await fetch(endpoint, { method: "GET" });
		await checkStatus(res);
    const data = await res.json();
    return sortFunc(data);
  } catch (err) {
    console.error(err);
  }
};

Прежде чем мы перейдем к функциям сортировки для tasks и lists, давайте рассмотрим функцию checkStatus, которая также будет важна позже в проекте для обновления и удаления вызовов.

На данный момент эта функция только проверяет статус ответа и выдает ошибку, переданную с сервера, в случае плохого статуса. Это потенциально хорошее место для дальнейшей обработки ошибок.

const checkStatus = async (res) => {
  if (res.status >= 400 && res.status < 600) {
    const data = await res.json();
    throw data.error;
  }
};

Переходя к сортировке, задачи будут храниться, как подготовлено в DataStore, в двух переменных, одна будет просто массивом со всеми задачами, именно такими, как они приходят с сервера. Вторая переменная будет называться sortedTasksByList и по умолчанию будет состоять только из массива nolist, который будет содержать все задачи, не назначенные ни одному из списков. Теперь мы можем пройтись по списку задач и назначить каждую из них нужному списку, мы также должны проверить, существует ли ключ для списка в объекте, иначе push новую задачу не удастся существующий массив.

const sortTasks = (tasks) => {
  let sortedTasksByList = { nolist: [] };
  tasks.forEach((task) => {
    const listId = task.list_id || "nolist";
    if (sortedTasksByList[listId]) {
      sortedTasksByList[listId].push(task);
    } else {
      sortedTasksByList[listId] = [task];
    }
  });
  dataStore.set("tasks", tasks);
  dataStore.set("sortedTasksByList", sortedTasksByList);
  return { tasks, sortedTasksByList };
};

Сортировка списков будет немного проще, так как мы будем хранить их только как массив и как объект, в котором id списка будет ключом, для более быстрого и простого доступа.

const sortLists = (lists) => {
  const sortedLists = {};
  lists.forEach((list) => {
    sortedLists[list.id] = list;
  });
  dataStore.set("lists", lists);
  dataStore.set("sortedLists", sortedLists);
  return { lists, sortedLists };
};

Подготовив все функции, мы можем вызвать renderData внизу файла, чтобы инициализировать приложение при загрузке страницы. Поскольку мы импортируем скрипты в конец HTML-файла, нам не нужно использовать какие-либо прослушиватели, такие как onload, которые ждут, пока страница полностью отобразится, прежде чем вызывать функцию (в нашем случае страница уже будет отрисована).

renderData();

После выполнения этих действий у нас есть база для рендеринга данных на экране, но пока никаких задач или списков пользователь не увидит.

3.2. Обновление тела

В то место, где мы оставили комментарий // Update HTML в функции renderData, теперь можно добавить следующую логику.

Первый шаг — получить элемент task-list, потому что именно там все будет отображаться. Нам нужно очистить innerHTML, потому что файл HTML отображается с Loading... внутри этого элемента, чтобы указать, что с пользователем что-то происходит.

Все задачи будут отображаться внутри элемента, отвечающего за список, которому назначена задача, но сначала мы должны обработать задачи, у которых нет никакого списка. Мы вынесем их в отдельный элемент, чтобы упростить доступ к ним в будущем, дополнительно мы добавим заголовок, указывающий пользователю на начало нового раздела. Под заголовком мы отобразим все задачи, у которых нет списка. В конце мы можем добавить только что созданный раздел к listsElement, чтобы показать его пользователю.

// Update HTML
const listsElement = document.getElementById("task-list");
listsElement.innerHTML = "";
const noListElement = document.createElement("div");
noListElement.class = "list";
noListElement.id = "no-list";
noListElement.innerHTML += `<h1>Tasks</h1>`;
sortedTasksByList.nolist.forEach(
  (task) => (noListElement.innerHTML += renderTaskElement(task))
);
listsElement.appendChild(noListElement);
Object.entries(sortedLists).map(([listId, list]) => {
  if (listId === "nolist") return;
  listsElement.appendChild(renderTaskListElement(list, sortedTasksByList));
});

Теперь мы можем отображать все остальные задачи, назначенные списку. Как видите, мы используем функцию renderTaskListElement для рендеринга элемента списка, а внутри этой функции мы вызываем renderTaskElement, который рендерит элемент задачи. Сначала сосредоточимся на последнем.

Это простая функция, которая отображает строку HTML. Мы используем здесь тег form, потому что в будущем мы добавим возможность редактирования этой задачи, что сделает весь элемент формой, которая будет отправлять обновленную задачу на сервер. На данный момент мы будем отображать только флажок, метку с названием задачи и кнопку для переключения в режим редактирования в будущем.

const renderTaskElement = ({ id, ...task }) => `
  <form 
    id="task-${id}-wrapper" 
    class="task-wrapper" 
    method="post" 
    onsubmit="toggleEditTask(event, ${id}, ${task.list_id})">
      <input
        type="checkbox"
        id="task-${id}"
        ${task.checked ? "checked" : ""}
        onchange="handleToggleTask(event, ${id})">
      </input>
      <label
        id="task-${id}-name"
        for="task-${id}"
        class="task-name">
          ${task.name}
      </label>
      <input type="submit" value="Edit" id="submit-task-${id}" />
  </form>`;

Упомянутая выше функция renderTaskListElement сначала создает элемент, который будет содержать весь список со всеми задачами, а затем мы создаем форму для заголовка списка. Как и элемент задачи, у него есть кнопка редактирования, которая переключает форму в режим редактирования, что позволяет изменить имя списка и удалить его.

В конце мы перебираем все задачи для списка и добавляем элементы задачи в элемент списка и возвращаем весь вновь созданный элемент. Нам не нужно добавлять его здесь, потому что мы уже делаем это в функции renderData при вызове функции renderTaskListElement.

const renderTaskListElement = ({ id, ...list }, sortedTasks) => {
  const taskListElement = document.createElement("div");
  taskListElement.className = "list";
  taskListElement.id = id;
  taskListElement.innerHTML += `
    <form
      id="list-form-${id}"
      class="list-form"
      method="post" 
      onsubmit="toggleEditList(event, ${id})">
        <h1>
          <div id="list-${id}-name" class="list-name">
            ${list.name}
          </div>
        </h1>
        <input type="submit" value="Edit" id="submit-list-${id}" />
    </form>`;
  sortedTasks[id]?.forEach(
    (task) => (taskListElement.innerHTML += renderTaskElement(task))
  );
  return taskListElement;
};

Выполнение этих шагов позволит отобразить задачи и списки, но поскольку до сих пор не было создано ни одной задачи, мы все равно не увидим никаких изменений.

3.3. Создание новых элементов

В файле HTML мы уже подготовили формы для вызова функций, которые мы создадим сразу после отправки. Имея это в виду, нам не нужно вносить никаких дополнительных изменений в разметку.

Функции для создания задачи и создания списка будут очень похожи, обе они вызывают функцию createItem, которую мы рассмотрим ниже, единственное существенное различие заключается в том, что происходит после создания элемента. В createTask мы визуализируем новый элемент задачи и помещаем его в конец элемента no-list, в случае createList мы визуализируем новый элемент списка и помещаем его в конец task-list внизу страницы.

const createTask = (e) => {
  const renderNewTask = (newTask) => {
    const taskListElement = document.getElementById("no-list");
    taskListElement.innerHTML += renderTaskElement(newTask);
  };
  createItem(e, "task", renderNewTask);
};
const createList = (e) => {
  const renderNewList = (newList) => {
    const listsElement = document.getElementById("task-list");
    listsElement.appendChild(renderTaskListElement(newList, {}));
  };
  createItem(e, "list", renderNewList);
};

Функция createItem сосредоточится на вызове правильной конечной точки, добавлении нового элемента в DataStore, проверке на наличие ошибок и вызове переданной функции renderNewItem в конце, если все остальное пойдет хорошо.

Вызов конечной точки использует метод POST в соответствии со спецификацией сервера, описанной выше. Поскольку и tasks, и lists требуют имени только в начале, структура данных, которую мы отправляем, выглядит одинаково в обоих случаях, и поскольку мы устанавливаем имена конечных точек как id входных данных, мы можем просто получить элемент input по id. Мы также используем здесь функцию checkStatus, которая была описана, когда мы говорили о выборке данных. В конце мы просто очищаем ввод, чтобы он указывал пользователю, что все прошло хорошо и ввод готов к использованию.

const createItem = async (e, itemName, renderNewItem) => {
  e.preventDefault();
  try {
    const input = document.getElementById(itemName);
    const res = await fetch(`/${itemName}`, {
      method: "POST",
      body: JSON.stringify({ name: input.value }),
    });
		await checkStatus(res);
    const newItem = await res.json();
    dataStore.add(itemName, newItem);
    input.value = "";
    renderNewItem(newItem);
  } catch (err) {
    console.error(err);
  }
};

Теперь, когда мы можем создавать новые задачи и списки, после запуска сервера (node index.js) вы сможете перейти на localhost:8080 и создать и просмотреть новые элементы. Вы по-прежнему не сможете, например, переключать задачи или включать режим редактирования.

3.4. Обновление элементов

На этом шаге мы создадим общую функцию для обновления элементов различными способами. Пока мы будем использовать ее только для включения переключения задач, но на следующих шагах, после создания режима редактирования, эта функция будет полезна во многих разных местах.

Сама функция очень похожа на функцию createItem, она также вызывает e.preventDefault(), поэтому страница не обновляется после отправки form, она принимает аргумент itemName, который используется в качестве конечной точки, затем статус проверяется на наличие ошибок с сервера. . В этом случае мы вызываем конечную точку с методом PUT в заголовке, так как это метод, который сервер ожидает для обновления элементов. Если все прошло хорошо, элемент обновляется в DataStore и вызывается в onAfterUpdate, что обычно используется для обновления HTML и внешнего вида страницы.

const updateItem = async (e, itemName, data, onAfterUpdate) => {
  e.preventDefault();
  try {
    const res = await fetch(`/${itemName}`, {
      method: "PUT",
      body: JSON.stringify(data),
    });
		await checkStatus(res);
    const updatedItem = await res.json();
    dataStore.update(itemName, updatedItem);
    if (onAfterUpdate) onAfterUpdate(updatedItem);
  } catch (err) {
    console.error(err);
  }
};

Наконец, мы можем создать функцию, которая будет переключать задачи. Поскольку функция уже была помещена в HTML при рендеринге задач в renderTaskElement со всеми переданными необходимыми параметрами, все, что нам нужно сделать сейчас, это создать функцию handleToggleTask, как в примере ниже.

const handleToggleTask = (e, id) => {
  const updatedTask = {
    ...dataStore.get("task", id),
    checked: e.target.checked,
  };
  updateItem(e, "task", updatedTask);
};

В случае переключения задач нам не нужно обрабатывать какие-либо изменения в теле документа после обновления задачи, поэтому нам нужно только подготовить обновленный объект задачи и передать его функции updateItem.

3.5. Удаление задач

Аналогично созданию элементов, удаление будет иметь две отдельные функции для tasks и lists, которые будут вызывать одну и ту же функцию deleteItem, но будут вести себя по-разному после успешного удаления.

После удаления элемента мы просто удалим элемент из документа, здесь нет необходимости в какой-либо дополнительной логике, так как все остальное обрабатывается внутри функции deleteItem.

const deleteTask = async (id) => {
  const onAfterDelete = () => {
    document.getElementById(`task-${id}-wrapper`).remove();
  };
  deleteItem("task", id, onAfterDelete);
};
const deleteList = async (id) => {
  const onAfterDelete = () => {
    document.getElementById(id).remove();
  };
  deleteItem("list", id, onAfterDelete);
};

Эта функция также очень похожа на createItem и updateItem, после некоторых настроек их, вероятно, можно было бы объединить в одну общую функцию, но для ясности мы будем держать их отдельно в этой статье.

const deleteItem = async (itemName, id, onAfterDelete) => {
  try {
    const res = await fetch(`/${itemName}`, {
      method: "DELETE",
      body: JSON.stringify({ id }),
    });
    await checkStatus(res);
    dataStore.remove(itemName, id);
    if (onAfterDelete) onAfterDelete();
    clearError();
  } catch (err) {
    renderError(err);
  }
};

В настоящее время вызов этой функции нигде в приложении не реализован, это изменится на следующем шаге с реализацией режима редактирования, где мы разрешим редактировать имя задачи, ее список и удалять задачи.

3.6. Изменить режим задачи

Режим редактирования для задач будет состоять из предварительно заполненного ввода имени, элемента выбора списка и кнопок удаления и сохранения. Точкой входа здесь будет функция, которая уже назначена кнопке Edit в элементах задачи под названием toggleEditTask. Единственная задача этой функции — проверить, находится ли задача в режиме редактирования или просмотра, проверив, существует ли кнопка удаления, и вызвать соответствующую функцию.

const toggleEditTask = (e, id, list_id) => {
  e.preventDefault();
  const nameElement = document.getElementById(`task-${id}-name`);
  if (!document.getElementById(`task-${id}-delete-button`)) {
    enableEditTask(e, id, nameElement);
  } else {
    saveEditedTask(e, id, nameElement);
  }
};

Функция, отвечающая за включение режима редактирования, заменит элемент имени задачи предварительно заполненным вводом, обновит текст кнопки с Edit на Save и отобразит оставшиеся элементы.

Вы можете заметить, что кнопке удаления при щелчке назначена функция deleteTask, которую мы уже создали на предыдущем шаге.

const enableEditTask = (e, id, nameElement) => {
  const taskElement = document.getElementById(`task-${id}-wrapper`);
  const newNameElement = document.createElement("input");
  const task = dataStore.get("task", id);
  newNameElement.id = `task-${id}-name`;
  newNameElement.setAttribute("class", nameElement.getAttribute("class"));
  newNameElement.setAttribute("value", nameElement.innerHTML.trim());
  nameElement.parentNode.replaceChild(newNameElement, nameElement);
  document.getElementById(`submit-task-${id}`).setAttribute("value", "Save");
  document.getElementById(`submit-task-${id}`).insertAdjacentHTML(
    "afterend",
    `<select
      id="task-${id}-select"
      onchange="changeTaskList(event, ${id})">
        ${renderListOptions(task.list_id)}
    </select>
    <button
      type="button"
      id="task-${id}-delete-button"
      onclick="deleteTask(${id})">
        Delete
    </button>`
  );
};

Варианты списка рендеринга для select довольно просты, мы должны добавить параметр по умолчанию для задач без какого-либо списка, а затем просто сопоставить массив lists с option элементами. Здесь мы также проверяем, должен ли список быть выбран по умолчанию на основе идентификатора выбранного списка задачи.

const renderListOptions = (selectedListId) => {
  let listsHtml = "";
  listsHtml += `<option value="">No list</option>`;
  dataStore.getAll("lists").forEach((list) => {
    listsHtml += `
      <option 
        value="${list.id}" 
        ${selectedListId === list.id ? "selected" : ""}>
          ${list.name}
      </option>`;
  });
  return listsHtml;
};

Последнее, что следует решить перед тем, как перейти к сохранению отредактированной задачи, — это обработка обновлений списка. При таком подходе мы будем отправлять запрос на сервер при каждом изменении списка и сразу после этого обновлять HTML. Это также автоматически закроет режим редактирования задачи, так как новый элемент задачи, отображаемый в другом списке, не будет находиться в режиме редактирования. Другие подходы, такие как обновление списка только после нажатия кнопки сохранения, также жизнеспособны. Решение, которое следует выбрать, зависит от дизайна UX, поскольку это приложение никто не будет использовать, подход, который мы выбираем, полностью зависит от нас.

Содержимое этой функции очень похоже на другие функции, обрабатывающие обновления, например, handleToggleTask. Здесь мы должны убедиться, что переданное list_id является числом, потому что это единственный тип, который примет база данных, отсюда и плюс перед e.target.value. После обновления задачи создаем новый элемент задачи в нужном месте и удаляем старый.

const changeTaskList = (e, id) => {
  const updatedTask = {
    ...dataStore.get("task", id),
    list_id: +e.target.value || "",
  };
  const onAfterUpdate = (updatedTask) => {
    document.getElementById(`task-${id}-wrapper`).remove();
    const listElement = document.getElementById(
      updatedTask.list_id || "no-list"
    );
    listElement.innerHTML += renderTaskElement(updatedTask);
  };
  updateItem(e, "task", updatedTask, onAfterUpdate);
};

Теперь сосредоточимся на сохранении отредактированной задачи. Как и в приведенной выше функции, основные отличия заключаются в части onAfterUpdate. В этом случае это в основном инверсия включения редактирования, элемент имени задачи возвращается в исходное состояние, заменяя ввод, текст кнопки изменяется на Edit, а другие элементы удаляются из DOM.

const saveEditedTask = async (e, id, nameElement) => {
  const updatedTask = {
    ...dataStore.get("task", id),
    name: nameElement.value,
  };
  const onAfterUpdate = () => {
    const newNameElement = document.createElement("label");
    newNameElement.id = `task-${id}-name`;
    newNameElement.setAttribute("class", nameElement.getAttribute("class"));
    newNameElement.setAttribute("for", `task-${id}`);
    newNameElement.innerHTML = nameElement.value;
    nameElement.parentNode.replaceChild(newNameElement, nameElement);
    document.getElementById(`submit-task-${id}`).setAttribute("value", "Edit");
    document.getElementById(`task-${id}-delete-button`).remove();
    document.getElementById(`task-${id}-select`).remove();
  };
  updateItem(e, "task", updatedTask, onAfterUpdate);
};

3.7. Режим редактирования списка

Последнее, что нужно сделать, чтобы сделать приложение полностью функциональным, — это отредактировать список. toggleEditList очень похож на toggleEditTask.

const toggleEditList = (e, id) => {
  e.preventDefault();
  const nameElement = document.getElementById(`list-${id}-name`);
  if (!document.getElementById(`list-${id}-delete-button`)) {
    enableEditList(e, id, nameElement);
  } else {
    saveEditedList(e, id, nameElement);
  }
};

Включение редактирования списка также очень похоже на включение режима редактирования для задач, но в этом случае требуется отрисовать меньше элементов, а поскольку у нас нет дополнительных элементов, таких как select, у нас нет для создания любых дополнительных обработчиков.

const enableEditList = (e, id, nameElement) => {
  const newNameElement = document.createElement("input");
  newNameElement.id = `list-${id}-name`;
  newNameElement.setAttribute("class", nameElement.getAttribute("class"));
  newNameElement.setAttribute("value", nameElement.innerHTML.trim());
  nameElement.parentNode.replaceChild(newNameElement, nameElement);
  document.getElementById(`submit-list-${id}`).setAttribute("value", "Save");
  document.getElementById(`submit-list-${id}`).insertAdjacentHTML(
    "afterend",
    `<button
      type="button"
      id="list-${id}-delete-button"
      onclick="deleteList(${id})">
        Delete
    </button>`
  );
};

Как и в случае с saveEditedTask, функция saveEditedList в основном сохраняет имя списка и обращает изменения enableEditList в HTML.

const saveEditedList = async (e, id, nameElement) => {
  const updatedList = { id, name: nameElement.value };
  const onAfterUpdate = () => {
    const newNameElement = document.createElement("div");
    newNameElement.id = `list-${id}-name`;
    newNameElement.setAttribute("class", nameElement.getAttribute("class"));
    newNameElement.innerHTML = nameElement.value;
    nameElement.parentNode.replaceChild(newNameElement, nameElement);
    document.getElementById(`submit-list-${id}`).setAttribute("value", "Edit");
    document.getElementById(`list-${id}-delete-button`).remove();
  };
  updateItem(e, "list", updatedList, onAfterUpdate);
};

Теперь у нас готово все фронтенд-приложение. Запуск его сейчас должен отобразить все и позволить вам выполнять все операции с задачами и списками. Дальнейшие улучшения могут включать, например, обработку и отображение ошибок, полученных от сервера, и это последний шаг, который будет рассмотрен в этом руководстве.

4. Простая обработка ошибок

Давайте начнем с создания элемента, в котором мы будем отображать сообщение об ошибке в начале тега body в файле HTML. В более продвинутых приложениях мы могли бы отображать сообщения об ошибках непосредственно в формах, но для нашего варианта использования будет достаточно отображать их в верхней части документа.

<div id="error-message"></div>

Кроме того, мы можем указать сообщение об ошибке, изменив цвет текста на красный.

#error-message {
  color: red;
}

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

const renderError = (error, skipRenderError) => {
  document.getElementById("error-message").innerHTML = prepareError(error);
};
const clearError = () => {
  document.getElementById("error-message").innerHTML = "";
};

Сервер может возвращать различные типы ошибок в зависимости от того, является ли это ошибкой проверки JSON или простой строковой ошибкой. В случае ошибки проверки JSON сервер вернет массив объектов, содержащих несколько свойств, для наших нужд нам потребуется только описание схемы и сообщение об ошибке. С помощью этой информации мы создадим строку, которая будет показана пользователю.

const prepareError = (error) => {
  if (typeof error === "string") {
    return error;
  } else if (error) {
    const errors = error.map(
      (err) => `${err.schema.description} ${err.message}`
    );
    return errors.join(", ");
  }
};

Последний шаг — вызывать эти функции в нужных местах. До сих пор мы использовали console.error(err) везде, где нам нужно было регистрировать ошибку. Теперь мы можем изменить их все на renderError(err).

// Change this:
console.error(err);
// To this:
renderError(err);

Кроме того, мы должны вызывать функцию clearError в конце каждой успешной отправки формы, в конце концов все ваши операторы try ... catch должны выглядеть так:

try {
	...
	clearError();
} catch (err) {
	renderError(err)
}

И вот, наконец, можно проверить, все ли работает корректно, задачи и списки успешно создаются, а если форма отправлена ​​с пустым вводом, вверху страницы показывается ошибка.

5. Резюме

Создание frontend-стороны приложения — это только одна часть всего проекта, вы можете прочитать статью о backend-части здесь, или пойти дальше и заглянуть в DevOps-сторону проекта здесь, где мы контейнеризируем все приложение с Docker и Kubernetes.

Вы также можете найти все файлы для этого проекта на GitHub.