電通総研 テックブログ

電通総研が運営する技術ブログ

Forge を使って Jira の機能を拡張する − UI kit 編 −

こんにちは、XI 本部ソフトウェアデザインセンター所属・新卒 1 年目の松本です。

昨年10月に配属されてから 3 ヶ月間、Atlassian Forge を使ったアプリ開発を担当しました。

そこで今回は、Forge の概要と実際の開発手順を解説していきます。

Forge に関する日本語記事はほとんどなく心細い想いをしたので、本記事が誰かの助けになれば幸いです。

Forge とは

Atlassian 社が提供する FaaS プラットフォームです。Atlassian のサービスをカスタマイズ・機能拡張するためのアプリを、ユーザー自身で簡単に作成できます。現在は、Jira、Jira Service Management、Confluence の3つのサービスに対応しており、作成したアプリはこれらのサービス上から利用することとなります。

アプリの例としては、

  • Jira の課題パネルから多言語翻訳を使えるようにするアプリ
  • Jira の課題の健全性を、更新の滞りなどの情報をもとに判断してくれるアプリ
  • Confluence に、Google フォトに保存されている写真を表示できるようにするアプリ

などが公式ドキュメントで紹介されています。

https://developer.atlassian.com/platform/forge/example-apps/

アプリ開発に伴う機能実装以外の作業(ビルド・デプロイ・権限の管理・スケーリング・テナント管理などなど)は Atlassian 側でほとんど担ってくれるため、開発者は実現したい機能の実装に注力できます。

UI 構築手法 2 パターン

アプリの UI 構築手法が 2 パターン用意されており、いずれかを選ぶ必要があります。

迅速・簡便な UI kit と 自由度が高い Custom UI です。

UI kit

提供されたコンポーネントを組み合わせて UI を構築します。コンポーネントは、各種入力フォーム・ボタン・テーブルなど、豊富に用意されています。いずれもコンポーネントも Atlassian 風のデザインとなっており、自分でデザインを考える手間が省けます(逆に、カスタマイズ性はほとんどありません)。

使い方は React のコンポーネントとよく似ています。また、フック機能も提供されており、こちらもほぼ React のそれです。

なお、レンダリングは全てサーバー側で行われるため、相応の遅延が発生します。また、現状、コンポーネントとフックは提供されたもの以外には使うことができないため、柔軟性に欠けます(Forge 開発チームの動向を見ていると、近々できるようになるかも?)。

Custom UI

HTML・CSSJavaScript などの静的リソースを使用して、独自の UI を構築します。UI kit とは異なり、レンダリングはユーザー側で行われるため、速いです(ただし、外部リソースへのアクセスはバックエンドを経由する必要がある、といったルールがあります)。

タイトルの通り、以降の開発手順では前者の UI kit を用います。Custom UI の解説はまたの機会に…。

開発手順の例

到達目標

手順の紹介に入る前に、本記事における開発の到達目標を明確にしておきます。

ここでは、Jira Service Management に「リクエスト内容をリクエスター自身が編集できる機能」を追加することを目標とします。

Jira Service Management とは、Jira を拡張したサービスデスク管理ツールです。Jira Service Management では現状、届いたリクエスト(= 困っている人からの問い合わせ)の内容を管理者側から編集することはできても、リクエスター側からはできないという制限があります。今回実装する機能は、こうした制限を解決するものです。

なお、Jira や Confluence を対象にした場合も同じような流れで開発を進められます。

完成イメージ 設置したボタンを押すと、 編集用のモーダルが開き、 編集して submit を押すと、 反映される。

執筆者の環境

主要なものだけあげておきます。

それでは開発に入っていきましょう。

① Node.js の導入

v14 以降の LTS release が必要です。入っていない場合はインストールしてください。

② Forge CLI の導入

Forge CLI は、Forge アプリを管理するために使用する要のパッケージです。npm からインストールしてください。

npm install --save-dev @forge/cli

インストール後、API トークンを使用してログインする必要があります。 詳細な手順は、以下のドキュメントを参照してください。

