Перетаскивание доски Канбан с помощью React TypeScript


Демонстрация (TLDR)

Это исходный код и доска Канбан, которую мы будем создавать.

HTML Drag and Drop API

HTML Drag and Drop API необходим для реализации функции перетаскивания на любой элемент DOM.

Управление состояниями

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

Я использую useReducer, поскольку состояние является сложным.

Вот начальное состояние. isDragOver необходим для обновления стиля элемента, который перетаскивается. Для простоты Date.now() используется в качестве уникального id элемента.

type Category = "todo" | "doing" | "done";
type Item = { id: number; content: string; isDragOver: boolean };
type State = { [key in Category]: Item[] };

const initialState: State = {
  todo: [{ id: Date.now(), content: "Task 4", isDragOver: false }],
  doing: [{ id: Date.now() + 1, content: "Task 3", isDragOver: false }],
  done: [
    { id: Date.now() + 2, content: "Task 2", isDragOver: false },
    { id: Date.now() + 3, content: "Task 1", isDragOver: false },
  ],
};
Вход в полноэкранный режим Выйти из полноэкранного режима

Вот действия, которые выполняет редуктор.

type Action =
  | { type: "CREATE"; content: string }
  | {
      type: "UPDATE_CATEGORY";
      newCategory: Category;
      oldCategory: Category;
      position: number;
      id: number;
    }
  | {
      type: "UPDATE_DRAG_OVER";
      id: number;
      category: Category;
      isDragOver: boolean;
    }
  | { type: "DELETE"; id: number; category: Category };
Войти в полноэкранный режим Выход из полноэкранного режима

Действие: СОЗДАТЬ

Действие create создает элемент в колонке todo доски Kanban.

case "CREATE": {
    if (action.content.trim().length === 0) return state;
    return {
      ...state,
      todo: [
        { id: Date.now(), content: action.content, isDragOver: false },
        ...state.todo
      ]
    };
}
Войти в полноэкранный режим Выход из полноэкранного режима

Действие: ОБНОВИТЬ_КАТЕГОРИЮ

Действие UPDATE_CATEGORY обновляет позицию и категорию элемента.

Сначала мы найдем старую позицию и элемент, используя id, указанный в объекте action. Чтобы избежать использования мутации, для возврата обоих значений в этой функции используется Immediately Invoked Function Expression (IIFE).

const { oldPosition, found } = (() => {
  const oldPosition = state[oldCategory].findIndex(
    (item) => item.id === action.id
  );
  return { oldPosition, found: state[oldCategory][oldPosition] };
})();
Вход в полноэкранный режим Выход из полноэкранного режима

Возвращается исходное состояние, если элемент не найден или если категория и позиция не изменились.

if (oldPosition === -1) return state;
if (newCategory === oldCategory && position === oldPosition) return state;
Вход в полноэкранный режим Выход из полноэкранного режима

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

const filtered = state[oldCategory].filter((item) => item.id !== action.id);
const newCategoryList = newCategory === oldCategory ? filtered : [...state[newCategory]];
Вход в полноэкранный режим Выход из полноэкранного режима

Списки обновляются в соответствии с положением нового элемента.

if (position === 0) {
  return {
    ...state,
    [oldCategory]: filtered,
    [newCategory]: [found, ...newCategoryList],
  };
}

return {
  ...state,
  [oldCategory]: filtered,
  [newCategory]: [
    ...newCategoryList.slice(0, position),
    found,
    ...newCategoryList.slice(position),
  ],
};
Вход в полноэкранный режим Выход из полноэкранного режима

Полный код.

case "UPDATE_CATEGORY": {
    const { position, newCategory, oldCategory } = action;

    const { oldPosition, found } = (() => {
      const oldPosition = state[oldCategory].findIndex(
        (item) => item.id === action.id
      );
      return { oldPosition, found: state[oldCategory][oldPosition] };
    })();
    if (oldPosition === -1) return state;
    if (newCategory === oldCategory && position === oldPosition) return state;

    const filtered = state[oldCategory].filter(
      (item) => item.id !== action.id
    );
    const newCategoryList =
      newCategory === oldCategory ? filtered : [...state[newCategory]];
    if (position === 0) {
      return {
        ...state,
        [oldCategory]: filtered,
        [newCategory]: [found, ...newCategoryList]
      };
    }

    return {
      ...state,
      [oldCategory]: filtered,
      [newCategory]: [
        ...newCategoryList.slice(0, position),
        found,
        ...newCategoryList.slice(position)
      ]
    };
}
Войти в полноэкранный режим Выход из полноэкранного режима

