アルアカ - Arcadia Academia

Arcadia Academiaは「エンジニアリングを楽しむ」を合言葉に日本のデジタル競争力を高めることをミッションとするテックコミュニティです。

WebpackとTypeScriptを使ったKintoneカスタマイズの基本

Featured image of the post

Kintoneは、プログラミング経験が少ないユーザーでも業務アプリを簡単に作成できるプラットフォームとして、多くの企業で利用されています。しかし、より高度で柔軟なカスタマイズが求められる場面では、JavaScriptやTypeScriptを使った開発が必要になります。この記事では、WebpackとTypeScriptを使ってKintoneのカスタマイズを行う方法を解説します。

なぜWebpackとTypeScriptを使うのか?

まずは、なぜWebpackとTypeScriptを使用するのかを説明します。

1. TypeScriptの利点

JavaScriptは柔軟で強力な言語ですが、コードが複雑になるとバグが発生しやすくなります。TypeScriptを使うと、型定義によりコードの保守性と信頼性が向上します。型チェックによって開発時に多くのエラーを未然に防ぐことができるため、特に大規模なプロジェクトではTypeScriptの導入が推奨されます。

2. Webpackの役割

Webpackは、複数のJavaScriptやTypeScriptファイルを1つにまとめるモジュールバンドラです。これにより、ファイルを効率的に管理・読み込むことができ、Kintoneのようなウェブアプリケーションのパフォーマンスが向上します。また、WebpackはBabelやTypeScriptコンパイラと連携して、最新のコードを古いブラウザでも動作するようにトランスパイルできます。

環境設定

まず、Node.jsがインストールされていることを確認します。その後、プロジェクトのセットアップを行います。

npm init -y
npm install --save-dev webpack webpack-cli typescript ts-loader

これで、Webpack、TypeScript、そしてTypeScriptファイルをWebpackで処理するためのts-loaderがインストールされます。

TypeScriptの設定

次に、TypeScriptの設定ファイルであるtsconfig.jsonをプロジェクトのルートに作成します。以下の設定を使用します。

{
  "compilerOptions": {
    "target": "ES6",
    "module": "ESNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "typeRoots": ["./node_modules/@types", "./src/types"]
  },
  "include": ["src/**/*"]
}

この設定により、生成されるJavaScriptはES6(ECMAScript 2015)に準拠し、TypeScriptの強力な型チェック機能を活用できます。※./src/types はあとで作成

Webpackの設定

次に、Webpackの設定ファイルwebpack.config.jsを作成します。基本的な設定は以下の通りです。

const path = require('path');

module.exports = {
  entry: './src/index.ts',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  resolve: {
    extensions: ['.ts', '.js']
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: 'ts-loader',
        exclude: /node_modules/
      }
    ]
  }
};

ここでは、entryにアプリケーションのエントリーポイントであるTypeScriptファイルを指定し、outputにはバンドルされたファイルの出力先を指定しています。

Kintone用のTypeScriptコードを書く

srcフォルダ内にindex.tsファイルを作成し、Kintoneカスタマイズ用のコードを記述します。例えば、レコードが追加された際にアラートを表示するカスタマイズコードは以下の通りです。

const events = ['app.record.create.submit'];

kintone.events.on(events, (event) => {
  alert('レコードが追加されました');
  return event;
});

このままだとkintoneで型エラーが起きるのでkintone.d.ts を作成します。

srcの配下にtypesというディレクトリを作成しkintone.d.ts を作成してください。

declare namespace kintone {
  namespace events {
    function on(event: string | string[], handler: (event: any) => any): void;
    function off(
      event: string | string[],
      handler: (event: any) => any
    ): boolean;
    function off(event: string | string[]): boolean;
    function off(): boolean;
  }

  namespace api {
    function url(path: string, detectGuestSpace?: boolean): string;
    function urlForGet(
      path: string,
      params: any,
      detectGuestSpace?: boolean
    ): string;

    function getConcurrencyLimit(): Promise<{
      limit: number;
      running: number;
    }>;
  }

  function api(pathOrUrl: string, method: string, params: any): Promise<any>;