https://developer.atlassian.com/platform/forge/getting-started/#log-in-with-an-atlassian-api-token

③ プロジェクトの作成

以下のコマンドでプロジェクトを作成します。

forge create

コマンドを実行すると 3 つ質問されるので、順に答えていきます。

(1) アプリ名

好きなアプリ名をつけましょう。

(2) UIツール

UI kit と Custom UI の選択です。前述の通り、今回は UI kit を選択します。

(3) モジュールのテンプレート

Forge では、Jira などのサービスにアプリを組み込むための機能を「モジュール」として提供しています。作りたいアプリに適したモジュールを選択すると、そのモジュールに合わせて良い感じのテンプレートを作成してくれます。

今回は、Jira Service Management のリクエスト閲覧画面に編集ボタンを付けたいので、「jira-service-management-portal-request-view-action」というモジュールを選択します。これは「リクエスト閲覧画面にボタンが追加され、ボタンをクリックすると定義したアクションを走らせることができる」というモジュールです。

モジュール一覧は以下から確認できますので、作りたいアプリに合わせて選んでみてください。

https://developer.atlassian.com/platform/forge/manifest-reference/modules/

以上3つの選択が完了すると、自動的に以下のようなプロジェクトが作成されます。

コードも少し覗いてみましょう。

// src/index.jsx

import ForgeUI, { render, Text, PortalRequestViewAction, ModalDialog, useState } from '@forge/ui';

const App = () => {
  const [isOpen, setOpen] = useState(true);

  if (!isOpen) {
    return null;
  }

  return (
    <ModalDialog header="Hello" onClose={() => setOpen(false)}>
      <Text>Hello world!</Text>
    </ModalDialog>
  );
};

export const run = render(
  <PortalRequestViewAction>
    <App/>
  </PortalRequestViewAction>
);

React そっくりですね。簡単にコードの解説をしておくと…

useState は UI kit が提供するフックの1つで、使い方は React の useState とほとんど同じです。

<ModalDialog> や <Text> は UI kit componets と呼ばれ、Atlssian 風デザインのパーツを提供するためのコンポーネントです。

一方、<PortalRequestViewAction> は Function components と呼ばれ、アプリ作成時に選択したモジュールが提供するコンポーネントです。今回の例では、<PortalRequestViewAction> に囲まれた UI kit componets が、リクエスト閲覧ページのボタンをクリックした際に表示されることとなります。

④ ビルド・デプロイ・インストール

ここで 1 度アプリをデプロイし、Atlassian サービス上で確認してみましょう。

アプリのルートで以下のコマンドを実行します。これ 1 つでエラーチェック・ビルド・デプロイまでを行ってくれる強力なコマンドです。

forge deploy

デプロイ先は develop、staging、production の3つが用意されており、--environment (-e) オプションで指定できます (デフォルトは development)。今回は development にします。

続いて、以下のコマンドを実行し、デプロイしたアプリを自分が管理するテナントにインストールします。実行後にテナントのドメインを聞かれるので入力してください。

forge install

これで、Atlassianのサービスからアプリを使えるようになりました。確認してみましょう。

今回は Jira Service Management のリクエスト閲覧画面にボタンを追加するモジュールを使ったので、当該画面を見に行きます。

すると、以下のようにボタンが設置されており、

クリックするとモーダルが開いて「Hello World!」と表示されます。

ここまで 1 ミリもコーディングをしていません。素晴らしいですね。

⑤ TypeScript への対応

本ステップは任意ですが、UI kit では簡単に対応できるので、ぜひやっておきましょう。 以下の 4 つの作業を行います。

(1) tsconfig.json の作成

今回は以下のように設定しました。

// tsconfig.json

{
  "compilerOptions": {
    "target": "es2020",
    "jsx": "react",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true
  }
}

(2) ソースファイルの拡張子を変更

js(x) → ts(x)

(3) TypeScript のプリプロセッサを package.json に追加

必要なプリプロセッサがあれば追加しましょう。

ついでに、React がないと index.tsx で エラーが表示されてしまうので、react および対応するプリプロセッサを npm からインストールするとともに、index.tsx でインポートしておきます。

