[Next.js]はじめてのdnd-kit　その3（複数コンテナ並べ替え）

## はじめに
Next.jsを触ってみたいと、学習用に簡単なアプリを作っており、その中で新たに試したことを記事にしています。
それでこれからNext.js始めてみようという方の参考になれば嬉しいです。

## やりたいこと
カンバンアプリのように、複数のコンテナの中のアイテムの並べ替えをドラッグアンドドロップで行いたい。

前回、単一コンテナの並べ替えを記事にしました。
その続きとして複数コンテナでの並べ替えを行っていきます。

「はじめてのdnd-kit」シリーズ全4回の3回目です。

過去分はこちら

https://qiita.com/mkthrkw/items/447b88f87ae92dc1ad08

https://qiita.com/mkthrkw/items/278369e2b596e657eaa5

続きはこちら

https://qiita.com/mkthrkw/items/5db4e919d71980ae54ef


## 環境
下記のDocker開発環境にて行います。

https://qiita.com/mkthrkw/items/30115c9ac54c2204faef

## 参考
こちらを参考にさせていただきました。ありがとうございます！！！

https://zenn.dev/hotari/articles/4c186b83b15975


## イメージ

![複数コンテナ２.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/ee624a3f-b510-fdb1-bef1-06380db99383.png)

・１つの```DndContext```の中に、複数の```SortableContext```を配置します。
・空の列にもドロップ出来るように```SortableContext```の中に```Droppable```を配置し、さらにその中にuseSortableで作成した並べ替えアイテムを配置していきます。

https://docs.dndkit.com/presets/sortable#multiple-containers


## サンプルデータ

サンプルデータは1つのプロジェクトの中に、いくつかのリストがあり、その中にいくつかのチケットが入っているものにします。

```typescript
export const sampleProjectData =
{
  id: 'PJ1',
  name: 'Project 1',
  description: 'This is project 1',
  image_url: '',
  lists: [
    {
      id: 'L1',
      title: 'List 1',
      tickets: [
        {id: 'T1', title: 'Ticket 1', description: 'This is ticket 1'},
        {id: 'T2', title: 'Ticket 2', description: 'This is ticket 2'},
        {id: 'T3', title: 'Ticket 3', description: 'This is ticket 3'},
      ],
    },
    {
      id: 'L2',
      title: 'List 2',
      tickets: [
        {id: 'T4', title: 'Ticket 4', description: 'This is ticket 4'},
        {id: 'T5', title: 'Ticket 5', description: 'This is ticket 5'},
        {id: 'T6', title: 'Ticket 6', description: 'This is ticket 6'},
      ],
    },
    {
      id: 'L3',
      title: 'List 3',
      tickets: [
        {id: 'T7', title: 'Ticket 7', description: 'This is ticket 7'},
        {id: 'T8', title: 'Ticket 8', description: 'This is ticket 8'},
        {id: 'T9', title: 'Ticket 9', description: 'This is ticket 9'},
      ],
    },
    {
      id: 'L4',
      title: 'List 4',
      tickets: [],
    },
  ],
}
```

## コンポーネントの組み合わせ
その2で使ったコンポーネントをそのまま使います。

```typescript
  const [projectData, setProjectData] = useState<ProjectDetail>(sampleProjectData);
  const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);

  return (
    <DndContext
      onDragEnd={handleDragEnd}
      onDragStart={handleDragStart}
      onDragOver={handleDragOver}
      collisionDetection={customClosestCorners}
      id={projectData.id}
    >
      <div className='flex justify-center items-center w-screen h-screen gap-8'>
        {projectData.lists.map((list) => (
          <SortableContext items={list.tickets} key={list.id} id={list.id} strategy={verticalListSortingStrategy}>
            <Droppable key={list.id} id={list.id}>
              <div className='flex flex-col gap-8 p-4 border min-h-[600px] min-w-44'>
                {list.tickets.map((ticket) => (
                  <Sortable key={ticket.id} id={ticket.id}>
                    <SortableItem itemId={ticket.id}/>
                  </Sortable>
                ))}
              </div>
            </Droppable>
          </SortableContext>
        ))}
        {activeId && (
          <DragOverlay>
            <SortableItem itemId={activeId} />
          </DragOverlay>
        )}
      </div>
    </DndContext>
  );
```

