ag-gridをwrapしてちょっと使いやすくする

# はじめに
グリッドを簡単に表示したいときに必ず候補として挙がるのがag-gridだと思います。
無償版でも高機能で導入の仕方も簡単なので何度もお世話になりました。

ですがag-gridを使う画面が複数あるとちょっと使いにくいなーという点が少しありました。
例えばコレ。（<a href="https://www.ag-grid.com/javascript-grid/getting-started/" about="_blank">公式サイト</a>から転載）

```js:
// setup the grid after the page has finished loading
document.addEventListener('DOMContentLoaded', () => {
    const gridDiv = document.querySelector('#myGrid');
    new agGrid.Grid(gridDiv, gridOptions);
});
```

グリッドを描画するには必ず書かなければいけない記述ですが
何度も書きたくないんですよね。
コンポーネントの登録もそうです。
使う画面ごとに同じコンポーネントの記述を書く必要があります。

```js:
const gridOptions = {
  columnDefs: columnDefs,
  components: {
    medalCellRenderer: MedalCellRenderer,
    totalValueRenderer: TotalValueRenderer,
  },
```

他にも

- テーマCSS
- 言語設定
- 列設定
- エラー時のCSSクラス

これらを共通設定としてwrapしたものを作ってちょっと使いやすくしようと思います。
ちなみにReactとかではなくプレーンなJSで作ります。

# ターゲット

- React等ではなくプレーンなJSでag-gridを利用している方
- TypeScriptを使用している方

# 前提条件

- node.jsのインストール
- webpack、gulp等のTypeScriptをコンパイルできる環境

参考までに筆者は以下のようなコンパイル環境となっています。

<details><summary>コンパイル環境</summary><div>

```json:package.json
{
  "name": "aggridwrap",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "npx webpack --mode development",
    "watch": "npx webpack -w --mode development"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "jquery": "^3.6.0"
  },
  "devDependencies": {
    "@babel/cli": "^7.8.4",
    "@babel/core": "^7.12.10",
    "@babel/plugin-proposal-class-properties": "^7.8.3",
    "@babel/plugin-transform-runtime": "^7.9.6",
    "@babel/polyfill": "^7.8.7",
    "@babel/preset-env": "^7.12.11",
    "@babel/preset-typescript": "^7.9.0",
    "@types/jquery": "^3.5.1",
    "ag-grid-community": "^25.1.0",
    "ag-grid-enterprise": "^25.1.0",
    "axios": "^0.21.1",
    "babel-cli": "^7.0.0-beta.3",
    "babel-loader": "^8.1.0",
    "browser-sync": "^2.26.12",
    "css-loader": "^5.2.6",
    "gulp": "^4.0.2",
    "gulp-autoprefixer": "^7.0.1",
    "gulp-cssmin": "^0.2.0",
    "gulp-data": "^1.3.1",
    "gulp-notify": "^3.2.0",
    "gulp-plumber": "^1.2.1",
    "gulp-pug": "^4.0.1",
    "gulp-rename": "^2.0.0",
    "gulp-sass": "^4.1.0",
    "html-webpack-plugin": "^4.5.1",
    "node-sass": "^5.0.0",
    "optimize-css-assets-webpack-plugin": "^5.0.4",
    "regenerator-runtime": "^0.13.5",
    "run-sequence": "^2.2.1",
    "sass-loader": "^10.1.1",
    "source-map-support": "^0.5.19",
    "style-loader": "^2.0.0",
    "terser-webpack-plugin": "^5.1.1",
    "typescript": "^3.8.3",
    "webpack": "^5.19.0",
    "webpack-cli": "^4.4.0",
    "webpack-dev-server": "^3.11.2",
    "webpack-manifest-plugin": "^3.1.0",
    "webpack-stream": "^6.1.0"
  }
}
```