// src/index.tsx

import React from "react"

なお、TypeScript のコンパイルは、前述した forge deploy コマンド実行時に一緒に実行してくれます。

⑥ Atlassian REST API と UI kit フックを使い、必要な情報を取得してみる

続いて、編集に必要な情報を取得して、モーダルに表示してみます。

アプリと Atlassian サービスとのやり取りは、基本的には Atlassian が提供する REST API を介して行われます。加えて、現在閲覧しているリクエストの ID など、UI kit のフックを介して取得する情報も一部あります。

Forgeでは、REST API の認可は OAuth2.0 で行われており、権限を移譲されたアプリがユーザーに代わって API 呼び出しを行います。そのため、開発者はトークンなどの機密情報を管理する必要がなく、簡潔なコードで API を呼び出せます。

併せて、アプリにどの程度の権限(スコープ)を与えるかを、マニフェストファイル (manifest.yml) に記述する必要があり、このスコープによって扱えるAPIの範囲が決まります。

利用できる Atlassian REST API は以下から確認してください。その API に必要なスコープなどの情報も記載されています。 https://developer.atlassian.com/platform/forge/product-rest-api-reference/

今回は、現在閲覧しているリクエストの ID をUI kit の フック で取得し、その ID をもとに、リクエストの設問文や問い合わせ内容を REST API で取得してみます。

現在閲覧しているページの情報は、useProductContext という UI kit フックで取得します。なお、本フックの返り値の型として ProductContext 型が提供されていますが、Jira のみに対応していて、Jira Service Management には対応していません。ですので、今回は ProductContextForJsm 型を自身で定義しています。

// src/index.tsx

// ProductContextForJsm の型定義 (省略。「コード全文を見る」から確認できます。)

const productContext = useProductContext() as ProductContextForJsm;
const requestId = productContext.extensionContext.request.key;

次に、欲しい情報を REST API で取得します。まずは @forge/api モジュールを npm からインストールするとともに、index.tsx でインポートします。

// src/index.tsx

import api, { Route, route } from "@forge/api";

リクエスト情報の取得には「Get customer request by id or key」というAPI を用います。この API を呼びだす関数を定義しましょう。

// src/index.tsx

// ResponseJson の型定義 (省略。「コード全文を見る」から確認できます。)

const fetchRequest = async (requestKey: string): Promise<ResponseJson> => {
  const response = await api
    .asApp()
    .requestJira(route`/rest/servicedeskapi/request/${requestKey}`, {
      headers: {
        Accept: "application/json",
      },
    });
  return await response.json();
};

API 呼び出しの関数は、useEffect 内で呼び出し、返り値は state に保持するのが良いでしょう。

state を追加し、

// src/index.tsx

const [responseJson, setResponseJson] = useState<ResponseJson>({
  requestFieldValues: [],
});

useEffect 内で API を呼び出して、返り値を state にセットします。

// src/index.tsx

useEffect(async () => {
  const responseJson = await fetchRequest(requestId);
  setResponseJson(responseJson);
}, []);

最後に、使用する API を呼びだすために必要なスコープを、マニフェストファイルに追加します。

// manifest.yml

permissions:
  scopes:
    - "read:servicedesk-request"
    - "read:jira-work"

コード全文を見る

// src/index.tsx

import api, { Route, route } from "@forge/api";
import ForgeUI, {
  render,
  Text,
  PortalRequestViewAction,
  ModalDialog,
  useState,
  useProductContext,
  useEffect,
} from "@forge/ui";
import { ExtensionContext, ProductContext } from "@forge/ui/out/types";
import React from "react";

interface ProductContextForJsm extends ProductContext {
  extensionContext: ExtensionContextForJsm;
}

interface ExtensionContextForJsm extends ExtensionContext {
  request: { key: string };
}

interface ResponseJson {
  requestFieldValues: Request[];
}

interface Request {
  fieldId: string;
  label: string;
  value: string;
}