Действие: UPDATE_DRAG_OVER

Это действие обновит элемент, который перетаскивается на него или из него.

case "UPDATE_DRAG_OVER": {
    const updated = state[action.category].map((item) => {
      if (item.id === action.id) {
        return { ...item, isDragOver: action.isDragOver };
      }
      return item;
    });
    return {
      ...state,
      [action.category]: updated
    };
}
Войти в полноэкранный режим Выход из полноэкранного режима

Действие: УДАЛИТЬ

Наконец, это действие удалит элемент на доске Канбан.

case "DELETE": {
    const filtered = state[action.category].filter(
      (item) => item.id !== action.id
    );
    return {
      ...state,
      [action.category]: filtered
    };
}
Войти в полноэкранный режим Выход из полноэкранного режима

Состояние формы добавления элемента

Есть еще два состояния, которые используются для управления колонкой добавления элемента в список todo на доске Kanban.

Состояние add определяет, скрывать или показывать форму добавления элемента, а состояние addInput будет хранить заголовок нового элемента.

const [state, dispatch] = useReducer(reducer, initialState); // our reducer
const [add, setAdd] = useState(false);
const [addInput, setAddInput] = useState("");
Вход в полноэкранный режим Выход из полноэкранного режима

Пользовательский интерфейс (UI)

Теперь мы рассмотрели все, что касается управления состояниями доски Kanban. Я рассмотрю некоторые из основных компонентов пользовательского интерфейса доски Kanban.

Форма добавления элемента

TSX формы добавления элемента.

{
  add && (
    <div className="addItem">
      <input
        type="text"
        onKeyUp={(e) => {
          if (e.code === "Enter") {
            e.preventDefault();
            e.stopPropagation();
            dispatch({ type: "CREATE", content: addInput });
            setAddInput("");
            setAdd(false);
          }
        }}
        onChange={onAddInputChange}
        value={addInput}
      />
      <div>
        <button
          onClick={() => {
            dispatch({ type: "CREATE", content: addInput });
            setAddInput("");
            setAdd(false);
          }}
        >
          Add
        </button>
        <button onClick={() => setAdd(false)}>Cancel</button>
      </div>
    </div>
  );
}
Вход в полноэкранный режим Выход из полноэкранного режима

Функция слушателя события изменения ввода.

const onAddInputChange = (event: ChangeEvent<HTMLInputElement>) => {
  const value = event.currentTarget.value;
  setAddInput(value);
};
Вход в полноэкранный режим Выход из полноэкранного режима

Колонки доски Канбан

TSX столбцов в доске Канбан.

<div
  className="items"
  onDragOver={(e) => e.preventDefault()}
  onDrop={(e) => onItemsDrop(e, "doing")} // "todo" | "doing" | "done"
>
  {Items(state.doing, "doing")}  {/* "todo" | "doing" | "done" */}
</div>
Войти в полноэкранный режим Выйти из полноэкранного режима

Функция слушателя onDrop для столбцов предназначена для определения того, был ли перетаскиваемый элемент опущен на столбец. Функция e.dataTransfer может получать, хранить или очищать данные из перетаскиваемого элемента. Данные должны быть разобраны в формате JSON, так как dataTransfer принимает только string.

const onItemsDrop = (
  e: React.DragEvent<HTMLDivElement>,
  newCategory: Category
) => {
  const item = e.dataTransfer.getData("text/plain");
  const parsedItem = JSON.parse(item);
  const decodedItem = ItemDecoder.verify(parsedItem);
  dispatch({
    type: "UPDATE_CATEGORY",
    id: decodedItem.id,
    newCategory,
    oldCategory: decodedItem.category,
    position: state[newCategory].length,
  });
};
Вход в полноэкранный режим Выход из полноэкранного режима

Decoders

Decoders — это моя лучшая библиотека проверки данных для JavaScript и NodeJS. Она легкая, имеет хорошую поддержку TypeScript и расширяема. Разобранный элемент проверяется этой библиотекой.

const decodedItem = ItemDecoder.verify(parsedItem);
Вход в полноэкранный режим Выход из полноэкранного режима

Действие отправляется на редуктор для обновления столбцов на доске Kanban.

Элементы в доске Канбан

Функция TSX для отображения элементов на доске Kanban.

