見出し画像

HTMLのテーブルのセルを動的結合するvueコンポーネントを作る

htmlのテーブルタグのセルは縦と横に結合することができます。静的ページであれば、マニュアル通りに愚直に書けば良いですがデータが動的な場合はロジックを少々工夫する必要があります。仕事でたまたまこれをやる必要がありましたが、要件に合うvueコンポーネントが無かったので自作することにしました。

どうして結合して表示したいの?

A:B = 1:多で論理的には正規化されていますが、閲覧時には結合して俯瞰したい場合があります。その際にテーブルで結合後のデータをそのまま表示すると、ごちゃごちゃしてて見辛いので、これをグルーピングしたいという事です。

入力

        items: [
         {A: "A1", B: "B1", C: "C1"},
         {A: "A1", B: "B2", C: "C2"},
         {A: "A2", B: "B3", C: "C1"},
         {A: "A2", B: "B3", C: "C1"},
         {A: "A2", B: "B3", C: "C3"},
       ]

無結合

スクリーンショット 2020-10-08 15.37.13

結合後

スクリーンショット 2020-10-08 15.33.00

会社 : 部署 : 従業員(会社:部署=1:多, 部署:従業員:1:多)というモデルがあった場合に、以下のように表示すると見やすい(かもしれない)

スクリーンショット 2020-10-08 16.05.08

要件から外した仕様[横方向の結合]

今回の要件では必要ないので外しました。

スクリーンショット 2020-10-08 15.40.14

要件から外した仕様2 [多:多(many to many)]

B(リレーションテーブル)を基準にAとCを引っ張ってくるパターン. これも見せ方としては良さそうだけど、必要なかったので外しました。これはそもそもテーブルで頑張る意味があるのかよく分かりません。

スクリーンショット 2020-10-08 16.08.09

HTMLテーブル結合

まずはHTMLの静的ページでセルを縦結合する場合はどのように記述するかを確認していきます。サンプルとして次のように3x3のサイズで結合無しのテーブルを作成します。縦結合するための属性値はrowspanです。(W3CにあるようにHTMLのrowspanデフォルト値は1なので、まずは1として書きます)

    <table>
     <tbody>
     <tr>
       <td rowspan="1"><div>A1</div></td>
       <td rowspan="1"><div>B1</div></td>
       <td rowspan="1"><div>C1</div></td>
     </tr>
     <tr>
       <td rowspan="1"><div>A1</div></td>
       <td rowspan="1"><div>B1</div></td>
       <td rowspan="1"><div>C2</div></td>
     </tr>
     <tr>
       <td rowspan="1"><div>A1</div></td>
       <td rowspan="1"><div>B3</div></td>
       <td rowspan="1"><div>C3</div></td>
     </tr>
     </tbody>
   </table>

このように記述すると以下のように表示されます。

スクリーンショット 2020-10-08 17.14.58

このテーブルの1番左カラムの値が全てA1で同じなので、これを縦に3つ結合させるように書き換えていきます。手順は以下の2段階です。

1) 1行目のtdタグのrowspanを1から3に書き換え
2) 2行目,3行目のtdタグ(値がA1のもの)を削除する
    <table>
     <tbody>
     <tr>
       <td rowspan="3"><div>A1</div></td> <!-- 1から3に書き換え -->
       <td rowspan="1"><div>B1</div></td>
       <td rowspan="1"><div>C1</div></td>
     </tr>
     <tr>
       <!-- <td rowspan="1"><div>A1</div></td> コメントアウト -->
       <td rowspan="1"><div>B2</div></td>
       <td rowspan="1"><div>C2</div></td>
     </tr>
     <tr>
       <!-- <td rowspan="1"><div>A1</div></td> コメントアウト -->
       <td rowspan="1"><div>B3</div></td>
       <td rowspan="1"><div>C3</div></td>
     </tr>
     </tbody>
   </table>

以上のように書き換えると、次のようにセルが結合されます。rowspan=0では期待通りに動作しませんので、必ずタグ自体を削除する必要があります。

スクリーンショット 2020-10-08 17.24.17

結合したい場合は、基準にするtdタグのrowspanを結合する数に変更し、ターゲット(結合される側)のタグを消せば良い分けです。この状態からB2を基準にして、B3を消した場合は以下のような挙動になります。

    <table>
     <tbody>
     <tr>
       <td rowspan="3"><div>A1</div></td>
       <td rowspan="1"><div>B1</div></td>
       <td rowspan="1"><div>C1</div></td>
     </tr>
     <tr>
       <td rowspan="2"><div>B2</div></td>
       <td rowspan="1"><div>C2</div></td>
     </tr>
     <tr>
       <td rowspan="1"><div>C3</div></td>
     </tr>
     </tbody>
   </table>

スクリーンショット 2020-10-08 17.32.10