```js:webpack.config.js
// 開発or本番モードの選択(development、production、noneのいずれか設定必須)
// development: 開発時のファイル出力のモード(最適化より時間短縮,エラー表示などを優先)
// production: 本番時のファイル出力のモード(最適化されて出力される)
const MODE = "development";
// ソースマップの利用有無(productionのときはソースマップを利用しない)
const enabledSourceMap = MODE === "development";

// ファイル出力時の絶対パス指定に使用
const path = require('path'),
    glob = require('glob'),
    _ = require('lodash');

// プラグイン
// js最適化
const TerserPlugin = require('terser-webpack-plugin');

const config = {
  "baseDir": "src",
  "distDir": "static",
  "jsDir": "js"
};

const jsBasePath = path.resolve(__dirname, config.baseDir + "/" + config.jsDir),
    jsDistPath = path.resolve(__dirname, config.distDir + "/" + config.jsDir)

String.prototype.filename = function () {
    return this.match(".+/(.+?)([\?#;].*)?$")[1];
}

var targets = _.filter(glob.sync(`${jsBasePath}/**/*.ts`), (item) => {
    return !item.filename().match(/^_/) && !item.match(/node_modules/) && !item.match(/lib/)
});

// entryに入れるhash
var entries = {};

// pathも含めたfilenameからpathとfilenameでhashを作る
targets.forEach(value => {
    var path = jsBasePath.replace(/\\/g, '\/')
    var re = new RegExp(`${path}/`);
    var key = value.replace(re, '');
    key = key.replace(".ts", '');
    key = key.replace(".js", '');

    // 確認用に取得したファイル名を出す
    //console.log('--------------------------')
    //console.log(path)
    //console.log(key)
    //console.log(value.filename())
    entries[key] = value;
});


module.exports = {
  // エントリーポイント(メインのjsファイル)
  entry: entries,
  // ファイルの出力設定
  output: {
    path: jsDistPath,
    filename: '[name].js'
  },
  mode: MODE,
  // ソースマップ有効
  devtool: 'source-map',
  // ローダーの設定
  module: {
    rules: [
      { 
        test: /\.css$/, 
        use: ["style-loader", "css-loader"]
      },
      {
        // 拡張子 .js の場合
        test: /\.ts$/,
        use: [
            {
                // Babel を利用する
                loader: 'babel-loader',
                // Babel のオプションを指定する
                options: {
                    presets: [
                        ["@babel/preset-env", {
                            targets: {
                                "browsers": ["last 2 versions", "ie >= 11"]
                            },
                            useBuiltIns: 'entry',
                            corejs: 3,
                            modules: false
                        }
                        ],
                        [
                            "@babel/preset-typescript", {
                                allowNamespaces: true
                            }
                        ]
                    ],
                    "plugins": [
                        "@babel/plugin-proposal-class-properties",
                        "@babel/plugin-transform-runtime"
                    ]
                }

            }
        ]
      },
      {
        // ローダーの対象 // 拡張子 .js の場合
        test: /\.js$/,
        // ローダーの処理対象から外すディレクトリ
        exclude: /node_modules/,
        // Babel を利用する
        loader: "babel-loader",
        // Babel のオプションを指定する
        options: {
          presets: [
            // プリセットを指定することで、ES2019 を ES5 に変換
            "@babel/preset-env"
          ]
        }
      }
    ]
  },
  // import 文で .ts ファイルを解決するため
  resolve: {
    // Webpackで利用するときの設定
    alias: {
    },
    extensions: ["*", ".ts", ".js", ".json"]
  },
  // mode:puroductionでビルドした場合のファイル圧縮
  optimization: {
    minimizer: MODE
      ? []
      : [
        // jsファイルの最適化
        new TerserPlugin({
          // すべてのコメント削除
          extractComments: 'all',
          // console.logの出力除去
          terserOptions: {
            compress: { drop_console: true }
          },
        }),
      ]
  },
  // js, css, html更新時自動的にブラウザをリロード
  devServer: {
    // サーバーの起点ディレクトリ
    // contentBase: "dist",
    // バンドルされるファイルの監視 // パスがサーバー起点と異なる場合に設定
    publicPath: jsDistPath,
    //コンテンツの変更監視をする
    watchContentBase: true,
    // 実行時(サーバー起動時)ブラウザ自動起動
    open: true,
    // 自動で指定したページを開く
    openPage: "index.html",
    // 同一network内からのアクセス可能に
    host: "0.0.0.0"
  }
};
```

