[Next.js]はじめてのdnd-kit　その4（コンテナのネスト）

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

## やりたいこと
前回、複数コンテナの並べ替えを記事にしました。
その続きとしてさらに、そのコンテナ自体の並べ替えを行えるように、コンテナの入れ子を作成します。

「はじめてのdnd-kit」シリーズ全4回の4回目です。
前回分はこちら

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

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

https://qiita.com/mkthrkw/items/562f748e3002c1c3db48


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

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

## イメージ

![メモ (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/ce86481a-9ff3-f61d-194a-bc04e35c99f8.png)

今回は、コンテナ自体の入れ子になるため、SortableContextの中にさらにSortableContextを入れていきます。

ドキュメントUsageの一番下の形になります。

https://docs.dndkit.com/presets/sortable/sortable-context#usage

```typescript
// Good, nested Sortable contexts with unique `id`s
<DndContext>
  <SortableContext items={["A, "B", "C"]}>
    <SortableContext items={[1, 2, 3]}>
      {/* ... */}
    </SortableContext>
  </SortableContext>
</DndContext>
```

## サンプルデータ
```typescript
type SampleTicketType = {
  id: string,
  title: string,
  description: string,
}

type SampleListType = {
  id: string,
  title: string,
  tickets: SampleTicketType[],
}

export type SampleProjectType = {
  id: string,
  name: string,
  description: string,
  image_url: string,
  lists: SampleListType[],
}

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: [],
    },
  ],
}
```

## コンポーネントの組み合わせ
前回の「その3」の内容にリスト移動用の「SortableContext」「Sortable」を追加しました。

このような組み合わせです。
```typescript
<DndContext>                         // ①プロジェクト全体をラップするコンテキスト
  <SortableContext>                   // ②リスト要素をラップするコンテキスト
    {projectData.lists.map((list) => ( // ③リスト要素をループで取得
      <Sortable>                        // ④リスト
        <SortableContext>                // ⑤チケット要素をラップするコンテキスト
          <Droppable>                     // ⑥チケットがない場合のドロップエリア
          {list.tickets.map((ticket) => (  // ⑦チケット要素をループで取得
            <Sortable>                      // ⑧チケット
              // チケットアイテム
            </Sortable>
          ))}
          </Droppable>
        </SortableContext>
      </Sortable>
    ))}
  </SortableContext>
</DndContext>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/6346db9f-9984-9c79-9216-7374ec09ed81.png)


```typescript
  const [projectData, setProjectData] = useState<SampleProjectType>(sampleProjectData);
  const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
  
    return (
    <DndContext
      onDragEnd={handleDragEnd}
      onDragStart={handleDragStart}
      onDragOver={handleDragOver}
      collisionDetection={customClosestCorners}
      id={projectData.id}
    >
      <SortableContext items={projectData.lists.map((list) => list.id)} id={projectData.id} key={projectData.id}>
        <div className='flex justify-center w-screen h-screen gap-8 overflow-auto pt-16 px-4 pb-4'>
          {projectData.lists.map((list) => (
            <Sortable id={list.id} key={list.id}>
              <SortableContext items={list.tickets} key={list.id} id={list.id} strategy={verticalListSortingStrategy}>
                <div className='min-h-[600px] min-w-44 border rounded-xl p-4'>
                  <h2 className='border-b-4 border-base-content'>{list.title}</h2>
                  <Droppable key={list.id} id={list.id}>
                    <div className='flex flex-col gap-4 mt-4'>
                      {list.tickets.map((ticket) => (
                        <Sortable key={ticket.id} id={ticket.id}>
                          <SortableItem itemId={ticket.id}/>
                        </Sortable>
                      ))}
                    </div>
                  </Droppable>
                </div>
              </SortableContext>
            </Sortable>
          ))}
        </div>
      </SortableContext>
      {activeId && (
          <DragOverlay>
            <SortableItem itemId={activeId} />
          </DragOverlay>
        )}
    </DndContext>
  );
```


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

前回の「その3」とほとんど同じですが、リスト移動にも対応するように下記としました。

```typescript
  const customClosestCorners: CollisionDetection = (args) => {
    const cornerCollisions = closestCorners(args);
    const idLists = projectData.lists.map((list) => list.id);
    // リストを動かしている場合は、プロジェクトのコンテナを取得
    // チケットを動かしている場合は、リストのコンテナで一番近いものを取得
    const closestContainer = cornerCollisions.find((c) => {
      if(idLists.includes(String(args.active.id))){
        return c.id === projectData.id;
      }else{
        return idLists.includes(String(c.id));
      }
    });
    // コンテナが見つからない場合は、cornerCollisionsを返す
    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;
    });
    // collisionsがない場合は、closestContainerを返す
    if (collisions.length === 0) {
      return [closestContainer];
    }
    // collisionsがある場合は、collisionsを返す
    return collisions;
  };
