次世代型採用管理システム「Wantedly Hire」に、新機能「レポートビルダー」が登場 | Wantedly, Inc.
この度、当社が提供する次世代型採用管理システム「Wantedly Hire(ウォンテッドリー ハイアー)」において、新機能「レポートビルダー」の提供を開始します。高度で自由な分析が、採用を成功さ...
https://www.wantedly.com/companies/wantedly/post_articles/987965
こんにちは。ウォンテッドリーでフロントエンドチームのリーダーをしている原 剛士 (@chloe463) です。この記事では、私が個人で開発したダッシュボードの開発時に使えるライブラリ panelgrid を紹介します。いわゆる「ダッシュボード」と呼ばれるような画面にある、複数のパネルをドラッグ・アンド・ドロップで自由で配置したり、パネルサイズを変えられたりする機能を実現するためのライブラリです。
私は現在 Wantedly Hire の開発チームのリーダーをしています。Wantedly Hire はいわゆる ATS (Application Tracking System/採用管理システム) と呼ばれるシステムで、採用活動のために使われるWebアプリケーションです。この Hire の大きな目玉機能がレポートビルダーです。レポートビルダーには大きく2つの機能があります。
Aのレポート作成機能開発においては、ピボットテーブル用にバックエンドで構築された複雑なデータ構造を解きほぐしてテーブルとして表示したり、そのデータをチャート用に整形するための困難がありました。しかしそこまで大きな不確実性はなく想定内の時間で対応することができました。一方 B のダッシュボード機能は PO がイメージしている挙動を実現できるかどうか不確実なことも多く悩みのタネでした。しかし、運よく Gridstack.js というライブラリに出会うことができたため、技術検証や実装もスムーズに進み、もともと想定していたリリースに間にあわせることができました。
前述の通り、Gridstack.js を使ってダッシュボード機能を開発することができました。しかしながら Gridstack.js には少しクセがあります。 Gridstack.js は特定のライブラリ・フレームワークを想定したライブラリではありません。React アプリケーションでなくても使用できるライブラリです。Gridstack.js は DOM を直接操作してパネルの再配置・リサイズを可能にします。そのため、仮想 DOM を使って UI を構築する React と組み合わせて使うときは少し工夫をしないといけません。特に追加や削除を行うときにその問題が現れます。
Gridstack.js には addWidget, removeWidget という関数が用意されています。これらはそれぞれ要素をグリッドに追加、削除する関数です。この関数を呼び出すと、 Gridstack.js は DOM ツリーを直接操作して要素を追加したり、削除したりします。このようにすると React はそれらの要素を認識できません。直接 DOM に追加された要素は仮想 DOM にはなく、逆に削除されると仮想 DOM には存在するが実 DOM には存在せずミスマッチになってしまいます。
この問題には回避策があります。常に React の状態を single source of truth として扱うようにすることです。React のアプリケーションなので、ダッシュボード上にあるパネルの要素(レポートビルダーの場合はどのレポートが載っているか)は React の state として管理されています。Gridstack.js があるからといってこの state 管理の方法を変える必要はなく、むしろ別の手段を取るとアプリケーション開発が難しくなりメンテナンス性が下がります。したがって、React の state を唯一の状態として扱い、Gridstack.js がその状態に追従するという形式を取ることにしました。具体的には先程説明した addWidget, removeWidget という関数を使わないようにして、state への要素追加、削除を行うようにします。Gridstack には makeWidget という関数があり、DOM の状態から Gridstack.js 内の状態を構築することができます。これを使うことで Gridstack.js のウィジェットとして認識されるようにします。
本番コードではありませんが、下記と同じようなコードを使って React state と Gridstack.js の状態を同期するようにしています。
// items(表示させるレポートの配列)が変わるたびに実行
useEffect(() => {
if (!gridStack) return;
gridStack.batchUpdate(false);
try {
// 一度 Gridstack の状態をクリアする
gridStack.removeAll(false);
items.forEach((item) => {
// Gridstack に状態を認識させる
gridStack.makeWidget(`#grid-stack-item-${item.id}`, {
w: item.w || 2,
h: item.h || 2,
x: item.x,
y: item.y,
});
});
} finally {
gridStack.commit();
}
}, [gridStack, items]);
ちなみに Gridstack.js も各ライブラリ向けにアダプターのようなものを用意しています。React 向けにも開発が進んでいるようですが、本ブログ執筆時点ではまだ GA という状況ではないようです。(リポジトリを見ると TODO が消化されていないように見えます。)
cf. https://github.com/gridstack/gridstack.js/tree/master/react
レポートビルダーのダッシュボードは Gridstack.js を使って問題なく動いています。しかしながら React の自然な API を使って、 useEffect を使った状態同期をすることなく使えるようになると嬉しいなと思います。例えば次のようなコードで実現できるとよいなと思うはずです。(思いますよね?)
<AwesomeGridProvider
panels={[
{ id: 1, x: 0, y: 0, w: 1, h: 2 },
{ id: 2, x: 1, y: 1, w: 2, h: 2 },
]}
columnCount={6}
gap={8}
>
<AwesomeGridPanelRenderer itemRenderer={Panel} />
</AwesomeGridProvider>
ドラッグ・アンド・ドロップや要素のリサイズというのは、これまであまり取り組んだことがない機能だったので、個人的な興味もあり取り組んでみることにしました。 (※ 本番に入れることを目標にせず、あくまで趣味プロジェクトとしてです)
ドラッグ・アンド・ドロップとリサイズ機能を作るにあたり、最初は HTML と CSS の標準の機能を使おうと考えていました。HTML5 には標準でドラッグ・アンド・ドロップの機能が備わっています。 この機能を使えば、ドラッグ・アンド・ドロップは簡単に実装できるできるだろうと考えていました。そして事実、draggable オプションと dragstart, dragend というイベントを使うことで簡単に実装ができました。しかしながら以下の点が素直に実現できませんでした。
ドラッグしてから動かしている最中にマウスカーソルのスタイルを dragging にできない。
スクリーンショットで撮ることができませんでしたが、上の画像では中央に表示されている要素の中央あたりにマウスカーソルがあり、それは通常時と同じ形状 (cursor: default) をしています。この問題はいくつか記事を呼んだり AI と相談したりしましたが、解決できませんでした。動かしている最中の体験として掴んでいる感のあるマウスカーソルは必須であると考えていたので、これが解決できないのであれば別の方法を取ったほうがいいかと考えました。
リサイズについても同様に、CSS の resize を使って簡単に実現できるだろうと考えていました。しかしながらこちらにも問題がありました。
1については言わずもがな、使えるブラウザが限られる(Chromeのみ)というのはサービスプロバイダーとしては取れにくい選択肢です。少なくともモダンブラウザの最新版では使える (Newly Available) 程度でないと採用はできないなと考えています。2のリサイズハンドルですが、これはリサイズ可能な要素の右下にちょこんと表示されている要素のことです。 MDN にあるサンプルを (Chromeで) 見ると、要素の右下に小さく切れ込みのような見た目になっているのが分かるかと思います。レポートビルダーのダッシュボードではこのハンドルにもデザインの指定があったため、カスタマイズできない方法は選択できませんでした。
まとめると UI 面、機能面から、ブラウザ対応面から HTML, CSS の標準機能を使うことはできないという結論になりました。
前節で説明したとおり、標準のドラッグ・アンド・ドロップとリサイズ機能が使えないと分かったので、自前で実装するしかなくなりました。つまり JS を使って DOM を操作して、位置移動と大きさ変更を管理する処理を実装するしかないということです。とはいえ、いろいろな記事を呼んだり AI と相談していると、最初になんとなく思っていたほど難しくないことが分かりました。ドラッグ・アンド・ドロップもリサイズも概ね処理は次のようになります。
onMouseDown (マウスを押し込んだときのイベント) 時に次を実行onMouseMove onMouseUp のイベントハンドラーを登録onMouseMove のイベントハンドラーではマウスカーソルの動きに応じて対象要素の位置を動かしたり、サイズを変更したりする (このときに onMouseDown で保持しておいてカーソル位置を考慮して動かす)onMouseUp のイベントハンドラーではその時点のマウスカーソルの位置をもとに、対象要素の位置やサイズを確定させる。(またこのときに onMouseMove と onMouseUp のハンドラーを解除する)const onMouseDown = (e) => {
const mouseMoveListenerCtrl = new AbortContrller();
const mouseUpListenerCtrl = new AbortController();
document.addEventListener('mousemove', (e) => {
// 移動・リサイズ処理
}, { signal: mouseMoveController.signal });
document.addEventListener('mouseup', (e) => {
// 位置確定、サイズ確定
// state 更新
// イベントリスナー削除
mouseMoveListenerCtrl.abort();
mouseUpListenerCtrl.abort();
}, { signal: mouseUpController.signal });
}
前節で言及した各種スタイルの問題ですが、この手段を取ると問題になりません。動かしたい要素のリサイズハンドルに当たる要素に適切にスタイルを当てたり SVG 等の画像を表示すれば簡単にカスタマイズ可能です。またドラッグ・アンド・ドロップ時のマウスカーソルについても onMouseDown 時に cursor: grabbing になるように実装するとそれが適用され、表現したい UI/UX が実現できます。(コードは超大なのでここでは割愛します。読みたい方は https://github.com/chloe463/panelgrid/blob/main/src/usePanelGrid.ts を見てみてください。)
また、panelgrid ではマウスカーソルが動いている最中の状態管理を全て ref を使って行い、mouseup イベント時に state を確定させるという実装にすることで、無駄な再レンダリングを避けることを意識しました。
ここがこのライブラリの1番の肝でと言ってもいいかもしれません。ドラッグ・アンド・ドロップで要素を移動したときや、リサイズした結果他の要素と衝突が起こることがあります。このときに要素同士が被らないように再配置する必要があり、そのロジックを組む必要がありました。
この再配置処理には色々な考え方があるかと思いますが、今回は次のようなものを採用しました。
panelgrid ではこの再配置処理の実装を AI に書いてもらいました。上記は最終的なちゃんと書いた仕様ですが、これよりももっとベーシックで雑な仕様と、アルゴリズム実装のアイデアを書いて Claude Code に渡したところ、一発目のアウトプットからそれなりに良いものを出してきてくれました。(Claude Code サマサマです。)
実装はここにありますが、思ったよりも少ない行数で実現できています。
https://github.com/chloe463/panelgrid/blob/9b73976d3edde9fc78da050f835774b20b04ff65/src/helpers/rearrangement.ts#L146-L227
雑なアイデアを渡して生成 AI がどこまでできるかなーと試してみる気持ちでやってみたのですが、まったく苦戦することなくよい実装を出してきたので驚きました。1番実装が楽しそうなところを持って行かれた悔しさみたいな感情もありますが…。
ちなみに panelgrid ではこの再配置処理を外側から差し込むことができます。PanelGridProvider には rearrangement という prop を用意しています。これにカスタムの関数を渡すと自分なりの再配置アルゴリズムで動かすことができます。 README の Advanced Usage にいくつかカスタム再配置関数の例を載せています。
Wantedly Hire のレポートビルダーのダッシュボード機能実装をきっかけに、ダッシュボード用ライブラリ panelgrid を作ってみた話を紹介しました。普段は DOM 要素をいじって操作するような機能を書かないので、そういった実装をするのは勉強になりましたし、非常に面白い経験でした。またこういった実装は個人的に苦手意識を持っていたところだったので、詳しく触れることができて良かったです。また、アルゴリズム実装について、AI がやってしまって少しもったいない気持ちにもなりましたが、AI を活用することでやりたいことがすぐに実現できているのは素直に喜ぶべきことかもしれません。より本質的な課題や抽象的な課題について考える時間が増えたと考えることにします。このライブラリはまだ 0.1.0 というバージョンをつけています。まだ追加したい機能やドキュメントの整備、CI/CD の改善などのアイデアがあるのでまだまだ楽しめそうです。何か気付いたことなどあったらフィードバックお待ちしています。
注意: panelgrid はまだ趣味プロジェクトの域を出ません。Wantedly Hire でもまだ使用していないライブラリです。本番アプリケーションでの動作は保証できませんのでご了承ください。