```js:gulpfile.js
const gulp = require( 'gulp' );
const notify = require('gulp-notify');
const plumber = require('gulp-plumber');
const browsersync = require('browser-sync');
const webpackStream = require("webpack-stream");
const webpack = require("webpack");
// webpackの設定ファイルの読み込み
const webpackConfig = require("./webpack.config");
const config = {
  "baseDir": "src",
  "distDir": "static",
  "jsDir": "js"
};

const paths = {
    src: './'+ config.baseDir + '/',
    dest: './'+ config.distDir + '/',
  };

gulp.task("js", () => {
  // ☆ webpackStreamの第2引数にwebpackを渡す☆
  return webpackStream(webpackConfig, webpack)
    .pipe(plumber({
        errorHandler: notify.onError("Error: <%= error.message %>")
    }))
    .pipe(gulp.dest(paths.dest+config.jsDir+"/"));
});

gulp.task('browser-sync', function (done) {
    browsersync({
      server: { //ローカルサーバー起動
          baseDir: paths.dest
    }});
    done();
  });

gulp.task('watch', function () {
    const reload = () => {
      browsersync.reload(); //リロード
    };
    gulp.watch(paths.src + '**/*.js').on('change', gulp.series('js', reload));
    gulp.watch(paths.src + '**/*.ts').on('change', gulp.series('js', reload));
});
```

gulpのタスク"js"でコンパイルを実行します。

```
npx gulp js
```

</div></details>

# 構成

```:構成図
├── src                    //ソース置き場
    ├── js
        ├── ag-grid.ts     //ag-gridのwrap
        ├── components.js  //ag-gridコンポーネント
        ├── index.ts       //画面のts
├── static                 //コンパイル後ファイル
    ├── js
        ├── ag-grid.js
        ├── index.js
├── gulpfile.js
├── index.html
├── package.json
├── package-lock.json
├── webpack.config.js
```

コンパイルにはwebpackとgulpを使用しましたが、
正直コンパイルできればなんでも良いです。（必須なのはstyle-loaderくらい）
srcフォルダのファイルをstaticフォルダに出力するシンプルな構成です。

# ag-grid側の定義