```

〜以下部分的に記載〜

### 1. リストとチケットどちらの移動かを判定し、一番近いコンテナを取得
・ドラッグ中の対象のid(args.active.id)がリストかを判定
・リストを移動中の場合：一番外側のプロジェクトIDのコンテナをclosestContainerとする
・チケットを移動中の場合：findで最初に見つかったリストIDのコンテナをclosestContainerとする

```typescript
    const cornerCollisions = closestCorners(args);
    const idLists = projectData.lists.map((list) => list.id);
    // リストを動かしている場合は、プロジェクトのコンテナを取得
    // チケットを動かしている場合は、リストのコンテナで一番近いものを取得
    const closestContainer = cornerCollisions.find((c) => {
      if(idLists.includes(String(args.active.id))){
        return c.id === projectData.id;
      }else{
        return idLists.includes(String(c.id));
      }
    });
    // コンテナが見つからない場合は、cornerCollisionsを返す
    if(!closestContainer) return cornerCollisions;
```

↓下記画像の青いコンテナか、緑のコンテナのいずれかがclosestContainerとなる

![メモ (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/1c363457-4fe7-a9b1-6525-31ca14fa0554.png)


### 2. closestContainerに所属するアイテムだけを抽出

・cornerCollisionsで取得したオブジェクトの```containerId```を確認し、closestContainerに一致するもののみを取得
・リスト移動中の場合：containerIdはプロジェクトIDになっているため、所属するリスト一覧が取得される
・チケット移動中の場合（移動先にチケットアイテムがある場合）：移動先のコンテナに所属するアイテム一覧が取得される
・チケット移動中の場合（移動先にチケットアイテムがない場合）：移動先のコンテナに所属するアイテムがないので、collisionsは空となる

```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;
    });
```

### 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;
    // ドラッグ中のアイテムが存在しないか、リストの移動の場合はリターン
    const activeContainerId = active.data.current?.sortable.containerId;
    if(!active || activeContainerId === projectData.id) return;
    // ドラッグ中のアイテムをアクティブにする
    setActiveId(active.id);
  }
```
併せてドロップ後に```DragOverlay```を非表示にするためactiveIdを空にします。
```typescript
function handleDragEnd(event: DragEndEvent) {
　setActiveId(null);
```



## onDragOver（チケットの別コンテナへの移動）

リストの移動は処理せず、チケットの「コンテナから別のコンテナへの移動」をDragOverでドラッグ中に移動処理します。

```typescript
  function handleDragOver(event: DragOverEvent){
    // リストの移動の場合はリターン
    if(event.active.data.current?.sortable.containerId === projectData.id) return;
    // 移動情報を取得
    const data = getData(event);
    if(!data) return;
    const {from, to} = data;
    // 同じコンテナ内の移動の場合はリターン
    if(from.containerId === to.containerId) return;
    // 移動先のコンテナがプロジェクトのコンテナの場合は、移動先のコンテナをドロップターゲットにする
    if(to.containerId === projectData.id){
      to.containerId = event.over?.id;
      to.index = NaN;
      to.items = NaN;
    }
    // 移動元と移動先のリストを取得
    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 === event.active.id);
    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.リスト移動は処理しない
リスト移動は単一コンテナ内の移動のため、DragEndイベントで処理する。
そのため、移動対象のコンテナIDを確認して、プロジェクトIDだった場合（リスト移動の場合）は即時リターン。
```typescript
    // リストの移動の場合はリターン
    if(event.active.data.current?.sortable.containerId === projectData.id) return;
