58
61

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でTrello風タスク管理アプリを作成する日記③

Last updated at Posted at 2023-11-27

はじめに

タイトルが変わりました
TODOアプリを作る中で空想が膨らみタスク管理アプリのTrelloみたいなものを作る事にしました。

image.png

前回までのあらすじ

Next.jsでTODOアプリを作成する日記①

  • Tailwindを導入できた
  • ComponentにPropsを渡せた
  • Componentの中でPropsを使用できた
  • Componentをループを使って表示できた
  • React Iconsを導入できた

Next.jsでTODOアプリを作成する日記②

  • useStateを使って状態管理ができた
  • setStateを使って状態の更新ができた
  • 状態に対するイミュータブル(不変)な操作の必要性が分かった
  • 子側で親のイベントをトリガーできた

前回までの画面
image.png

状態に応じてフィルターしたTodoリストを並べる

状態に応じてTodoリストにフィルターをかけ各タスクリストを状態に対応して横並びにしました。

2023-11-21_11h10_12.gif

上記の修正についてポイントを絞って記載します。

状態を配列で定義しこれをループさせて各状態に応じたカラムを作成します

// Status型に含まれませんが"All"という全ての状態のTodoを表示するカラムも作る事にします
const [statuses, setStatuses] = useState([
    "All",
    "Incomplete",
    "Progress",
    "Done",
]);
// useStateを使わないとDOM描画時にstatuses.lengthが取得できなかったです。
// (原因わかったら追記します)

各カラムとTodoItemを描画する部分

return (
    <>
        {/* colsはstatusesに設定した状態の数に合わせる */}
        <div className={`grid grid-cols-${statuses.length}`}>
            {statuses.map((status, i) => {
                // filterを使用してTodoListの状態に応じてフィルタリングする
                const filteredTodoList = todoItemList.filter(
                    // statusが"All"の場合はフィルタリングしない
                    (item) => status === "All" || item.status === status
                );
            
                return (
                    <div key={i} className="mx-2 px-4 py-2 rounded-lg bg-gray-200">
                        {/* statusに対応したタグを設置 */}
                        <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>
                        {/* filterしたTodoListをmapで回してTodoを描画*/}
                        {filteredTodoList.map((todo, j) => (
                            <div key={j}>
                                <TodoItem {...todo} />
                            </div>
                        ))}
                        {/* statusがAllの時だけtTodoFormを設置 */}
                        {status === "All" && <TodoForm addTodoOnclick={addTodoOnClick} />}
                    </div>
                );
            })}
            </div>
		</>

TodoItemで各状態に応じたIcon描画も追加しました
image.png

今更ですがtsxの関数内ではDOMをそのまま変数に定義できる事に驚きました。

components/todoItem.tsx
// 状態に応じたIconのElementを定義しています。
switch (props.todo.status) {
    case "Done":
        ...
        statusValues.bgColor = "bg-emerald-500";
        // ここでElementをそのまま変数に入れてる
        statusValues.iconDom = (
            <FaCheckCircle className="w-6 h-6 text-white fill-current" />
        );
        break;
    case "Progress":
        ...
        statusValues.bgColor = "bg-blue-600";
        // ここでElementをそのまま変数に入れてる
        statusValues.iconDom = (
            <TbProgress className="w-6 h-6 text-white fill-current" />
        );
        break;
    case "Incomplete":
        ...
        statusValues.bgColor = "bg-gray-600";
        // ここでElementをそのまま変数に入れてる
        statusValues.iconDom = (
            <RiZzzFill className="w-6 h-6 text-white fill-current" />
        );
        break;
}

コンポーネントの中でこんな感じで色に関するstyleとセットでiconを描画しています

<div className={`w-12 ${statusValues.bgColor}`}>
    {statusValues.iconDom}
</div>

ドラッグ&ドロップで並べ替える

ドラック&ドロップでカラム内のTodoコンポーネントの並べ替えを作成したいと思います。
下記サンプルではカラム内だけでなくカラムを横断した並び替えを実装していますが、ここではまずはカラム内の並び替えのみを実装します。

下記サイトでライブラリを比較を参考にドラック中の描画がいい感じだったdnd kitというライブラリを使用したいと思います。

Reactのドラッグ&ドロップソートライブラリを比較

公式サイトにサンプルコードもありましたので参考にしながらアプリに仕込みました。

わーい(^^)/
Animation.gif

上記ではアプリとして不足部分がありますが、ここまでの手順をdnd kitの仕組みと合わせポイントを絞ってまとめます。

DnD導入

npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

Dnd kitを仕込む
DnD kitを組み込んだコード部分のみ記載します。

todoListForm.tsx
return (
    <>
        <div className={`grid grid-cols-${statuses.length}`}>
            {statuses.map((status, i) => {
                const filteredTodoList = todoItemList.filter(
                    (item) => status === "All" || item.status === status
                );

                return (
                    <div key={i} 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>

                        {status !== "All" ? (
                            <>
                                // ここから
                                <DndContext
                                    sensors={sensors}
                                    collisionDetection={closestCenter}
                                    onDragEnd={handleDragEnd}>
                                    {filteredTodoList.map((todo, j) => (
                                        <SortableContext
                                            key={todo.id}
                                            items={todoItemList}
                                            strategy={verticalListSortingStrategy}>
                                            <TodoItem todo={todo} isSortable />
                                        </SortableContext>
                                    ))}
                                </DndContext>
                                // ここまで
                            </>
                        ) : (
                            filteredTodoList.map((todo, j) => (
                                <TodoItem key={todo.id} todo={todo} />
                            ))
                        )}
                        {status === "All" && <TodoForm addTodoOnclick={addTodoOnClick} />}
                    </div>
                );
            })}
        </div>
    </>
);

以下のサイトではカラム間のドラック&ドロップが解説付きで実装されているため、DnD kitを知りたい方をすぐにこれらの記事を開いて下さい。

senser

  • ドラッグの開始、移動、終了をどのような操作で実行するか設定する
     ポインター操作とキーボード操作を設定しています。
  • KeyboardSensorにcoordinateGetter:sortableKeyboardCoordinatesを設定する事で矢印キー押下時に隣のアイテムと入れ替えられるみたいです
const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
        coordinateGetter: sortableKeyboardCoordinates,
    })
);

