Counts the Clouds

TanStack Tableで動的にヘッダーグループを生成する
2024.06.01

TanStack Table

maria-elena-zuniga-eXnFJDfb-FQ-unsplash.jpg

TanStackはreact-queryでおなじみのオープンソースライブラリスタック。TanStack Tableはヘッドレスのデータグリッドライブラリ。shadcn/uiのData TableはTanStack Tableをロジック部分に使用している。

TanStack TableはHTMLのcolspanのように複数の列にまたがったテーブルヘッダーを表示することができるが、これを動的に生成したい。

基本のテーブル

いちおう、ヘッダーグループがない基本の実装方法を見ておく。テーブルを表示する詳細は省略する。

// .----.----.----.----.
// | K1 | K2 | K3 | K4 | Header (key)
// |----|----|----|----|
// | V1 | V2 | V3 | V4 | Data (value)
// |----|----|----|----|
// | :  | :  | :  | :  |

const data = [ // 行
  {
    [K1]: { ...V1 }, // 列
    [K2]: { ...V2 },
    [K3]: { ...V3 },
    [K4]: { ...V4 },
  },
  // ...
];

const columns = [ // 列
  columnHelper.accessor(K1, { /* ここでヘッダーやセルの表示設定ができる */ }),
  columnHelper.accessor(K2, {
    header: () => <span>Header</span>,
    cell: (info) => info.getValue(),
  }),
  columnHelper.accessor(K3, { /**/ }),
  columnHelper.accessor(K4, { /**/ }),
];

const table = useReactTable({ data, columns });

動的に生成するには以下のようにすればよさそう。

// .----.----.----.----.---
// | K1 | K2 | K3 | K4 | .. Header
// |----|----|----|----|---
// | V1 | V2 | V3 | V4 | .. Data
// |----|----|----|----|---
// | :  | :  | :  | :  |

const data = [ // 行
  {
    [K1]: { ...V1 }, // 列
    [K2]: { ...V2 },
    [K3]: { ...V3 },
    [K4]: { ...V4 },
    // ... ここを動的にしたい
  },
  // ...
];

// 1行目のデータからカラムを生成
const columns = Object.entries(data[0]).map(([key, value]) => {
  return columnHelper.accessor(key, { /**/ });
});

const table = useReactTable({ data, columns });

固定のヘッダーグループ

サンプルにあるような固定の結合ヘッダーはcolumnsの構造をネストするだけで表示できる。columnsにヘッダーグループと列をcolumnHelperの階層構造でわたし、dataに行列データをわたす。

// .---------.---------.
// |   G1    |   G2    | Header Group
// |----.----|----.----|
// | K1 | K2 | K3 | K4 | Header
// |----|----|----|----|
// | V1 | V2 | V3 | V4 | Data
// |----|----|----|----|
// | :  | :  | :  | :  |

const columns = [ // ヘッダーグループ
  columnHelper.group({
    id: G1,
    name: "Group 1",
    columns: [ // 列
      columnHelper.accessor(K1, { /**/ }),
      columnHelper.accessor(K2, { /**/ }),
    ],
  }),
  columnHelper.group({
    id: G2,
    name: "Group 2",
    columns: [
      columnHelper.accessor(K3, { /**/ }),
      columnHelper.accessor(K4, { /**/ }),
    ],
  }),
];

const data = [ // 行
  {
    [K1]: { ...V1 }, // 列
    [K2]: { ...V2 },
    [K3]: { ...V3 },
    [K4]: { ...V4 },
  },
  // ...
];

const table = useReactTable({ data, columns });

動的なヘッダーとヘッダーグループ

これまでに得た情報から、動的にヘッダーグループを生成するには以下の2つの構造を元データを変形して生成する必要がありそうだ。

columnsdataとで異なる構造を取るので、まだ直感的にこう書けばいいという確信が得られていない。

まずは素直にカラム生成用の3階層のデータを構成することにして、データはヘッダーグループ階層を取り除いて取得することにする。

このデータは、行→ヘッダーグループ→列の3つの階層で構成されており、ヘッダーグループを取り除くと行→列の階層だけが得られる。

// .---------.---------.---
// |   G1    |   G2    | .. Header Group
// |----.----|----.----|---
// | K1 | K2 | K3 | K4 | .. Header
// |----|----|----|----|---
// | V1 | V2 | V3 | V4 | .. Data
// '----'----'----'----'---
// | :  | :  | :  | :  |

const original = [ // 行
  [ // ヘッダーグループ
    {
      id: G1,
      name: "Group 1",
      children: [ // 列
        { id: K1, ...V1 },
        { id: K2, ...V2 },
        // ...
      ],
    },
    {
      id: G2,
      name: "Group 2",
      children: [ // 列
        { id: K3, ...V3 },
        { id: K4, ...V4 },
        // ...
      ],
    }
    // ...
  ],
  // ...
];

// 1行目をカラムの生成に利用する
const columns = original[0].map((headerGroup) => { // ヘッダーグループ
  return columnHelper.group({
    id: headerGroup.id,
    header: () => headerGroup.name,
    columns: headerGroup.children.map((header) => { // 列
      return columnHelper.accessor(header.id, { /**/ });
    }),
  });
});

// 行→列の階層だけ取り出す。列はKey-Valueになるように変形する
// ```
// const data = [ // 行
//   { // 列
//     K1: { id: K1, ...V1 }
//     K2: { id: K2, ...V2 }
//   }
//   // ...
// ]
// ```
const data = original.map((row) => { // 行
  // 列をオブジェクト形式にする
  return Object.fromEntries(
    // flatMapを使用してヘッダーグループを捨てる
    row
      .flatMap((headerGroup) => headerGroup.children)
      // オブジェクトにするためタプルで返す
      .map((header) => [header.id, header]),
  );
});

const table = useReactTable({ data, columns });

これで動的なヘッダーグループを持つテーブルを表示することができる。元のデータ構造にもよるが、あまり効率的ではないように思えるので、もっといい方法がないか検討してみる。

たとえば逆に、行列データからカラム階層を生成することも考えてみる。各列に所属するヘッダーグループを指定する列を追加し、ヘッダーグループの階層はgroupByのような処理で生成する。コードは直感的にわかりにくいが、構造はシンプルになった。

なんとなく、こっちのほうが本筋のように思える。

// 元データはそのまま`data`にわたせる構造
const data = [ // 行
  { // 列
    [K1]: {
      id: K1,
      groupId: G1, // ヘッダーグループ
      groupName: 'Group 1',
      ...V1, // その他のデータ
    },
    [K2]: {
      id: K2,
      groupId: G1,
      groupName: 'Group 1',
      ...V2, // その他のデータ
    },
    // ...
  },
  // ...
]

// ヘッダーグループとヘッダーの構造を生成する
// groupIdでカラムをグループ化し、Object.entriesやObject.valuesで配列に戻す
const columns = Object.entries(
  Object.groupBy(Object.values(data[0]), ({ groupId } => groupId))
).map(([groupId, headers]) => { // ヘッダーグループ
  return columnHelper.group({
    id: groupId,
    header: () => headers[0].groupName, // 各列がグループの情報も持っている
    columns: headers.map((header) => { // 列
      return columnHelper.accessor(header.id, {/**/})
    })
  })
})

const table = useReactTable({ data, columns })

※Object.groupByを使用するには最新のブラウザや、v22以上のNode.js環境が必要。環境に応じてLodashのgroupByなどのpolyfillを使用する。