```ts:ag-grid.ts
import $ from 'jquery';
import { Grid, GridOptions } from 'ag-grid-community';
import { ButtonRenderer } from './components';

import "ag-grid-community/dist/styles/ag-grid.min.css";
import "ag-grid-community/dist/styles/ag-theme-balham.min.css";

/**
 * ag-gridクラス
 *
 */
export class AgGrid {
    private readonly gridId: string;
    private columnDefs: Array<any>;
    private rowData: Array<any>;
    private gridOptions: GridOptions;
    private extraGridOptions: object | undefined;
    private eGridDiv: HTMLElement | null;
    private grid: Grid | undefined;

    /**
     * コンストラクタ
     *
     * @param pGridId グリッドID
     * @param pResources リソース
     */
    constructor(pGridId: string | undefined) {
        this.gridId = pGridId || 'myGrid';
        pGridId = this.gridId;
        this.gridId = '#' + this.gridId;

        this.columnDefs = [];
        this.rowData = [];
        this.gridOptions = {};
        this.eGridDiv = null;
    }

    /**
     * 初期化を実行する
     *
     * @returns 実行結果
     */
    init(): boolean {
        this.setDefaultGridOptions();
        // グリッドを作成する要素を格納する
        this.eGridDiv = document.querySelector(this.gridId);
        // グリッドを作成する
        this.grid = new Grid(this.eGridDiv, this.gridOptions);
        return true;
    }

    /**
     * 列定義をセットする
     *
     * @param pColumnDefs 列定義
     */
    setColumnDefs(pColumnDefs: Array<any>): void {
        this.columnDefs = pColumnDefs;
    }

    /**
     * 行データをセットする
     *
     * @param pRowData 行データ
     */
    setRowData(pRowData: Array<any>): void {
        this.rowData = pRowData;
        if (this.gridOptions.api) {
            this.gridOptions.api.setRowData(this.rowData);
        }
    }

    /**
     * 拡張グリッドオプションをセットする
     *
     * @param pGridOptions 拡張グリッドオプション
     */
    setGridOptions(pGridOptions: object): void {
        this.extraGridOptions = pGridOptions;
        if (typeof pGridOptions["columnDefs"] !== "undefined") {
            this.setColumnDefs(pGridOptions["columnDefs"]);
        }
        if (typeof pGridOptions["rowData"] !== "undefined") {
            this.setRowData(pGridOptions["rowData"]);
        }
    }

    /**
     * 初期グリッドオプションをセットする
     *
     */
    private setDefaultGridOptions(): void {
        // グリッドに適用する列定義、行データを指定
        let defaultGridOptions: GridOptions = {
            columnDefs: this.columnDefs,
            rowData: this.rowData,
            singleClickEdit: true,
            groupSelectsChildren: false,
            suppressRowClickSelection: true,
            rowSelection: "multiple",
            //列幅リサイズ
            defaultColDef: {
                resizable: true,
                sortable: true,
                filter: true,
            },
            //列移動不可
            suppressMovableColumns: true,
            components: {
                ButtonRenderer: ButtonRenderer
            },
            rowClassRules: {
                'field-validation-error': function (params) { return params.node.hasError; },
                'alert-danger': function (params) { return params.node.hasError; },
            },
            localeText: {
                contains: "を含む",
                endsWith: "で終わる",
                equals: "と等しい",
                filterOoo: "フィルタ...",
                greaterThan: "より大きい",
                greaterThanOrEqual: "以上",
                inRange: "範囲",
                lessThan: "より少ない",
                lessThanOrEqual: "以下",
                noRowsToShow: " ",
                notContains: "を含まない",
                notEqual: "と等しくない",
                startsWith: "で始まる"
            }
        };

        $.extend(defaultGridOptions, this.extraGridOptions);
        this.gridOptions = defaultGridOptions;
    }

    /**
     * check列定義を取得する
     *
     * @returns check列定義
     */
    getCheckColumnDef(): object {
        return {
            headerName: "選択",
            field: "Check",
            filter: false,
            checkboxSelection: true,
            pinned: "left",
            width: 40,
            headerCheckboxSelection: true,
        };
    }

    /**
     * edit列定義を取得する
     *
     * @returns check列定義
     */
    getEditColumnDef(func: Function | undefined): object {
        return {
            headerName: "編集",
            field: "Edit",
            width: 85,
            pinned: "left",
            //ボタンコンポーネントを設定する
            cellRenderer: ButtonRenderer,
            cellRendererParams: { buttonValue: "編集" },
            onButtonClicked: (params) => {
                if (typeof func === "function") {
                    func(params);
                }
            }
        };
    }

    /**
     * 選択行を取得する
     *
     * @returns 選択行
     */
    getCheckedRows(): Array<any> {
        let rows: Array<any> = [];
        this.gridOptions.api.forEachNode(function (node) {
            if (node.isSelected()) {
                rows.push(node.data);
            }
        });
        return rows;
    }
}
```

setDefaultGridOptionsに共通設定の定義があります。
もし別の定義を使いたい場合はsetGridOptionsで定義を渡せばOKです。
よく使用すると思われるチェック列と編集列を関数にしていますので
画面側でこの関数を使用すれば同じ定義の列が使えます。
これを画面側のTypeScriptで読み込みます。

コンポーネントは別jsファイルにまとめています。
コンポーネントについては本筋ではないので参考程度に見てください。

<details><summary>コンポーネント定義</summary><div>