const App = () => {
  const [isOpen, setOpen] = useState(true);
  const [responseJson, setResponseJson] = useState<ResponseJson>({
    requestFieldValues: [],
  });

  useEffect(async () => {
    const responseJson = await fetchRequest(requestId);
    setResponseJson(responseJson);
  }, []);

  const productContext = useProductContext() as ProductContextForJsm;
  const requestId = productContext.extensionContext.request.key;

  const fetchRequest = async (requestKey: string): Promise<ResponseJson> => {
    const response = await api
      .asApp()
      .requestJira(route`/rest/servicedeskapi/request/${requestKey}`, {
        headers: {
          Accept: "application/json",
        },
      });
    return await response.json();
  };

  if (!isOpen) {
    return null;
  }

  return (
    <ModalDialog header="Hello" onClose={() => setOpen(false)}>
      <Text>Hello world!</Text>
    </ModalDialog>
  );
};

export const run = render(
  <PortalRequestViewAction>
    <App />
  </PortalRequestViewAction>
);

⑦ 取得した値を表示する

続いて、先ほど取得した値を画面に表示してみましょう。Jira Service Management では多様な設問タイプ(テキストボックス、チェックボックスラジオボタン・・・)がありますが、これら全てに対応しようとすると、本記事では収まりきりません。今回は簡易的に、リクエストの「要約」欄のみを表示してみることにします。

これです。

Form、TextField という UI kit コンポーネントを使用するので、まずはインポートを追加します。Text コンポーネントはもう使わないので、消しておきます。

// src/index.tsx

import ForgeUI, {
  render,
  PortalRequestViewAction,
  ModalDialog,
  useState,
  useProductContext,
  useEffect,
  Form,
  TextField,
} from "@forge/ui";

続いて、REST API で受け取ったリクエストの情報から要約欄の情報だけを抜き出し、 TextField コンポーネントに変換する関数を定義します。

// src/index.tsx

const makeTextField = (
  responseJson: ResponseJson
): JSX.Element | undefined => {
  const summary = responseJson.requestFieldValues.find(
    (request) => request.fieldId === "summary"
  );

  if (!summary) {
    return;
  }

  return (
    <TextField
      label={summary.label}
      name={summary.fieldId}
      defaultValue={summary.value}
    ></TextField>
  );
};

最後に、先ほど作成した関数から返される TextField コンポーネントを Form コンポーネントで囲み、App 関数で返すようにします。現在「Hello World」を返している部分を変更します。

// src/index.tsx

return (
  <ModalDialog header="Edit" onClose={() => setOpen(false)}>
    <Form onSubmit={() => setOpen(false)}>{makeTextField(responseJson)}</Form>
  </ModalDialog>
);

Form コンポーネントは onSubmit プロパティ(画面で submit ボタンが押された際に走る処理)の指定が必須ですが、一旦は、画面を閉じる処理を入れておきます。

それでは、画面で確認してみましょう、、、 の前に、アプリのスコープを変更したので、アップグレードが必要です。以下のコマンドでアップグレードを実行します。

forge install --upgrade

これでアプリを利用可能になりました。

確認してみると、以下のように、要約欄の情報を取得できるようになっていると思います。

コード全文を見る

// src/index.tsx

import api, { Route, route } from "@forge/api";
import ForgeUI, {
  render,
  PortalRequestViewAction,
  ModalDialog,
  useState,
  useProductContext,
  useEffect,
  Form,
  TextField,
} from "@forge/ui";
import { ExtensionContext, ProductContext } from "@forge/ui/out/types";
import React from "react";

interface ProductContextForJsm extends ProductContext {
  extensionContext: ExtensionContextForJsm;
}

interface ExtensionContextForJsm extends ExtensionContext {
  request: { key: string };
}

interface ResponseJson {
  requestFieldValues: Request[];
}

interface Request {
  fieldId: string;
  label: string;
  value: string;
}