  function api(
    pathOrUrl: string,
    method: string,
    params: any,
    callback: (resp: any) => void,
    errback: (err: any) => void
  ): void;

  function getRequestToken(): string;

  function proxy(
    url: string,
    method: string,
    headers: any,
    data: any
  ): Promise<any>;

  function proxy(
    url: string,
    method: string,
    headers: any,
    data: any,
    callback: (resp: any) => void,
    errback: (err: any) => void
  ): void;

  class Promise<T> {
    constructor(
      callback: (
        resolve: (resolved: T) => any,
        reject: (rejected: any) => any
      ) => void
    );

    then(callback: (resolved: T) => any): Promise<any>;
    catch(callback: (rejected: any) => any): Promise<any>;

    static resolve(resolved: any): Promise<any>;
    static reject(rejected: any): Promise<any>;
    static all(listOfPromise: Array<Promise<any>>): Promise<any>;
  }

  namespace proxy {
    function upload(
      url: string,
      method: string,
      headers: any,
      data: any,
      callback: (resp: any) => void,
      errback: (err: any) => void
    ): void;

    function upload(
      url: string,
      method: string,
      headers: any,
      data: any
    ): Promise<any>;
  }

  namespace app {
    function getFieldElements(fieldCode: string): HTMLElement[] | null;
    function getHeaderMenuSpaceElement(): HTMLElement | null;
    function getHeaderSpaceElement(): HTMLElement | null;
    function getId(): number | null;
    function getLookupTargetAppId(fieldCode: string): string | null;
    function getQuery(): string | null;
    function getQueryCondition(): string | null;
    function getRelatedRecordsTargetAppId(fieldCode: string): string | null;

    namespace record {
      function getId(): number | null;
      function get(): any | null;
      function getHeaderMenuSpaceElement(): HTMLElement | null;
      function getFieldElement(fieldCode: string): HTMLElement | null;
      function set(record: any): void;
      function getSpaceElement(id: string): HTMLElement | null;
      function setFieldShown(fieldCode: string, isShown: boolean): void;
      function setGroupFieldOpen(fieldCode: string, isOpen: boolean): void;
    }
  }

  namespace mobile {
    namespace app {
      function getFieldElements(fieldCode: string): HTMLElement[] | null;
      function getHeaderSpaceElement(): HTMLElement | null;
      function getId(): number | null;
      function getLookupTargetAppId(fieldCode: string): string | null;
      function getQuery(): string | null;
      function getQueryCondition(): string | null;
      function getRelatedRecordsTargetAppId(fieldCode: string): string | null;

      namespace record {
        function getId(): number | null;
        function get(): any | null;
        function getFieldElement(fieldCode: string): HTMLElement | null;
        function set(record: any): void;
        function getSpaceElement(id: string): HTMLElement | null;
        function setFieldShown(fieldCode: string, isShown: boolean): void;
        function setGroupFieldOpen(fieldCode: string, isOpen: boolean): void;
      }
    }

    namespace portal {
      function getContentSpaceElement(): HTMLElement | null;
    }

    namespace space {
      namespace portal {
        function getContentSpaceElement(): HTMLElement | null;
      }
    }
  }

  namespace plugin {
    namespace app {
      function getConfig(pluginId: string): any;
      function setConfig(config: any, callback?: () => void): void;

      function proxy(
        pluginId: string,
        url: string,
        method: string,
        headers: any,
        data: any
      ): Promise<any>;

      function proxy(
        pluginId: string,
        url: string,
        method: string,
        headers: any,
        data: any,
        callback: (resp: any) => void,
        error: (err: any) => void
      ): void;

      function setProxyConfig(
        url: string,
        method: string,
        headers: any,
        data: any,
        callback?: () => void
      ): void;

      function getProxyConfig(url: string, method: string): any;

      namespace proxy {
        function upload(
          pluginId: any,
          url: string,
          method: string,
          headers: any,
          data: any
        ): Promise<any>;

