Next.jsでTrello風タスク管理アプリを作成する日記④

# はじめに
前回はカラム内の並び替えを実装したのですが、カラムを跨ぐような処理は実装できていない状態です。
![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601137/ac6e20e6-4901-ce05-0b18-37be9256da89.gif)

↑の状態から別カラムにドラックした際にドラックしたTodoのstatusがカラムに応じて変更されてほしいです。

# 前回までのあらすじ

**[Next.jsでTODOアプリを作成する日記①](https://qiita.com/naitoyuma/items/dd60203cdacc026afdf5)**
- [x] Tailwindを導入できた
- [x] ComponentにPropsを渡せた
- [x] Componentの中でPropsを使用できた
- [x] Componentをループを使って表示できた
- [x] React Iconsを導入できた

**[Next.jsでTODOアプリを作成する日記②](https://qiita.com/naitoyuma/items/409f2e14624d8983d0cd)**
- [x] useStateを使って状態管理ができた
- [x] setStateを使って状態の更新ができた
- [x] 状態に対するイミュータブル(不変)な操作の必要性が分かった
- [x] 子側で親のイベントをトリガーできた  

**[Next.jsでTrello風タスク管理アプリを作成する日記③](https://qiita.com/naitoyuma/items/8c40216f3c2d759ac4f9)**
- [x] filterをかけて条件に一致したデータでComponentを表示できた
- [x] Dnd kikを導入できた
- [x] Dnd kikのDndContextとソート機能に関するSortableContextでカラム内ソートを実装できた

## コンポーネントを整理する
アプリの作成を行うなかで、状態に応じたのTodoを表示するカラムを作成しました。
異なる状態カラムへTodoをドロップするとTodoの状態が変化し、更に順番も変化するという機能を実装したいと思います。

また機能が複雑化しそうなので、カラムをコンポーネントとして切り出し機能の責任範囲を整理しようと思います。
※誤字です：Clmun→Column
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601137/e343a557-f70b-a101-274b-78e45b8d6f6b.png)

コードは割愛

## カラム間のDrag＆Dropでつまづく
コンポーネントを整理した後こちらの記事を見ながらカラム間のドロップを実装しようと試みたのですが苦戦しました。

https://zenn.dev/aldagram_tech/articles/c2cf248bd016fc

DragEndやDragOverのイベントに対してHndlerを設定していない簡単なサンプルを作成して挙動を確認しました。
![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601137/63272534-aa9f-f62c-7457-c32b6e692055.gif)

アイテムを把持してカラム間を横断しようとすると引き戻されてしまいます。

またColumnとドラッグするItemにidを設定し取得できるover.idをコンソール上で確認したのですが、Column上にドラッグしてもほとんどItemのidが返ってきてしまい困りました。
(取得したColumnのidに応じた処理を実装したかったので)

最初はこれがdndのComponentsの配置ミスによるものかと思って時間かかったのですがそうではなかったです。

> [Reactのドラッグ&ドロップライブラリ「dnd kit」を使ってかんばんボードを作る](https://codesandbox.io/p/sandbox/dnd-kit-kanban-board-1df69n)

上記のSandboxのコードから手がかりをもらったので紹介させて頂きます。

```tsx
    // colmunがCardを配列として持つデータ構造となっています
    const data: ColumnType[] = [
    {
      id: "Column1",
      title: "Column1",
      cards: [
        {
          id: "Card1",
          title: "Card1"
        },
        {
          id: "Card2",
          title: "Card2"
        }
      ]
    },
    {
      id: "Column2",
      title: "Column2",
      cards: [
        {
          id: "Card3",
          title: "Card3"
        },
        {
          id: "Card4",
          title: "Card4"
        }
      ]
    }
  ];

  const [columns, setColumns] = useState<ColumnType[]>(data);

  // ドラックした場所にあるCardもしくはColmunのidを取得した際
  // 上記のデータ構造からどのカラムにドロップされたかCard idから走査する
  const findColumn = (unique: string | null) => {
    if (!unique) {
      return null;
    }
    // overの対象がcolumnの場合があるためそのままidを返す
    if (columns.some((c) => c.id === unique)) {
      return columns.find((c) => c.id === unique) ?? null;
    }

    // over対象がcardの場合取得したidからover先がどのカラムか取得する
    const id = String(unique);
    const itemWithColumnId = columns.flatMap((c) => {
      const columnId = c.id;
      return c.cards.map((i) => ({ itemId: i.id, columnId: columnId }));
    });
    const columnId = itemWithColumnId.find((i) => i.itemId === id)?.columnId;
    return columns.find((c) => c.id === columnId) ?? null;
  };
```
上記のfindColmunメソッドを各種Handlerで使用し、取得したover.idからColmun.idを算出している事が分かりました。

現在カラム内の入れ替わりだけを想定していたHandlerに修正を加えhandlerOnDragOverを作成しました。
```tsx
// 引数のidをもとにカラムidを返す
const findColumn = (id: string | null) => {
    if (!id) {
        return null;
    }
    // colmuのidが返ってきた場合はそのまま返す
    if (id === ("Incomplete" || "Progress" || "Done")) {
        return id;
    }
    // itemのidが渡された場合、itemもつカラムのidを返したい
    return todoItemList.find((todo) => todo.id === id)?.status;
};


const handleDragOver = (event: DragEndEvent) => {
    const { active, over } = event;
    console.log("over");

    if (!over || active.id === over.id) {
        return;
    }

    // finfColumnを使用しカラムidを特定
    const overId = String(over.id);
    const overColumn = findColumn(overId);

	// over先todoのidが異なればデータの入れ替えを行う
    if (active.id !== over.id) {
        const oldIndex = todoItemList.findIndex((v) => v.id === active.id);
        const newIndex = todoItemList.findIndex((v) => v.id === over.id);

        // active.idからtodoを特定しstatusをcolumnのid(status)に変更する
        const updatedTodoList = todoItemList.map((todo) => {
            return todo.id === String(active.id)
                ? { ...todo, status: (overColumn as Status) || (overId as Status) }
                : todo;
        });

        setTodoList(arrayMove(updatedTodoList, oldIndex, newIndex));
    }
};
```

若干挙動が怪しいですがカラム間の移動ができるようになりました。
カラム跨ぎと順番の入れ替え両方を行った時が怪しいです。(原因分かったら追記します)
![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601137/542e16f8-b32c-a806-9efc-834c0bf8025d.gif)

上記のカラム間の移動をタスク管理アプリに実装しました。

データの入れ替えやStatusの変更を全てonDragOverで行っているため、Todoを保持してドロップせずに移動させると移動場所に応じてデータが更新されています。
![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601137/2c237d7c-d1c4-774d-9ee7-857ebeb1c7b5.gif)

ドロップしていないのにデータの順番が入れ替わるのはちょっと嫌なので

- onDragOver:カラム間を横断した時にそのTodoのStatusをカラムに合わせる
- onDragEnd:ドロップした場所に応じて順番を入れ替える

のようにonDragEndでデータの順番を更新する事にしました。

```tsx
const handleDragOver = (event: DragEndEvent) => {
    const { active, over } = event;

    console.log("over");

    if (!over || active.id === over.id) {
        return;
    }

    const overId = String(over.id);
    const overColumn = findColumn(overId);

    // active.idからtodoを特定しstatusをcolumnのid(status)に変更する
    const updatedTodoList = todoItemList.map((todo) => {
        return todo.id === String(active.id)
            ? { ...todo, status: (overColumn as Status) || (overId as Status) }
            : todo;
    });

    setTodoList(updatedTodoList);
};

const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;

    console.log("end");

    if (!over || active.id === over.id) {
        return;
    }

    const overId = String(over.id);
    const overColumn = findColumn(overId);

    // over先todoのidが異なればデータの入れ替えを行う
    if (active.id !== over.id) {
        const oldIndex = todoItemList.findIndex((v) => v.id === active.id);
        const newIndex = todoItemList.findIndex((v) => v.id === over.id);

        // active.idからtodoを特定しstatusをcolumnのid(status)に変更する
        setTodoList(arrayMove(todoItemList, oldIndex, newIndex));
    }
};
```

ドラック中には順番は入れ替わらずStatusだけ変更し、ドロップでデータの順番が入れ替わるよう設定しました。
![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601137/004b20ea-e1a6-f1cb-f38f-91a002ba4655.gif)

※直せなかった部分：カラムにTodoがない時、Todoが吸い込まれた後張り付いてドラックできない時があります
![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601137/cae9a5f7-bc72-e7cf-3251-13c58d527042.gif)


カラム間のドラックについてポイントを絞ってコードを記載します。
```tsx:todoListForm.tsx
   { /* DndContextはドラック対象のカラムを全て子要素として持つようにする */}
    <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        {/* 先ほど記載したhandlerを渡す */}
        onDragEnd={handleDragEnd}
        onDragEnd={handleDragEnd}>
        
        { /* カラムはmapで複数描画する。TodoItemの配列もこの時渡す */}
        {statuses.map((status, i) => {
            const filteredTodoList = todoItemList.filter(
                (item) => item.status === status
            );

            return (
                <div key={status} className="mx-2 px-4 py-2 rounded-lg bg-gray-200">
                    <span className="inline-flex items-center py-1.5 px-3 mb-1 rounded-full text-xs font-medium bg-gray-500 text-white">
                        {status}
                    </span>
                    {/* カラムのonDragイベントにactive.id、over.idとしてstatusを渡したい
                    　　各TodoColumnにstatusを渡しコンポーネント内部でその設定を行う*/}
                    <TodoColmun
                        status={status}
                        todoList={filteredTodoList}/>
                </div>
            );
        })}

    </DndContext>
```

```tsx:todoColumn.tsx
const TodoColmun = (props: TodoColmunProps) => {
    {/* カラムをドロップ可能要素に設定し渡されたstatusをhandlerに渡す準備をする */}
	const { setNodeRef } = useDroppable({ id: props.status });

	return (
        {/* SortableContextはカラム単位で設定し複数のTodoを含むようにする */}
		<SortableContext
            {/* カラムidはstatusを設定する */}
            id={props.status}
            // 並べ替えを行う配列を指定する
			items={props.todoList}
			strategy={rectSortingStrategy}>
            {/* TodoColumnにDrop,Overされるとstatusをhandlerに渡す */}
            <div ref={setNodeRef}>
                {props.todoList.map((todo) => (
                    <TodoItem key={todo.id} todo={todo} isSortable />
                )}
            </div>
		</SortableContext>
	);
};
```

```tsx:todoItem.tsx
    ...省略

    {/* TodoItemをソート可能要素に設定し渡されたtodo.idをhandlerに渡す準備をする */}
    const { attributes, listeners, setNodeRef, transform, transition } =
    useSortable({
        id: props.todo.id,
    });

	return (
		<div
            {/* TodoItemにDrop,Overされるとtodo.idをhandlerに渡す */}
			ref={setNodeRef}
			{...attributes}
			{...listeners}
			style={props.isSortable ? style : { cursor: "default" }}>
        ...
        </div>
        )
```

## Dnd kitの感想
個人的な所感としてDnD kitの素晴らしい点はDndContextが自分の子要素でも親要素でも内包するSortableContextのイベントを全てキャッチしてくれる点だと感じました。

現状のコンポーネント構造では親のDndContextに状態管理されたデータを持たせているため、このデータの変更するには通常、Propsとしてhandlerを渡す必要があるのですが(propsリレー)Dnd kitを使えばその必要がないと感じました。

またimportしたstyleを渡せばドラック＆ドロップのアニメーションも付けてくれます。

一番重要なデータの入れ替えや更新処理についてはhandlerを自分で作成しなければいけないのが大変でしたが、実装内容によっては色々なデータ構造や実現したい処理があると思うのでそれでいいと思います。

# まとめ
公式ドキュメントを早く的確に読めればいいのですが、なかなか答えを探し出す事ができません。
なのでみなさんの動いているコードをマネさせてもらって最後にちょっと変えて自分のものにしています。

- [x] Dnd kitのDropableでカラム間を移動した時に対応したidをhandlerに渡す事ができた
- [x] onDragEndとonDragOverを使い分けてhandlerを作成できた 
- [x] カラム間を横断するドラック＆ドロップアイテムを作成できた 

[Sandboxで公開](https://codesandbox.io/p/github/naitoyuma7110/next-tutorial/master?embed=1&file=%2Fsrc%2Fcomponents%2FTodo%2FtodoColmun.tsx&layout=%257B%2522sidebarPanel%2522%253A%2522GIT%2522%252C%2522rootPanelGroup%2522%253A%257B%2522direction%2522%253A%2522horizontal%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522id%2522%253A%2522ROOT_LAYOUT%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522clpwkatfl0006356jlxr7i5rc%2522%252C%2522sizes%2522%253A%255B70%252C30%255D%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522EDITOR%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522id%2522%253A%2522clpwkatfl0002356jhoptrr3k%2522%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522SHELLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522id%2522%253A%2522clpwkatfl0004356jm58v0k30%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522DEVTOOLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522id%2522%253A%2522clpwkatfl0005356j6506gtja%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%252C%2522sizes%2522%253A%255B40%252C60%255D%257D%252C%2522tabbedPanels%2522%253A%257B%2522clpwkatfl0002356jhoptrr3k%2522%253A%257B%2522id%2522%253A%2522clpwkatfl0002356jhoptrr3k%2522%252C%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clpzcsps20002356iwluctmg8%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522FILE%2522%252C%2522filepath%2522%253A%2522%252Fsrc%252Fcomponents%252FTodo%252FtodoColmun.tsx%2522%252C%2522state%2522%253A%2522IDLE%2522%257D%255D%252C%2522activeTabId%2522%253A%2522clpzcsps20002356iwluctmg8%2522%257D%252C%2522clpwkatfl0005356j6506gtja%2522%253A%257B%2522id%2522%253A%2522clpwkatfl0005356j6506gtja%2522%252C%2522activeTabId%2522%253A%2522clpzbt4z000h3356kfrg2fwdt%2522%252C%2522tabs%2522%253A%255B%257B%2522type%2522%253A%2522TASK_PORT%2522%252C%2522port%2522%253A3000%252C%2522taskId%2522%253A%2522dev%2522%252C%2522id%2522%253A%2522clpzbt4z000h3356kfrg2fwdt%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522path%2522%253A%2522%252F%2522%257D%255D%257D%252C%2522clpwkatfl0004356jm58v0k30%2522%253A%257B%2522id%2522%253A%2522clpwkatfl0004356jm58v0k30%2522%252C%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clpwkatfl0003356jwep4vly8%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522TERMINAL%2522%252C%2522shellId%2522%253A%2522clpwl4lx8001sedh463wv9sij%2522%257D%252C%257B%2522type%2522%253A%2522TASK_LOG%2522%252C%2522taskId%2522%253A%2522dev%2522%252C%2522id%2522%253A%2522clpwkautm006v356jscr60fxu%2522%252C%2522mode%2522%253A%2522permanent%2522%257D%252C%257B%2522type%2522%253A%2522TASK_LOG%2522%252C%2522taskId%2522%253A%2522CSB_RUN_OUTSIDE_CONTAINER%253D1%2520devcontainer%2520templates%2520apply%2520--template-id%2520%255C%2522ghcr.io%252Fdevcontainers%252Ftemplates%252Ftypescript-node%255C%2522%2520--template-args%2520%27%257B%257D%27%2520--features%2520%27%255B%255D%27%2522%252C%2522id%2522%253A%2522clpwkitpu0058356hldtqdh1b%2522%252C%2522mode%2522%253A%2522permanent%2522%257D%255D%252C%2522activeTabId%2522%253A%2522clpwkitpu0058356hldtqdh1b%2522%257D%257D%252C%2522showDevtools%2522%253Atrue%252C%2522showShells%2522%253Atrue%252C%2522showSidebar%2522%253Atrue%252C%2522sidebarPanelSize%2522%253A17.91666666666667%257D)