const App = () => {
  const [isOpen, setOpen] = useState(true);
  const [responseJson, setResponseJson] = useState<ResponseJson>({
    requestFieldValues: [],
  });

  useEffect(async () => {
    const responseJson = await fetchRequest(requestId);
    setResponseJson(responseJson);
  }, []);

  const productContext = useProductContext() as ProductContextForJsm;
  const requestId = productContext.extensionContext.request.key;

  const fetchRequest = async (requestKey: string): Promise<ResponseJson> => {
    const response = await api
      .asApp()
      .requestJira(route`/rest/servicedeskapi/request/${requestKey}`, {
        headers: {
          Accept: "application/json",
        },
      });
    return await response.json();
  };

  const makeTextField = (
    responseJson: ResponseJson
  ): JSX.Element | undefined => {
    const summary = responseJson.requestFieldValues.find(
      (request) => request.fieldId === "summary"
    );

    if (!summary) {
      return;
    }

    return (
      <TextField
        label={summary.label}
        name={summary.fieldId}
        defaultValue={summary.value}
      ></TextField>
    );
  };

  if (!isOpen) {
    return null;
  }

  return (
    <ModalDialog header="Edit" onClose={() => setOpen(false)}>
      <Form onSubmit={() => setOpen(false)}>{makeTextField(responseJson)}</Form>
    </ModalDialog>
  );
};

export const run = render(
  <PortalRequestViewAction>
    <App />
  </PortalRequestViewAction>
);

⑧ 編集内容を提出できるようにする

それでは最後に、編集した内容を提出し、変更を反映できるようにします。

編集内容の確定には「Edit issue」という API を使用します。この API を呼びだす関数を作成します。この際、リクエストボディには、submit ボタンが押された際に送られてくるデータをもとに作成した JSON を指定します。

// src/index.tsx

const execEdit = async (submitted: {summary: string}) => {
  await api.asApp().requestJira(route`/rest/api/3/issue/${requestId}`, {
    method: "PUT",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: `{"fields":{"summary":"${submitted["summary"]}"}}`,
  });

  setOpen(false);
};

そして、この関数を Form コンポーネントの onSubmit プロパティで指定します。

// src/index.tsx

return (
  <ModalDialog header="Edit" onClose={() => setOpen(false)}>
    <Form onSubmit={execEdit}>{makeTextField(responseJson)}</Form>
  </ModalDialog>
);

これでOKです。 画面で確認する前に、新たに API を追加したので、書き込み権限をアプリに与えてアップグレードしましょう。

// manifest.yml

permissions:
  scopes:
    - "read:servicedesk-request"
    - "read:jira-work"
    - "write:jira-work"

以上で全ての工程が完了です。実際に使ってみます。

要約欄を編集し、submit を押して…リロードすると…

反映されています!

コード全文を見る

// src/index.tsx

import api, { Route, route } from "@forge/api";
import ForgeUI, {
  render,
  PortalRequestViewAction,
  ModalDialog,
  useState,
  useProductContext,
  useEffect,
  Form,
  TextField,
} from "@forge/ui";
import { ExtensionContext, ProductContext } from "@forge/ui/out/types";
import React from "react";

interface ProductContextForJsm extends ProductContext {
  extensionContext: ExtensionContextForJsm;
}

interface ExtensionContextForJsm extends ExtensionContext {
  request: { key: string };
}
        
interface ResponseJson {
  requestFieldValues: Request[];
}

interface Request {
  fieldId: string;
  label: string;
  value: string;
}

const App = () => {
  const [isOpen, setOpen] = useState(true);
  const [responseJson, setResponseJson] = useState<ResponseJson>({
    requestFieldValues: [],
  });

  useEffect(async () => {
    const responseJson = await fetchRequest(requestId);
    setResponseJson(responseJson);
  }, []);

  const productContext = useProductContext() as ProductContextForJsm;
  const requestId = productContext.extensionContext.request.key;

  const fetchRequest = async (requestKey: string): Promise<ResponseJson> => {
    const response = await api
      .asApp()
      .requestJira(route`/rest/servicedeskapi/request/${requestKey}`, {
        headers: {
          Accept: "application/json",
        },
      });
    return await response.json();
  };

  const makeTextField = (
    responseJson: ResponseJson
  ): JSX.Element | undefined => {
    const summary = responseJson.requestFieldValues.find(
      (request) => request.fieldId === "summary"
    );

    if (!summary) {
      return;
    }

    return (
      <TextField
        label={summary.label}
        name={summary.fieldId}
        defaultValue={summary.value}
      ></TextField>
    );
  };

  const execEdit = async (submitted: { summary: string }) => {
    await api.asApp().requestJira(route`/rest/api/3/issue/${requestId}`, {
      method: "PUT",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: `{"fields":{"summary":"${submitted["summary"]}"}}`,
    });

    setOpen(false);
  };

  if (!isOpen) {
    return null;
  }

  return (
    <ModalDialog header="Edit" onClose={() => setOpen(false)}>
      <Form onSubmit={execEdit}>{makeTextField(responseJson)}</Form>
    </ModalDialog>
  );
};

