こんにちは、XI 本部ソフトウェアデザインセンター所属・新卒 1 年目の松本です。 昨年10月に配属されてから 3 ヶ月間、Atlassian Forge を使った アプリ開発 を担当しました。 そこで今回は、Forge の概要と実際の開発手順を解説していきます。 Forge に関する日本語記事はほとんどなく心細い想いをしたので、本記事が誰かの助けになれば幸いです。 Forge とは UI 構築手法 2 パターン UI kit Custom UI 開発手順の例 到達目標 執筆者の環境 ① Node.js の導入 ② Forge CLI の導入 ③ プロジェクトの作成 ④ ビルド・デプロイ・インストール ⑤ TypeScript への対応 ⑥ Atlassian REST API と UI kit フックを使い、必要な情報を取得してみる ⑦ 取得した値を表示する ⑧ 編集内容を提出できるようにする 開発を便利にする Tips UI kit を使ってみた感想 おわりに 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・ CSS ・ JavaScript などの静的リソースを使用して、独自の UI を構築します。UI kit とは異なり、 レンダリング はユーザー側で行われるため、速いです(ただし、外部リソースへのアクセスはバックエンドを経由する必要がある、といったルールがあります)。 タイトルの通り、以降の開発手順では前者の UI kit を用います。Custom UI の解説はまたの機会に…。 開発手順の例 到達目標 手順の紹介に入る前に、本記事における開発の到達目標を明確にしておきます。 ここでは、Jira Service Management に「リク エス ト内容をリク エス ター自身が編集できる機能」を追加することを目標とします。 Jira Service Management とは、Jira を拡張したサービスデスク管理ツールです。Jira Service Management では現状、届いたリク エス ト(= 困っている人からの問い合わせ)の内容を管理者側から編集することはできても、リク エス ター側からはできないという制限があります。今回実装する機能は、こうした制限を解決するものです。 なお、Jira や Confluence を対象にした場合も同じような流れで開発を進められます。 完成イメージ 設置したボタンを押すと、 編集用のモーダルが開き、 編集して submit を押すと、 反映される。 執筆者の環境 主要なものだけあげておきます。 macOS Monterey 12.6 Visual Studio Code 1.74.3 node 16.17.1 Forge CLI 6.4.0 Forge API 2.7.0 React 18.2.0 それでは開発に入っていきましょう。 ① 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 の概要と実際の開発手順を説明しました。データの取得・画面表示・登録と、基本的な動作をカバーしたつもりです。ニッチなツールではありますが、本記事が誰かのお役に立てば幸いです。 私たちは同じチームで働いてくれる仲間を探しています。今回のエントリで紹介したような仕事に興味のある方、ご応募お待ちしています。 ソリューションアーキテクト 執筆: @matsu ( Shodo で執筆されました )