### DndContext
・一番外側に配置し、各ドラッグイベントを設定します（処理については後ほど記載します）
・collisionDetectionで衝突検出アルゴリズム```customClosestCorners```を設定します。
（イベント処理、衝突検出アルゴリズムについてはこの後で詳細を記載）

### SortableContext
・複数リストの配列になっているため、map関数で反復してリスト毎にSortableContextを配置します。

### Droppable
・空のリストにもドロップ出来るように、Droppableを配置します。

### Sortable,SortableItem
・複数チケットの配列になっているため、map関数で反復してSortable.tsxとSortableItem.tsxを使って配置。

### DragOverlay
・コンテナ間の移動はドラッグ中に元のコンテナーからアンマウントし、別のコンテナーに再度マウントが必要
・表示に影響を与えないようにするために```DragOverlay```を利用してドラッグ中のアイテムを表示します。
・ドラッグ中のみ```DragOverlay```が表示される必要があるので、activeIdで表示/非表示を判別していきます。


https://docs.dndkit.com/api-documentation/draggable/drag-overlay#when-should-i-use-a-drag-overlay

## 衝突検出アルゴリズム

下記ドキュメントにもあるように```closestCenter```では、Droppableが重なった時に意図しない方を取得してしまう可能性があるので```closestCorners```の方が望ましいようです。
ドラッグアイテムの4つの角すべてと、各ドロップ可能なコンテナーの4つの角の間の距離を測定して、最も近いものを見つけるものです。

https://docs.dndkit.com/api-documentation/context-provider/collision-detection-algorithms#when-should-i-use-the-closest-corners-algorithm-instead-of-closest-center

ただ、そのままだと隣接するリスト同士が近いこともあり、空のリストに移動したい時に近い別のリストのチケットを取得してしまう問題があります。
（リストの4つの角より、別リストのチケットの角の方が近いという判定になってしまう）

![リストに入れれない.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/e80552ea-ec68-c4fb-ac0a-e50de588f8c1.gif)

そのため、移動しようとしているリスト以外のチケットアイテムは除外するという独自のアルゴリズムを作成します。

```typescript
  const customClosestCorners: CollisionDetection = (args) => {
    const cornerCollisions = closestCorners(args);
    // 一番近いリストのコンテナを取得
    const listIds = new Set(projectData.lists.map(list => list.id));
    const closestContainer = cornerCollisions.find((c) => {
      return listIds.has(c.id.toString())
    });
    if(!closestContainer) return cornerCollisions;
    // closestContainerの中のチケットのみを取得
    const collisions = cornerCollisions.filter(({ data }) => {
      if(!data) return false;
      const droppableData = data.droppableContainer?.data?.current;
      if(!droppableData) return false;
      const { containerId } = droppableData.sortable;
      return closestContainer.id === containerId;
    });
    // 中身のチケットがない場合は、closestContainerを返す
    if (collisions.length === 0) {
      return [closestContainer];
    }
    // 中身のチケットがある場合は、collisionsを返す
    return collisions;
  };
```

〜以下部分的に記載〜

### 1. closestCornersの値を取得
一度closestCornersの値を格納
```typescript
const cornerCollisions = closestCorners(args);
```
下記のようなオブジェクトが複数入った配列を取得します。
リスト、チケット全てが取得されます。
```json
{
	"id": "T8",
	"data": {
		"value": 0,
		"droppableContainer": {
			"disabled": false,
			"id": "T8",
			"key": "Droppable-34",
			"node": "...省略...",
			"rect": "...省略...",
			"data": {
				"current": {
					"sortable": {
						"containerId": "L3",
						"index": 1,
						"items": [
							"T7",
							"T8",
							"T9"
						]
					}
				}
			}
		}
	}
}
```

### 2. 上記１の中から一番近いコンテナ（リスト）を取得
配列が近い順に渡されるため、findで最初のコンテナ（リスト）を取得（チケットはスキップ）