最後にC2を基準にしてC3を結合してみます。この時、同じルールでtdタグを消していくとtrタグ配下にはtdタグが1つも無くなってしまいます。一見すると無駄に思えるので、これも合わせて削除したくなってしまいますが、残す必要があります。

    <table>
     <tbody>
     <tr>
       <td rowspan="3"><div>A1</div></td>
       <td rowspan="1"><div>B1</div></td>
       <td rowspan="1"><div>C1</div></td>
     </tr>
     <tr>
       <td rowspan="2"><div>B2</div></td>
       <td rowspan="2"><div>C2</div></td> <!-- 1から2に変更 -->
     </tr>
     <tr>
       <!-- <td rowspan="1"><div>C3</div></td> --><!--削除 -->
     </tr> <!--このtrタグは要素のtdが無くても消してはいけない -->
     </tbody>
   </table>

スクリーンショット 2020-10-08 17.39.57


動的な生成方法を考えてみる

rowspanで結合をコントロールできることが分かりました。これを動的に生成するためには、どうしたら良いかについて考えていきます。

    <table>
     <tbody>
     <tr>
       <td rowspan="3"><div>A1(3)</div></td>
       <td rowspan="1"><div>B1(1)</div></td>
       <td rowspan="1"><div>C1(1)</div></td>
     </tr>
     <tr>
       <td rowspan="2"><div>B2(2)</div></td>
       <td rowspan="1"><div>C1(1)</div></td>
     </tr>
     <tr>
       <td rowspan="1"><div>C2(1)</div></td>
     </tr>
     <tr>
       <td rowspan="2"><div>A2(2)</div></td>
       <td rowspan="2"><div>B1(2)</div></td>
       <td rowspan="1"><div>C1(1)</div></td>
     </tr>
     <tr>
       <td rowspan="1"><div>C2(1)</div></td>
     </tr>
     </tbody>
   </table>

このような結合テーブルがあった時には、以下のような表示になります。(カッコ内はrowspanの指定) 

スクリーンショット 2020-10-08 18.15.46

このままでは、動的生成のためのアイデアが浮かんで来ないので、削除されたセルを0として結合前の状態に戻してみます。

    <table>
     <tbody>
     <tr>
       <td><div>A1(3)</div></td>
       <td><div>B1(1)</div></td>
       <td><div>C1(1)</div></td>
     </tr>
     <tr>
       <td><div>A1(0)</div></td>
       <td><div>B2(2)</div></td>
       <td><div>C1(1)</div></td>
     </tr>
     <tr>
       <td><div>A1(0)</div></td>
       <td><div>B2(0)</div></td>
       <td><div>C2(1)</div></td>
     </tr>
     <tr>
       <td><div>A2(2)</div></td>
       <td><div>B1(2)</div></td>
       <td><div>C1(1)</div></td>
     </tr>
     <tr>
       <td><div>A2(0)</div></td>
       <td><div>B1(0)</div></td>
       <td><div>C2(1)</div></td>
     </tr>
     </tbody>
   </table>

これを表示すると、以下のようになります。 入力したデータに以下のような情報を付与できさえすれば、「数字をrowspanに設定する」「0の場合は表示しない」という条件でテーブルを組み立てれば動的に表示することができそうです。

スクリーンショット 2020-10-08 18.20.03

要するに以下のような配列(3 x 5)を作り上げられれば、良いということになります。

[3,1,1]
[0,2,1]
[0,0,1]
[2,2,1]
[0,0,1]


木構造として捉える

どのようにして配列を作れば良いかについて考えてみます。上記の例を木構造に置き換えて図にしてみました。値がそれぞれ、青いノードで対応するrowspanが黒で書かれています。

スクリーンショット 2020-10-08 18.57.00

この木構造をrootノードから順に辿って行き、最深部(リーフ)に到達したら辿ってきたノードに書かれているrowspanを記録しておきます。そして、辿ったノードのrowspanの値を0に書き換えます。

スクリーンショット 2020-10-08 19.00.09

この手順を順に全てのリーフに到達するまで行います。

スクリーンショット 2020-10-08 19.02.09


A1 -> B2 -> C2

スクリーンショット 2020-10-08 19.03.17

A2 -> B1 -> C1

スクリーンショット 2020-10-08 19.04.13

A2 -> B1 -> C2
ここまで来れば全ての配列が出揃います。上記のテーブルのレンダリングに要求される配列(3x5)と同じ物が出来上がったことが確認できます。

スクリーンショット 2020-10-08 19.04.41


元データから木構造を生成する

木構造から、レンダリングに必要な配列構造に直すことができました。次に入力データから木構造にする方法はどのようにすれば良いでしょうか。

元データ(入力したいデータ)

items: [
{A: "A1", B: "B1", C: "C1"},
{A: "A1", B: "B2", C: "C1"},
{A: "A1", B: "B2", C: "C2"},
{A: "A2", B: "B1", C: "C1"},
{A: "A2", B: "B1", C: "C2"},
],

まず1番左のAカラムに関して、グルーピングを行います。グルーピングされたノードに配下のノード数を記録して行きます。

スクリーンショット 2020-10-08 19.18.58