        function upload(
          pluginId: any,
          url: string,
          method: string,
          headers: any,
          data: any,
          callback: (resp: any) => void,
          error: (err: any) => void
        ): void;
      }
    }
  }

  namespace portal {
    function getContentSpaceElement(): HTMLElement | null;
  }

  namespace space {
    namespace portal {
      function getContentSpaceElement(): HTMLElement | null;
    }
  }

  interface LoginUser {
    id: string;
    code: string;
    name: string;
    email: string;
    url: string;
    employeeNumber: string;
    phone: string;
    mobilePhone: string;
    extensionNumber: string;
    timezone: string;
    isGuest: boolean;
    language: string;
  }

  function getLoginUser(): LoginUser;
  function getUiVersion(): 1 | 2;

  const $PLUGIN_ID: string;

  namespace fieldTypes {
    interface SingleLineText {
      type?: "SINGLE_LINE_TEXT";
      value: string;
      disabled?: boolean;
      error?: string;
    }

    interface RichText {
      type?: "RICH_TEXT";
      value: string;
      disabled?: boolean;
      error?: string;
    }

    interface MultiLineText {
      type?: "MULTI_LINE_TEXT";
      value: string;
      disabled?: boolean;
      error?: string;
    }

    interface Number {
      type?: "NUMBER";
      value: string;
      disabled?: boolean;
      error?: string;
    }

    interface Calc {
      type: "CALC";
      value: string;
      disabled?: boolean;
    }

    interface RadioButton {
      type?: "RADIO_BUTTON";
      value: string;
      disabled?: boolean;
      error?: string;
    }

    interface DropDown {
      type?: "DROP_DOWN";
      value: string;
      disabled?: boolean;
      error?: string;
    }

    interface Date {
      type?: "DATE";
      value: string;
      disabled?: boolean;
      error?: string;
    }

    interface Time {
      type?: "TIME";
      value: string;
      disabled?: boolean;
      error?: string;
    }

    interface DateTime {
      type?: "DATETIME";
      value: string;
      disabled?: boolean;
      error?: string;
    }

    interface Link {
      type?: "LINK";
      value: string;
      disabled?: boolean;
      error?: string;
    }

    interface CheckBox {
      type?: "CHECK_BOX";
      value: string[];
      disabled?: boolean;
      error?: string;
    }

    interface MultiSelect {
      type?: "MULTI_SELECT";
      value: string[];
      disabled?: boolean;
      error?: string;
    }

    interface UserSelect {
      type?: "USER_SELECT";
      value: Array<{ code: string; name: string }>;
      disabled?: boolean;
      error?: string;
    }

    interface OrganizationSelect {
      type?: "ORGANIZATION_SELECT";
      value: Array<{ code: string; name: string }>;
      disabled?: boolean;
      error?: string;
    }

    interface GroupSelect {
      type?: "GROUP_SELECT";
      value: Array<{ code: string; name: string }>;
      disabled?: boolean;
      error?: string;
    }

    interface File {
      type: "FILE";
      value: Array<{
        contentType: string;
        fileKey: string;
        name: string;
        size: string;
      }>;
      disabled?: boolean;
      error?: string;
    }

    interface Id {
      type: "__ID__";
      value: string;
    }

    interface Revision {
      type: "__REVISION__";
      value: string;
    }

    /**
     * field type of UserField is MODIFIER.
     * So error property not exists.
     */
    interface Modifier {
      type: "MODIFIER";
      value: { code: string; name: string };
    }

    /**
     * field type of UserField is CREATOR.
     * So error property not exists.
     */
    interface Creator {
      type: "CREATOR";
      value: { code: string; name: string };
    }

    interface RecordNumber {
      type: "RECORD_NUMBER";
      value: string;
      error?: string;
    }

    interface UpdatedTime {
      type: "UPDATED_TIME";
      value: string;
      error?: string;
    }

    interface CreatedTime {
      type: "CREATED_TIME";
      value: string;
      error?: string;
    }
  }
}
カスタマイズファイルのアップロード

次に、Kintoneにカスタマイズファイルをアップロードするためのkintone-customize-uploaderをプロジェクトにインストールします。

