0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

やりたいこと

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

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

環境

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

イメージ

メモ (1).png

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

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

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

サンプルデータ

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」を追加しました。

このような組み合わせです。

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

image.png

  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」とほとんど同じですが、リスト移動にも対応するように下記としました。

  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とする

    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

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

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

    // 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の中身がなければ(空のリストであれば)そのコンテナを返却。

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

DragOverlay用の処理(activeId制御)

チケットのみコンテナを跨ぐためDragOverlayをドラッグ中のみ表示。
そのため、ドラッグ開始時にactiveIdにドラッグ中のアイテムIDを格納します。

  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を空にします。

function handleDragEnd(event: DragEndEvent) {
 setActiveId(null);

onDragOver(チケットの別コンテナへの移動)

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

  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だった場合(リスト移動の場合)は即時リターン。

    // リストの移動の場合はリターン
    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が空のデータとして返却
    // 移動情報を取得
    const data = getData(event);
    if(!data) return;
    const {from, to} = data;
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の中身を空のコンテナとして上書き。

    // 同じコンテナ内の移動の場合はリターン
    if(from.containerId === to.containerId) return;
    // 移動先のコンテナがプロジェクトのコンテナの場合は、移動先のコンテナをドロップターゲットにする
    if(to.containerId === projectData.id){
      to.containerId = event.over?.id;
      to.index = NaN;
      to.items = NaN;
    }

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

    // 移動元と移動先のリストを取得
    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で移動中のアイテムを除外
・移動先リストは対象の場所に、移動中アイテムを挿入

    // 移動するチケットを取得
    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.並べ替え後のデータへ更新

newFromTicketsnewToTicketsで新しいlistsを作成。
setProjectDataでデータを更新。

    // チケットの順番を更新
    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でドロップ時に処理。

  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と同じ処理

    // 移動情報を取得
    const data = getData(event);
    if(!data) return;
    const {from, to} = data;

2.コンテナ内移動かチェック

fromとtoのコンテナが一致しない場合は、onDragOverでの処理のためリターン
チケットでもリストでもonDragEndで処理するのは同一コンテナ内の移動になるため。

if(from.containerId !== to.containerId) return;

3.リスト移動の場合の処理

・fromのコンテナIDがプロジェクトIDの場合はリスト移動と判定。
・準備されている関数arrayMoveを使って並べ替え後のlistsを取得
setProjectDataでデータを更新

    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でデータを更新

    }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

さいごに

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

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?