export const run = render(
  <PortalRequestViewAction>
    <App />
  </PortalRequestViewAction>
);

開発を便利にする Tips

開発手順の紹介は以上ですが、開発を便利にする Tips も紹介しておきます。

① ログの出し方

ログを出したい箇所に

console.log("出したい内容")

を入れ、

forge logs

を実行することで、ログが確認できます。

いちいち上記コマンドを打つのは面倒ですが、次に紹介する Tunnel モードを組み合わせることで解消されます。

② Tunnel モード

ファイル保存時に自動的に再ビルドを実行してくれる機能です。ただし、development 環境へアプリをデプロイしている場合に限定されます。いちいち forge deploy コマンドを打つ手間が省けるほか、前述したログもリアルタイムで出してくれるため、非常に便利です。

使い方は以下を確認してください。 https://developer.atlassian.com/platform/forge/tunneling/

注意点として、たまに、何のエラーも出ていないにもかかわらず変更が反映されない場合がありました。把握しておかないと永遠に時間を溶かすことになるので気をつけてください。

③ モジュールの追加方法

forge create コマンドでプロジェクトを作成した直後はモジュールは1つだけですが、当然複数モジュールを扱うこともできます。マニフェストファイル (manifest.yml) に追加すればOKです。以下のようなイメージ。

// manifest.yml

modules:
  jiraServiceManagement:portalRequestViewAction:
    - key: module1
      function: func1
      title: モジュール1
  jiraServiceManagement:queuePage:
    - key: module2
      function: func2
      title: モジュール2
  function:
    - key: func1
      handler: index.run1
    - key: func2
      handler: index.run2

表示するボタンの表記やアイコン、ボタンを押した際に最初に呼び出される関数などを変更したい場合もマニフェストファイルをいじります。

UI kit を使ってみた感想

本記事では UI kit を使って開発を進めてきましたが、メリット・デメリットともに強く感じたため、感想を記しておきます(おおむねコンセプト通りの感想ですが)。

メリット

  • とにかく開発が速くて簡単です。デザインのことは何も考えなくて良いです(考える余地がないとも言う)。コンポーネントの種類も割と豊富で、シンプルな機能を追加するだけであればあまり困らないと思います。

デメリット

  • 想像以上に自由がききませんでした。現状だと、文字の色やサイズ、コンポーネント間の間隔などのちょっとしたところも変えられません。また、今回紹介した編集機能に関して言うと、フィールドに対応したコンポーネントが提供されておらず、工夫してもどうしようもない場合がありました(リッチテキストで入力する欄など)。
  • レンダリング速度が遅いです。毎回バックエンドを経由しているので仕方ないのですが、入力値のバリデーションなどでは特に気になります。

機能が複雑化してくるとどうしても UI kit では物足りない場面があるため、今後は Custom UI を使用した開発にも取り組んでいきたいと思います。

おわりに

今回は、Atlassian Forge の概要と実際の開発手順を説明しました。データの取得・画面表示・登録と、基本的な動作をカバーしたつもりです。ニッチなツールではありますが、本記事が誰かのお役に立てば幸いです。


私たちは同じチームで働いてくれる仲間を探しています。今回のエントリで紹介したような仕事に興味のある方、ご応募お待ちしています。

ソリューションアーキテクト

執筆:@matsuShodoで執筆されました