```typescript
// 一番近いリストのコンテナを取得
const listIds = new Set(projectData.lists.map(list => list.id));
const closestContainer = cornerCollisions.find((c) => {
  return listIds.has(c.id.toString())
});
if(!closestContainer) return cornerCollisions;
```

### 3. 上記2のclosestContainerに所属するチケットだけを抽出

オブジェクトの中身を辿って```containerId```を確認し、closestContainerに一致するもののみを取得。
リストはdroppableDataが```undefined```なのでスキップされ、チケットのみ取得される。

```typescript
// closestContainerの中のチケットのみを取得
const collisions = cornerCollisions.filter(({ data }) => {
  if(!data) return false;
  const droppableData = data.droppableContainer?.data?.current;
  if(!droppableData) return false;
  const { containerId } = droppableData.sortable;
  return closestContainer.id === containerId;
});
```

### 4. 上記3のcollisionsの中身によって返却内容を判別
collisionsの中身があればcollisionsを返却。
collisionsの中身がなければ（空のリストであれば）そのリストを返却。

```typescript
// 中身のチケットがない場合は、closestContainerを返す
if (collisions.length === 0) {
    return [closestContainer];
}
// 中身のチケットがある場合は、collisionsを返す
return collisions;
```


## DragOverlay用の処理（activeId制御）
```DragOverlay```をドラッグ中のみ表示するため、ドラッグ開始時にactiveIdにドラッグ中のアイテムIDを格納します。
```typescript
function handleDragStart(event: DragStartEvent) {
  const {active} = event;
  if(!active) return;
  setActiveId(active.id);
}
```
併せてドロップ後に```DragOverlay```を非表示にするためactiveIdを空にします。
```typescript
function handleDragEnd(event: DragEndEvent) {
　setActiveId(null);
```


## onDragEnd（コンテナ内移動）

「コンテナ内の移動」はDragEndでドロップ時に処理。

```typescript
  function handleDragEnd(event: DragEndEvent) {
    setActiveId(null);
    const data = getData(event, projectData);
    if(!data) return;
    const {from, to} = data;
    if(from.containerId !== to.containerId) return;
    const list = projectData.lists.find(list => list.id == from.containerId);
    if(!list) return;
    const newTickets = arrayMove(list.tickets, from.index, to.index);
    const newLists = projectData.lists.map(list => {
      if(list.id === from.containerId) return {...list, tickets: newTickets};
      return list;
    });
    setProjectData({...projectData, lists: newLists});
  }
```

〜以下部分的に記載〜

### 1.eventからfrom,toを取得

```typescript
const data = getData(event, projectData);
if(!data) return;
const {from, to} = data;
```
この部分はgetDataという関数に切り出しました。
- DragEndEventから移動元のactive、移動先のoverを取り出し
- activeからdata.current.sortableを取り出し
- overからdata.current.sortableを取り出し
- form,toデータとして返却
空のリストへの移動の場合、overはdata.current.sortableがないので、その場合はtoデータをcontainerIdがover.idで、index,itemsが空のデータとして返却


```typescript
export function getData(event: { active: Active; over: Over | null }, projectData: ProjectDetail) {
  const {active, over} = event;
  // キャンセルされた、もしくはターゲットがない場合はリターン
  if(!active || !over) return;
  // ドラッグアイテムとターゲットが同じ場合はリターン
  if(active.id === over.id) return;
  // activeのデータを取得
  const fromData = active.data.current?.sortable;
  if(!fromData) return;
  // overのデータを取得
  const toData = over.data.current?.sortable;
  const toDataNotSortable = {
    containerId: over.id,
    index: NaN,
    items: NaN,
  }
  // データを返す
  return {
    from: fromData,
    to: toData ?? toDataNotSortable,
  };
}
```

### 2.コンテナ内移動かチェック
fromとtoのコンテナが一致しない場合は、onDragOverでの処理のためリターン
```typescript
if(from.containerId !== to.containerId) return;
```

### 3.リストデータを取得
```typescript
const list = projectData.lists.find(list => list.id == from.containerId);
if(!list) return;
```