const Items = (items: Item[], category: Category) => {
  return items.map(({ id, content, isDragOver }) => (
    <div
      key={id}
      draggable={true}
      onDragStart={(e: React.DragEvent<HTMLDivElement>) => {
        e.dataTransfer.setData(
          "text/plain",
          JSON.stringify({ id, content, category, isDragOver })
        );
      }}
      onDragOver={(e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault();
        dispatch({
          type: "UPDATE_DRAG_OVER",
          category,
          id,
          isDragOver: true,
        });
      }}
      onDragLeave={(e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault();
        dispatch({
          type: "UPDATE_DRAG_OVER",
          category,
          id,
          isDragOver: false,
        });
      }}
      onDrop={(e: React.DragEvent<HTMLDivElement>) => {
        e.stopPropagation();
        const item = e.dataTransfer.getData("text/plain");
        const parsedItem = JSON.parse(item);
        const decodedItem = ItemDecoder.verify(parsedItem);
        const position = state[category].findIndex((i) => i.id === id);
        dispatch({
          type: "UPDATE_CATEGORY",
          id: decodedItem.id,
          newCategory: category,
          oldCategory: decodedItem.category,
          position,
        });
        dispatch({
          type: "UPDATE_DRAG_OVER",
          category,
          id,
          isDragOver: false,
        });
      }}
    >
      <div className={"itemContent" + (isDragOver ? " dashed" : "")}>
        <h2>{content}</h2>
        <button onClick={() => dispatch({ type: "DELETE", category, id })}>
          <DeleteIcon height={13} width={13} />
        </button>
      </div>
    </div>
  ));
};
Вход в полноэкранный режим Выход из полноэкранного режима

Перетаскиваемый

Чтобы сделать div перетаскиваемым. draggable={true} добавляется в свойства div DOM.

OnDragStart

Слушатель OnDragStart срабатывает при перетаскивании элемента. Необходимые данные сохраняются как string в dataTransfer Drag and Drop API.

onDragStart={(e: React.DragEvent<HTMLDivElement>) => {
    e.dataTransfer.setData(
      "text/plain",
      JSON.stringify({ id, content, category, isDragOver })
    );
}}
Вход в полноэкранный режим Выход из полноэкранного режима

onDragOver и onDragLeave

Эти два слушателя срабатывают, когда элемент перетаскивается на другой элемент на доске Kanban или покидает ее.

onDragOver={(e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    dispatch({
      type: "UPDATE_DRAG_OVER",
      category,
      id,
      isDragOver: true
    });
  }}
onDragLeave={(e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    dispatch({
      type: "UPDATE_DRAG_OVER",
      category,
      id,
      isDragOver: false
    });
}}
Вход в полноэкранный режим Выход из полноэкранного режима

onDrop

Наконец, у нас есть слушатель onDrop. Он аналогичен слушателю onItemsDrop для столбцов доски Kanban. e.stopPropagation() предотвращает распространение этого слушателя на родительские элементы и повторное срабатывание того же слушателя. Ознакомьтесь с этой статьей, чтобы узнать, как это работает.

onDrop={(e: React.DragEvent<HTMLDivElement>) => {
    e.stopPropagation();
    const item = e.dataTransfer.getData("text/plain");
    const parsedItem = JSON.parse(item);
    const decodedItem = ItemDecoder.verify(parsedItem);
    const position = state[category].findIndex((i) => i.id === id);
    dispatch({
      type: "UPDATE_CATEGORY",
      id: decodedItem.id,
      newCategory: category,
      oldCategory: decodedItem.category,
      position
    });
    dispatch({
      type: "UPDATE_DRAG_OVER",
      category,
      id,
      isDragOver: false
    });
}}
Вход в полноэкранный режим Выход из полноэкранного режима

Элемент доски Канбан

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

<div className={"itemContent" + (isDragOver ? " dashed" : "")}>
  <h2>{content}</h2>
  <button onClick={() => dispatch({ type: "DELETE", category, id })}>
    <DeleteIcon height={13} width={13} />
  </button>
</div>;
Вход в полноэкранный режим Выход из полноэкранного режима

Заключение

Мы подошли к концу этой статьи. Есть еще функции, которые можно улучшить или добавить к нашей доске Kanban. Вот их неполный список.

  1. Обновление заголовка элемента
  2. Содержание тела элемента Канбан
  3. Сохранение данных элемента Kanban в базу данных/хранилище.
  4. Назначение персоны для элемента Канбан.

Цель этой статьи — дать толчок к тому, как создать Kanban Board без каких-либо внешних библиотек, и я надеюсь, что мне это удалось. Спасибо за прочтение!

Оцените статью
devanswers.ru
Добавить комментарий