```

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

この部分は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
    // 移動情報を取得
    const data = getData(event);
    if(!data) return;
    const {from, to} = data;
```
```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;
    // データを返す
    return {
      from: fromData,
      to: toData,
    };
}
```

### 3.別コンテナへの移動かチェック
fromとtoのコンテナが一致する場合は、onDragEndでの処理のためリターン
移動先コンテナがプロジェクトIDのコンテナ（チケットが空のリストへの移動）の場合は、toの中身を空のコンテナとして上書き。
```typescript
    // 同じコンテナ内の移動の場合はリターン
    if(from.containerId === to.containerId) return;
    // 移動先のコンテナがプロジェクトのコンテナの場合は、移動先のコンテナをドロップターゲットにする
    if(to.containerId === projectData.id){
      to.containerId = event.over?.id;
      to.index = NaN;
      to.items = NaN;
    }
```

### 4.移動元と移動先のリストデータを取得

```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;
```


### 5.移動元と移動先の並び替え後のリストデータを作成
・移動中アイテムを取得
・移動元リストはfilterで移動中のアイテムを除外
・移動先リストは対象の場所に、移動中アイテムを挿入
```typescript
    // 移動するチケットを取得
    const moveTicket = fromList.tickets.find(ticket => ticket.id === event.active.id);
    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)];
```

### 6.並べ替え後のデータへ更新
```newFromTickets```と```newToTickets```で新しいlistsを作成。
```setProjectData```でデータを更新。
```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;
    });
    setProjectData({...projectData, lists: newLists});
```


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

「リストの移動」と「コンテナ内のチケット移動」はDragEndでドロップ時に処理。

```typescript
  function handleDragEnd(event: DragEndEvent) {
    // ドラッグ中のアイテムを非アクティブにする
    setActiveId(null);
    // 移動情報を取得
    const data = getData(event);
    if(!data) return;
    const {from, to} = data;
    // 移動元コンテナと移動先コンテナが違う場合はリターン
    if(from.containerId !== to.containerId) return;
    if(from.containerId === projectData.id){
      // リストの移動
      const movedLists = arrayMove(projectData.lists, from.index, to.index);
      setProjectData({...projectData, lists: movedLists});
      return;
    }else{
      // チケットの移動
      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を取得
```onDragOver```と同じ処理
```typescript
    // 移動情報を取得
    const data = getData(event);
    if(!data) return;
    const {from, to} = data;
```

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

### 3.リスト移動の場合の処理
・fromのコンテナIDがプロジェクトIDの場合はリスト移動と判定。
・準備されている関数```arrayMove```を使って並べ替え後のlistsを取得
・```setProjectData```でデータを更新
```typescript
    if(from.containerId === projectData.id){
      // リストの移動
      const movedLists = arrayMove(projectData.lists, from.index, to.index);
      setProjectData({...projectData, lists: movedLists});
      return;
```

### 4.チケット移動の場合
・対象のリスト（コンテナ）を取得
・準備されている関数```arrayMove```を使って並べ替え後のticketsを取得
・並び替え後のticketsと差し替えた、新しいlistsを作成
・```setProjectData```でデータを更新
```typescript
    }else{
      // チケットの移動
      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});
    }
```


## テスト
無事チケットも、リストも入れ替えが出来るようなりました。

![コンテナ入れ子.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/648ca176-204b-d1f4-1ed9-f3617e206cdc.gif)


## さいごに
・少しdnd-kitに慣れてきたのか、そこまで苦労することなく、コンテナの入れ子ができました。
・もっと細かい設定や機能がありますが、基本的なところは学習できたので「はじめてのdnd-kit」シリーズは終わりにしたいと思います。
・また新しいライブラリなどを使う時に記事にしてみます。

## 参考

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

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