### 4.並び替え後のチケット一覧データを取得
準備されている関数```arrayMove```を使って並べ替え後のticketsを取得
```typescript
const newTickets = arrayMove(list.tickets, from.index, to.index);
```

### 5.並べ替え後のリスト一覧データを作成
上記4で作成したticketsと差し替えた新しいlistsを作成
```typescript
const newLists = projectData.lists.map(list => {
    if(list.id === from.containerId) return {...list, tickets: newTickets};
    return list;
});
```

### 5.データを更新
全体のデータを```setProjectData```で更新
```typescript
setProjectData({...projectData, lists: newLists});
```

## onDragOver（別コンテナへの移動）

「コンテナから別のコンテナへの移動」はDragOverでドラッグ中に移動処理

```typescript
  function handleDragOver(event: DragOverEvent){
    const data = getData(event, projectData);
    if(!data) return;
    const {from, to} = data;
    if(from.containerId === to.containerId) return;
    const fromList = projectData.lists.find(list => list.id == from.containerId);
    const toList = projectData.lists.find(list => list.id == to.containerId);
    if(!fromList || !toList) return;
    const moveTicket = fromList.tickets.find(ticket => ticket.id === from.items[from.index]);
    if(!moveTicket) return;
    const newFromTickets = fromList.tickets.filter((ticket) => ticket.id !== moveTicket.id);
    const newToTickets = [...toList.tickets.slice(0, to.index), moveTicket, ...toList.tickets.slice(to.index)];
    const newLists = projectData.lists.map(list => {
      if(list.id === from.containerId) return {...list, tickets: newFromTickets};
      if(list.id === to.containerId) return {...list, tickets: newToTickets};
      return list;
    });
    setProjectData({...projectData, lists: newLists});
  }
```

〜以下部分的に記載〜

### 1.eventからfrom,toを取得
内容はonDragEndと同じ
```typescript
const data = getData(event, projectData);
if(!data) return;
const {from, to} = data;
```

### 2.別コンテナへの移動かチェック
fromとtoのコンテナが一致する場合は、onDragEndでの処理のためリターン

```typescript
if(from.containerId === to.containerId) return;
```

### 3.移動元と移動先のリストデータを取得
```typescript
const fromList = projectData.lists.find(list => list.id == from.containerId);
const toList = projectData.lists.find(list => list.id == to.containerId);
if(!fromList || !toList) return;
```


### 4.移動元と移動先の並び替え後のリストデータを作成
・移動中アイテムを取得
・移動元リストはfilterで移動中のアイテムを除外
・移動先リストは対象の場所に、移動中アイテムを挿入
```typescript
const moveTicket = fromList.tickets.find(ticket => ticket.id === from.items[from.index]);
if(!moveTicket) return;
const newFromTickets = fromList.tickets.filter((ticket) => ticket.id !== moveTicket.id);
const newToTickets = [...toList.tickets.slice(0, to.index), moveTicket, ...toList.tickets.slice(to.index)];
```

### 5.並べ替え後のリスト一覧データを作成
上記4で作成したticketsと差し替えた新しいlistsを作成
```typescript
const newLists = projectData.lists.map(list => {
    if(list.id === from.containerId) return {...list, tickets: newFromTickets};
    if(list.id === to.containerId) return {...list, tickets: newToTickets};
    return list;
});
```

### 5.データを更新
全体のデータを```setProjectData```で更新
```typescript
setProjectData({...projectData, lists: newLists});
```


## テスト
無事コンテナを跨いでチケットの入れ替えが出来るようなりました。

![テスト用.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/db1d1734-e37e-47b4-021a-5cb1cdfa32aa.gif)


## つづき

https://qiita.com/mkthrkw/items/5db4e919d71980ae54ef


## さいごに
・複数コンテナの並び替えが何とかできました。
・センサーのカスタマイズや、SotableContextをネストしてリスト自体の入れ替えをしたりと、実際のアプリで使えるようにもっと理解が必要だなと感じました。

## 参考

https://zenn.dev/hotari/articles/4c186b83b15975

https://docs.dndkit.com/presets/sortable