npm install --save-dev @kintone/customize-uploader
カスタマイズファイルの設定

customize-manifest.jsonというファイルをプロジェクトのルートディレクトリに作成し、アップロードするファイルや適用するアプリの設定を行います。

{
  "app": アプリID,
  "scope": "ALL",
  "desktop": {
    "js": [
      "dist/bundle.js"
    ],
    "css": []
  },
  "mobile": {
    "js": [],
    "css": []
  }
}

ここで、appにカスタマイズを適用するKintoneアプリのIDを指定します。

環境変数の設定

次に、dotenvパッケージを使用して環境変数を設定します。まず、dotenvパッケージをインストールします。

npm install --save-dev dotenv

その後、プロジェクトのルートディレクトリに.envファイルを作成し、Kintoneの環境変数を設定します。

KINTONE_BASE_URL=https://yourdomain.kintone.com
KINTONE_USERNAME=your_username
KINTONE_PASSWORD=your_password
upload.js の作成

プロジェクトルートディレクトリにupload.js を作成します。

require("dotenv").config();
const { spawn } = require("child_process");

const mode = process.env.NODE_ENV || "development";

const webpackProcess = spawn("npx", ["webpack", "--watch", "--mode", mode], { shell: true });

webpackProcess.stdout.on("data", (data) => {
  console.log(`Webpack: ${data}`);

  if (data.toString().includes("built")) {
    console.log("Webpack build completed. Starting upload...");

    const uploadProcess = spawn(
      "npx",
      [
        "kintone-customize-uploader",
        "customize-manifest.json",
        "--base-url",
        process.env.KINTONE_BASE_URL,
        "--username",
        process.env.KINTONE_USERNAME,
        "--password",
        process.env.KINTONE_PASSWORD,
      ],
      { shell: true }
    );

    uploadProcess.stdout.on("data", (uploadData) => {
      const message = uploadData.toString();

      if (message.includes("Wait for deploying completed...")) {
        console.log("カスタマイズの反映を待機中...");
      } else if (message.includes("Customize setting has been updated!")) {
        console.log("カスタマイズ設定が更新されました!");
      } else if (message.includes("Setting has been deployed!")) {
        console.log("カスタマイズが正常にデプロイされました!");
      } else {
        console.log(`Uploader: ${message}`);
      }
    });

    uploadProcess.stderr.on("data", (uploadError) => {
      console.error(`Uploader Error: ${uploadError}`);
    });

    uploadProcess.on("close", (code) => {
      console.log(`Uploader process exited with code ${code}`);
    });
  }
});

webpackProcess.stderr.on("data", (data) => {
  console.error(`Webpack Error: ${data}`);
});

webpackProcess.on("close", (code) => {
  console.log(`Webpack process exited with code ${code}`);
});
スクリプトの実行

package.jsonに以下のようなスクリプトを追加し、upload.jsを実行してカスタマイズをアップロードします。

"scripts": {
  "start": "node upload.js"
}

スクリプトを実行するには、以下のコマンドを使います。

npm run start

これにより、.envファイルに設定された環境変数を使ってKintoneにカスタマイズがアップロードされます。

まとめ

WebpackとTypeScriptを使用したKintoneカスタマイズは、カスタマイズのソースをGitなどでバージョン管理もできるので、保守性が高く、効率的な開発を可能にします。

さらに、kintone-customize-uploaderを利用することで、簡単にカスタマイズをKintoneにアップロードできます。これにより、Kintoneカスタマイズの開発が一層強力で効率的なものになるので、ぜひ、試してみてください。

業務効率化・DX推進でお悩みですか?

オンラインセッションで課題を可視化し、最適な解決策をご提案します。

  • DX推進を何から始めればいいかわからない
  • ツール導入を検討している
  • 社内でデジタル人材を育成したい
まずは無料で課題整理

相談は完全無料・オンラインで気軽に

あなたを爆速で成長させるメンタリングプログラムはこちら

メンタープログラムバナー

業務効率化・DX推進のご相談はこちら

伴走支援プログラムの詳細を見る
無料相談はこちら