Bカラムのグルーピング。A1->B1は、マージが発生してないので1となります。

スクリーンショット 2020-10-08 19.24.29

Cカラムのグルーピング

スクリーンショット 2020-10-08 19.24.35

元のデータから木構造の生成を行うことができました。 rowspanの値は要するに配下のリーフ数ということです。

ここまでの手順で、元データから、木構造(rowspan) 、木構造から配列に返還、レンダリングまでの流れができました。次に実装を見ていきます。オブジェクトの配列を指定のキーでグルーピングする処理は以下のようになります。reduceでオブジェクトを舐めて、[...accumulator[item[key]] || [], item]で, 同じキーがあれば新たに配列の要素として加えるという処理を行っています。

      groupByKey(items, key) {
       return items.reduce((accumulator, item) => {
         accumulator[item[key]] = [...accumulator[item[key]] || [], item];
         return accumulator;
       }, {});
      
     },

上記入力itemsをキー(A)で並び変えた場合の出力は以下の通りです。

{
  "A1":[
     {
        "A":"A1",
        "B":"B1",
        "C":"C1"
     },
     {
        "A":"A1",
        "B":"B1",
        "C":"C2"
     },
     {
        "A":"A1",
        "B":"B2",
        "C":"C1"
     }
  ],
  "A2":[
     {
        "A":"A2",
        "B":"B1",
        "C":"C1"
     },
     {
        "A":"A2",
        "B":"B1",
        "C":"C1"
     },
     {
        "A":"A2",
        "B":"B2",
        "C":"C1"
     }
  ]
}

次にこのgroupByKey関数を使って、リーフに到達するまで(keys.length==0)再帰処理を使って繰り返す処理が以下の通りです。この関数で、元データからツリーが出来上がります。

      makeIndexTree(items, keys) {
       if (keys.length == 0) {
         return {
           count: items.length,
           items: items
         }
       }
       let result = this.groupByKey(items, keys[0])
       keys.shift()
       Object.keys(result).forEach((key) => {
         let copiedKeys = []
         Object.assign(copiedKeys, keys)
         result[key] = this.makeIndexTree(result[key], copiedKeys)
         result.count = items.length
       })
       items = result
       return items
     },

ツリーのノードを辿って、配列を作成する関数は以下のようになります。routedNodeに経路の情報(rowspanのカウント, カラム名(A,B,C), ラベル(ノードの値))を保存して、リーフに到達した時に最終的な結果としてaccumulatorに保存します。 

      makeItemsWithRowspanCount(accumulator, routedNode, tree, keys, height) {
       let depth = keys.length
       Object.keys(tree).filter(key => key != "count").forEach((key) => {
         routedNode[height] = {key: key, count: tree[key].count, label: keys[height - 1]}
         // 最深部に到達したら通った経路の情報をアキュムレータに保存する
         if (height == depth) {
           let arr = {}
           let count = routedNode[depth].count
           for (let i = 1; i <= depth; i++) {
             arr[routedNode[i].label] = {key: routedNode[i].key, count: routedNode[i].count}
             routedNode[i].count = 0 // 通過してきたノードのcountを0にする
           }
           accumulator.push(arr)
           // 最深部でcountが2以上の場合は、結合が発生している場合はパディングを入れる
           if (count > 1) {
             for (let z = 1; z < count; z++) {
               let arr = {}
               for (let i = 1; i <= depth; i++) {
                 arr[routedNode[i].label] = {key: routedNode[i].key, count: 0}
               }
               accumulator.push(arr)
             }
           }
         } else {
           this.makeItemsWithRowspanCount(accumulator, routedNode, tree[key], keys, height + 1)
         }
       })
     }

最深部(リーフ)でのグルーピングでマージが発生する場合はカウントが2になりますが、最初の例で示した通りtdが1つも無くてもtrタグは必要なので、そのためのパディングを入れています。(力技っぽくてなんかスマートじゃ無い。。。リーフのカウントが必ず1になるようなデータ構造だと必要ないんだが)

vueのテンプレートは以下のようになります。 v-ifでrowspannのcountが0の場合はタグを削除(表示しない)しています。

    <table>
     <thead>
     <tr>
       <th v-for="header in headers">
         {{header.text}}
       </th>
     </tr>
     </thead>
     <tbody>
     <tr v-for="(item,index) in itemsWithRowspanCount">
       <td v-if="item[header.value].count > 0" v-for="header in headers" :rowspan="item[header.value].count">
         <div>{{item[header.value].key}}</div>
       </td>
     </tr>
     </tbody>
   </table>

computedメソッドは以下のようになります。

      itemsWithRowspanCount() {
       let indexTree = this.makeIndexTree(this.items, this.headers.map(f => f.value))
let accumulator = []
       this.makeItemsWithRowspanCount(accumulator, [], indexTree, this.headers.map(f => f.value), 1)
       return accumulator
     },

ソースは以下に上げておきました。需要があればご自由にどうぞ









この記事が気に入ったらサポートをしてみませんか?