collisionDetection設定:衝突検出のアルゴリズム
もっとも近い要素を取得するclosestCenterを設定しました。

image.png

Handler

onDragEndにドロップしたの際の処理を設定します。

DndContext

ドラック&ドロップによる入れ替わりに必要な処理は自作する必要があります。
ドラックさせるデータをどんな構造で保持しているかは開発者によって異なるため、入れ替わりの処理もそれらに対応させなければいけないのだと思います。

データ構造
// 現状のデータ構造はシンプルな配列です
data:Todo[] = [
{
    id: 1,
    title: "タイトル",
    content: "TODO内容はここに記載します。",
    status: "Done",
},
{
    id: 2,
    title: "タイトル2",
    content: "TODO内容の二番目",
    status: "Progress",
},
{
    id: 3,
    title: "タイトル3",
    content: "TODO内容の3番目",
    status: "Incomplete",
},
]

ドラック&ドロップの動きをデータに紐づけるため

  1. SortableContextのitems propsにバインドしたいデータ配列を設定
  2. keyに配列内の各データに一意の値を設定
<SortableContext
    /* keyの値を設定するためTodoにidを持たせました
       idは後に記述するHandelrで使用します */
    key={todo.id}
    // itemsは入れ替わりが発生するデータ配列を設定します
    items={todoItemList}
    strategy={rectSortingStrategy}>
    ...
</SortableContext>

またTodoItem側でも渡されたidをuseSortableに設定しています。
keyとしてもidを渡しているのですがkeyとuseSortableのidは一致させておく必要がありました。
その辺の処理内容をきちんと調べてないです。

<div
    // ↓この辺はよく分からない
    ref={setNodeRef}
    {...attributes}
    {...listeners}
    // ソートが可能か否かisSortableというpropsに渡して対応したstyleを設定してます。
    style={props.isSortable ? style : { cursor: "default" }}>
    ...
</div>

上記の設定でドラック操作対象のTodoのidやドロップした場所にあるTodoのidを取得する事ができます。

image.png

上記のidをHandler内で利用し入れ替わりを実装します。

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

    // 別のTodo上にドロップした場合(over.idとactive.idが異なる)
    if (active.id !== over.id) {
        const oldIndex = todoItemList.findIndex((v) => v.id === active.id);
        const newIndex = todoItemList.findIndex((v) => v.id === over.id);
        // arrayMoveを使用して配列内の順番を入れ替える
        setTodoList(arrayMove(todoItemList, oldIndex, newIndex));
    }
};

HandlerはDndContextに以下のようにpropsで渡します。
現在はonDragEnd propsとして設定しドロップしたタイミングで発火させます

<DndContext
    sensors={sensors}
    collisionDetection={closestCenter}
    onDragEnd={handleDragEnd}>
    ...
</DndContext>

ちょっと見にくいですがConsoleにactive.idとover.idを出力するとこのようになります。
Animation.gif

データが入れ替わった後、その情報が即座にDOMに反映されるのはuseStateを使用してデータの状態を管理し、ドロップ後のhandlerでsetStateを使用しているためです。

またアニメーションやスタイルはDnd kitが用意したstyleをTodoItem側に設定しています。

// ドラック&ドロップに伴うスタイルを設定
const style = {
    transform: CSS.Transform.toString(transform),
    transition,
};

// 先ほども記載したこの部分
<div
    ref={setNodeRef}
    {...attributes}
    {...listeners}
    // ソートが可能か否かisSortableというpropsに渡して対応したstyleを設定してます。
    style={props.isSortable ? style : { cursor: "default" }}>

まだカラム間のドラック&ドロップを実装していませんが今回はここで一旦区切ります。

まとめ

私にとってはライブラリが充実している事より、ライブラリの記事が充実している方が大切だと感じました。
みなさん分かりやすい記事をありがとう…

  • filterをかけて条件に一致したデータでComponentを表示できた
  • Dnd kitを導入できた
  • Dnd kitのDndContextとソート機能に関するSortableContextでカラム内ソートを実装できた

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

58
61
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
58
61

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?