```js:components.js
import $ from 'jquery';

/// <summary>
/// ボタンコンポーネント
/// </summary>
export function ButtonRenderer() {
    this.eGui = document.createElement('button');
}
ButtonRenderer.prototype.init = function (params) {
    var self = this;
    var i = document.createElement('i');
    i.className = "icon edit";
    self.eGui.className = "ui teal basic button";
    self.eGui.name = params.colDef.field;

    $(self.eGui).append(params.colDef.cellRendererParams["buttonValue"]);

    eGuiClassSetting(self.eGui, params);
    disabledSetting(self.eGui, params);
    hideSetting(self.eGui, params);

    $(self.eGui).on('click', function () {
        if (typeof params.colDef.onButtonClicked === "function") {
            params.colDef.onButtonClicked(params);
        }
        return false;
    });
};
ButtonRenderer.prototype.getGui = function getGui() {
    return this.eGui;
};
ButtonRenderer.prototype.destroy = function () {

};

/// <summary>
/// 活性非活性設定
/// </summary>
function disabledSetting(eGui, params) {
    //disabledが設定されていた場合
    if (typeof params.node["disabled"] === "undefined") {
        params.node["disabled"] = {};
    }
    //ファンクションが設定されていた場合はファンクションを優先する
    if (typeof params.colDef.disabledFunc === "function") {
        var disabled = params.colDef.disabledFunc(params);
        params.node["disabled"][params.colDef.field] = disabled;
    }
    if (typeof params.node["disabled"] !== "undefined") {
        eGui.disabled = params.node["disabled"][params.colDef.field];
    }
}

/// <summary>
/// 表示非表示設定
/// </summary>
function hideSetting(eGui, params) {
    //hideが設定されていた場合
    if (typeof params.node["hide"] === "undefined") {
        params.node["hide"] = {};
    }
    //ファンクションが設定されていた場合はファンクションを優先する
    if (typeof params.colDef.hideFunc === "function") {
        var hide = params.colDef.hideFunc(params);
        params.node["hide"][params.colDef.field] = hide;
    }
    if (typeof params.node["hide"] !== "undefined") {
        if (params.node["hide"][params.colDef.field]) {
            $(eGui).addClass("hide");
        } else {
            $(eGui).removeClass("hide");
        }
    }
}

/// <summary>
/// クラス設定
/// </summary>
function eGuiClassSetting(eGui, params) {
    //eGuiClassが設定されていた場合
    if (typeof params.colDef["eGuiClass"] === "function") {
        $(eGui).addClass(params.colDef["eGuiClass"](params));
    } else if (typeof params.colDef["eGuiClass"] === "string") {
        $(eGui).addClass(params.colDef["eGuiClass"]);
    }
}
```

</div></details>

# 画面側の定義

```ts:index.ts
import { AgGrid } from './ag-grid';

const grid = new AgGrid("TestGrid");
//列定義を設定する
const columnDefs = [
    grid.getCheckColumnDef(),
    grid.getEditColumnDef((params) => { alert(params) }),
    { headerName: "ID", field: "Id", width: 100 },
    { headerName: "名前", field: "Name" },
    { headerName: "説明", field: "Description", width: 250 }
];

const rowData = [
    {
        Id: "1",
        Name: "名前1",
        Description: "説明1"
    },
    {
        Id: "2",
        Name: "名前2",
        Description: "説明2"
    },
    {
        Id: "3",
        Name: "名前3",
        Description: "説明3"
    }
];

const gridOptions = {
    columnDefs: columnDefs,
    rowData: rowData
};

grid.setGridOptions(gridOptions);
grid.init();
```

画面側では列と行のjsonを指定するだけです。
他のもろもろの設定をする必要はありません！
今回の例gridOptionsにrowDataを指定していますが
initした後にsetRowDataからセットしてももちろん問題ありません。
これをコンパイルしてhtmlの方で読み込みます。

```html:index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <style>
        .ag-grid {
            height: 200px;
            width: 800px;
        }
    </style>
    <title>test</title>
  </head>
  <body>
    <div class="ag-theme-balham ag-grid" id="TestGrid"></div>
    <script src="./static/js/ag-grid.js"></script>
    <script src="./static/js/index.js"></script>
  </body>
</html>
```

# 実行結果
![ag-grid.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1734544/e0a9c23d-da1d-ba8b-4460-3d58bf348d67.png)

# おわりに
業務システム等で同じグリッドが20画面ある、とか
その20画面すべてに同じ修正をしなきゃいけない、といったシチュエーションで使えると思いますがいかがでしょうか。