AppMakerでワークフローアプリのミニマム版を作るぞ！

どうもこんばんは、みかんです。

**！！！！とつぜんですがたいせつなおしらせ！！！！**

2020年1月27日
App Maker の提供終了に向けた対応のご案内
https://support.google.com/a/answer/9682494?p=am_announcement&visit_id=637157414793613981-426262357&rd=1


??!???!?!?!???!?

残念ながら2021年1月19日には終了してしまうそうです(涙)
移行先はAppSheetかApp EngineかGoogleフォームだそうです・・・

消すのは忍びないので記事は残して置きますが、何かの間違いじゃ無い限り役に立つことはないでしょう・・・


**！！！！おしらせはおわりです！！！！！**

AdventCalendar最終日用の記事でしたが師走には勝てず。
そのまま全力で年末年始OFFっちゃったのでまさかの1月6日にFinishです。。

今回は「ワークフローアプリのミニマム版を作るぞ！」という題で書いてみます。
下記ステップでやっていきます。

* 1 はじめに
* 2 要件を整理する
* 3 完成形の確認
* 4 モデルを作る
* 5 ページを作る
* 6 スクリプトをこしらえる
* 7 おわりに

# 1 はじめに

「ワークフローアプリ」ってテンプレートあるんじゃないの？ とお気づきの方、正解です。
現在、下記テンプレートが用意されています。

- 1 ドキュメント承認
- 2 旅費交通費

これを活用するには、次のような障壁があります。

- 英語でわからない
- 英語でわからない
- 英語でわからない

ネタではありません！(とある会でのアンケート結果で8割これでした)。
App Makerで学習コスト少なめに業務アプリ作れるよ！としたくても、すべて英語だと馴染めないっていう点だけで色々速度が落ちます。
2019年6月くらいに公式ガイドは日本語化されたので、それを元にあれこれ試してみる感じになりますね。

残り2割はこんな感じかなと。

- スクリプト大杉でわからん
- そもそもどういう動きするのかわからん
- 試しづらい

スクリプトは一旦置いておくとして、動きがわからない&試しづらいについてはテンプレートがCloud SQLモデル前提になっており、そもそも試すまでのハードルが高いという点があります。G Suite全体の設定としてデフォルトのCloud SQLインスタンスの設定が必要なので、やりたいと思っている人の権限で完結しないパターンが多いのでは無いでしょうか。

用意されているワークフローアプリは結構よくできているので、内容を完全に理解したほうが実運用までの道は近かったりするのですが、まったくピンと来ないのも困りものです。そこで本記事では、機能をゴリゴリ削ったバージョンを作ってみたいと思います！

# 2 要件を整理する

ワークフローとして最低限動作する要件、仕様を考えてみます。

- ドキュメントの承認申請が出せる
- 申請された人は承認か否認をコメント付きで選択する
- 否認された場合は再申請できる

これくらいにしておきましょう。各操作は相手方に伝わる必要があるので、メールの送信も実装します。
管理者向けには、後から記録を確認できる画面も用意してみます。

# 3 完成形の確認

先に完成形を確認します。

|![ap01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/679cb3ec-122d-a2c6-f53f-4594d98b1f83.png)|
|:-:|

トップページはリクエスト一覧です。作成した申請の一覧が並びます。
StarterAppから作成しているので、見た目はそのまんまです。


|![ap02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/1749394a-7ea3-e3d8-1c17-84c1d43ed90c.png)|
|:-:|

画面は**リクエスト一覧**、**承認待ち一覧**、**管理画面**の3つです。


|![ap03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/a71e7d50-e4d9-62a6-4715-7ccaa394bee4.png)|
|:-:|

承認待ち一覧です。承認が必要な申請がここにずらりと並びます。

|![ap04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/c1638026-1e32-9c63-bfe3-132ae7a0b549.png)|
|:-:|

管理画面です。すべてのデータが確認可能です。

一連の流れを見ていきます。

|![ap05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/f550a563-b2a0-c9d1-af70-0f2450c5073f.png)|
|:-:|

リクエスト一覧画面の右下のボタンを押すと、このようにポップアップが表示されます。
DrivePickerで対象のドキュメントを選択し、承認者に伝える内容を詳細に記入し、承認者のアドレスをUserPicker(では無いですが)で選択します。
必要事項をすべて入力すると、申請ボタンが活性化するので、申請をクリックします。


|![ap06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/6a77c3d2-7dc4-d124-1acf-cb6f129c0fb9.png)|
|:-:|

申請を一つ作成したので、リクエスト一覧にデータが追加されています。クリックすると詳細情報を確認できます。

|![ap07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/bdba72db-a78c-ce99-d856-5362ec507739.png)|
|:-:|

申請中なので、ドキュメントのURLや詳細は編集できないようにしています。

このタイミングで、申請者と承認者の両方にメールが送信されています。

**申請者**

|![ap08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/a1079d68-b7e2-d782-b1ae-d775a00c7612.png)|
|:-:|

**承認者**

|![ap09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/f2f95f61-503c-3e02-b629-615a0db4e95b.png)|
|:-:|

「システムURL」に記載されているURLをクリックすると、直接承認画面に飛ぶことができるようになっています。

|![ap10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/cf701429-1cfd-3c13-2a45-734c62075537.png)|
|:-:|

否認してみます。

|![ap11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/410bd6f0-349e-8cd0-aa2f-f44fd41c12a3.png)|
|:-:|

これで、承認待ち一覧は空になります。
否認されたことを通知するため、申請者には下記メールが送信されます。

|![ap12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/d305aa2e-e042-b082-1bf7-45ba162caf7c.png)|
|:-:|

同じ様にシステムURLから対象となる申請情報にアクセスします。

|![ap13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/dcf93fc8-c7b9-11c7-fb6a-62c1b3b51eef.png)|
|:-:|

ドキュメントや詳細が変更できるようになっています。
承認者が記載したコメントも確認できています。
また、ステータスは否認になっており、再申請ボタンも表示されています。

再申請ボタンを押してみましょう。

|![ap14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/53993452-ca72-c0ba-29b0-f7bd6b87d375.png)|
|:-:|

こういったタイトルでまた承認者にメールが送信されます。内容は初回申請時と同一です。
申請者にも初回と同じメールが送信されています。

また同じ様にURLから起動します。

|![ap15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/fefcc4eb-88db-0f39-78bf-fcb4ad6e1c05.png)|
|:-:|

今度は承認を押してみます。

|![ap16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/61733c74-6f2d-3d6a-ebd1-759cc29aeb69.png)|
|:-:|

申請者に、承認完了のメールが飛びます。

管理画面も見てみましょう。

|![ap17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/6f0a2975-1177-48c4-1c34-a778e9e4c3d3.png)|
|:-:|

このような感じで、情報を確認できます。
では、どのように作られているか解説していきたいと思います。

# 3 モデルを作る

削りに削った結果、モデルは**3つ**になりました。
そのうち一つはDirectoryモデルなので実質**2つ**です。

|![ap18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/8489b58c-7e43-bf91-653f-f3afa6ab59f8.png)|
|:-:|

Requests(申請/承認情報)とComments(コメント)だけです。DirectoryモデルはUserPickerを使おうと思ったので追加されています。
本家には、承認のステップを表現するためのWorkflowStageや、一つのステップに承認者を複数設定するためにApproverというモデルも用意されていますが、今回用意したアプリはそのあたりをざっくりと省いて、申請情報にすべて情報を乗せる形にしました。

一つの申請に対して、コメントは複数つけられるようになっています。これはリレーションで表現されています。

|![ap19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/09cde77c-44b5-434b-7345-1c04313da3a2.png)|
|:-:|

## Requestsモデル

Requestsモデルのフィールドは下記のようになっています。

|![ap20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/317f607e-598a-25b5-c6d6-aad1f993b116.png)|
|:-:|

それぞれRequiredをつけています。
StatusはPossible Valuesの設定を入れています。

|![ap21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/172f9c07-6996-a83b-9ccf-d2e7491a364b.png)|
|:-:|

デフォルトは未選択になるようにしています。こういう値を名前そのものでやるのは本来オススメできませんが、しっかり対応するとコードが増えるのでこうしています。

Requestsモデルのデータソースは下記3種用意しました。

|![ap22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/54b20809-57af-17f1-18fd-349ecbc7e3ed.png)|
|:-:|

それぞれ、用途は画面と対応しており、

- Requests : リクエスト一覧画面
- Approves : 承認待ち一覧画面
- AllRequestForAdmin : 管理画面

となっています。それぞれ見ていきます。

|<img width="238" alt="ap23.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/bf84dbb6-f879-92ec-6749-63ea91b8bb7f.png">|
|:-:|

リクエスト一覧は、「自分が作成した申請情報」が見れる画面なので、それを表現する条件を**Query Builder**に設定しています。
作成者を表す「OwnerEmail」と、自己定義した「CurrentUser」が一致しているレコードのみ取得するという設定です。
CurrentUserの中身は、**@user.email**とバインディング設定することで、ログイン中のユーザーを指す様にしています。

また、アプリの都合上、色々と読み込みタイミング等は制御したかったため、「Manual save mode」をONに、「Automatically load data」をOFFにしています。どちらもデフォルトの設定とは真逆です。

次にApprovesを見てみます。

|<img width="322" alt="ap24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/34c84fd8-34c0-b29d-febc-9ba683ccb862.png">|
|:-:|

承認待ち一覧は、「自分を承認者として指定している申請のうち、ステータスが未選択か再申請のもの」が見れる画面なので、同じ様にQuery Builder設定しています。ManualとAutomatiallyの設定もRequestsと同様です。

最後に、AllRequestForAdminですね。

|![ap25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/7d8542b4-aefa-5b4c-b0f2-78432c26f81f.png)|
|:-:|

こちらは、**Query script**で条件を指定しています。
これは管理者権限を持つもののみ情報を取得できるように制御したいため、このような書き方をしています。
(モデルの情報とは関係のない情報を条件にしたいので、Query Builderでは表現できないと思います)

`app.getActiveUserRoles()`がミソですね。利用しているユーザが持つ権限の一覧が取得できるので、それにAdminsが含まれているかどうかで、空配列を返すか、通常通りクエリの結果を返却するかを分けています。
このデータソースは単なるテーブルを置くだけの画面に利用するので、ManualとAutomaticallyの設定はデフォルトのままです。

RequestsモデルのEventですが、`onBeforeCreate`に下記スクリプトを設定しています。
`record.OwnerEmail = Session.getActiveUser().getEmail();`
まあ見たまんまですね。

RequestsモデルのSecurityはEveryoneとしています。

## Commentsモデル

|![ap26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/bb844798-6dba-a9d8-d20f-82cc99e9db3a.png)|
|:-:|

コメントはコメント本文と追加日時くらいですね。データソースはデフォルトのままです。Eventsは`onBeforeCreate`に

```javascript
record.AddedOn = new Date();
record.OwnerEmail = Session.getActiveUser().getEmail();
```

という感じです。SecurityはEveryone。

## Directoryモデル

・・・は単純に追加できるDirecoryモデルそのままですが、SecurityをEveryoneに変更しています。

# 4 ページを作る

お次はページを見ていきます。

|![ap27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/c89938fc-4db6-bc91-363c-af23453d5c4a.png)|![ap28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/534f0170-911d-c3b4-9827-7e91eadf2abc.png)|
|:---:|:---:|

画面が「Admin」「ApproveList」「RequestList」の3つ。
画面の上に展開されるポップアップも「AddRequest」「Request」「Approve」の3つ。
画面上のヘッダーが「Header」で左から出てくるナビゲーションメニューが「NavigationMenu」です。

## ヘッダー

|![ap29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/16ee7256-976d-1802-0496-bf2d50615483.png)|
|:-:|

Starter Appほぼそのままです。要らない検索とかアイコンを消したりしてます。

## ナビゲーション

|<img width="347" alt="ap30.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/df7d4ead-0805-55ef-b7f8-d31fc2cdbcd8.png">|
|:-:|

Starter Appから要らない部分を削り、管理画面用の選択肢を増やしています。その行のvisibleは`@user.role.Admins`を設定して管理者だけが見れるようにしています。

「リクエスト一覧」および「承認待ち一覧」を開く時のスクリプトに、それぞれ
`google.script.history.replace(null, "", null)`
を記述しています。
これは、メール本文からアプリの特定の画面、特定の情報を直接開くための工夫として、スクリプトにて

```javascript
"<div>システムURL : " + ScriptApp.getService().getUrl() + "?requestId=" + request.key + "#RequestList</div>";
```

という仕込みをしており、リクエスト一覧画面と承認待ち一覧画面では、これを活用するようになっています。
このURLでアクセスしてきた状態から、別画面に遷移(`app.showPage(app.pages.RequestList);`)しようとすると、`requestId=`の部分が残ってしまい、遷移先で誤動作するようになってしまったので、
`google.script.history.replace(null, "", null)`
によってパラメータ部分は消去されるように制御しています。

## 管理画面

簡単な管理画面から先に見ておきます。

|![ap31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/319328e8-01e3-a752-76bf-72b61470e429.png)|
|:-:|

データソースを「AllRequestForAdmin」としたテーブルを置いているだけです。

|![ap32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/6ea28a4b-541e-5f00-bb2e-15ae0cd38816.png)|
|:-:|

管理者専用なのでSecurity設定をAdmins Onlyにしています。

## リクエスト一覧

|![ap33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/953600f5-26ba-b404-bcbc-eb2b50fee81a.png)|
|:-:|

画面構成はシンプルで、テーブルとボタンです。ボタンはAddRequest画面を表示する設定になっています。
テーブルのデータソースは「Requests」なので、モデルに設定した通り、自身の申請情報のみ表示されます。

テーブル行のonClickには、`app.popups.Request.visible = true;`を設定しており、ポップアップで詳細画面が表示されます。
ゴミ箱アイコンは削除処理になりますが、Requestsモデルは「Manual save mode」としているので一工夫必要です。

```javascript
widget.datasource.deleteItem(function(){
  widget.datasource.saveChanges();
});
```
各種操作を確定するためのsaveChanges()の呼び出しが必要です。

また、このページのonAttachイベントでは、`startLoadingForRequestList()`という関数を呼び出しています。
こちらは、SCRIPTSの`RequestList`に下記のように定義しています。

```javascript
function startLoadingForRequestList() {
  google.script.url.getLocation(function(location) {
    var requestId = location.parameter.requestId;
    app.datasources.Requests.load(function(){
      if (requestId) {
        app.datasources.Requests.selectKey(requestId);
        var item = app.datasources.Requests.item;
        if (item) {
          app.popups.Request.visible = true;
        }
      }});
  });
}
```

ページを開いた時点でのURLをチェックした上でデータの読込を開始。
requestId=<key>が含まれていたら、そのデータを選択します。
さらに、選択されているデータをチェックし、ちゃんと存在していた場合は、Requestポップアップを表示するという動きです。

これによって、直接この画面を起動された時に、そのまま対象のデータを選択した状態で詳細ポップアップが開くという動きが実現できています。

## リクエスト追加

|![ap34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/230fc78a-b8f8-7790-7300-865d1ece0acd.png)|
|:-:|

追加画面はこの様な感じです。
空のポップアップを新規作成したあと、RequestsをデータソースとしたFormウィジェットをポンと配置。そのあと個別にウィジェットを置き直しています。

Drive Pickerは選択された結果を単純にバインドしているだけなので、特に工夫はありません。
オーナーアドレスは、今この画面を開いている人のアドレスで固定なので、オーナーアドレス用テキストボックスのウィジェットのonAttachで`widget.value = app.user.email;`を書いています。

承認者アドレスのところは、承認者になってほしい人のメールアドレスを打ち込んでもらうことを想定しています。そういう時に、サジェストを効かせたり、候補となったユーザの詳細情報を扱えるようにするためにUserPickerというウィジェットがあります。
とりあえずそれを置いておけば話が早いのですが、今回はUserPickerではなくSuggestBoxを使います。
詳細は割愛しますが、UserPickerのバリデーション(入力されたかどうかなどの反映)がちょっと良くない動きをします。どうしようかなと思っていたらStackOverflowに「バリデーションいい感じにかけたいならしゃあない、SuggestBox使ったらええよ」という投稿があったので乗ることにしました。

|![ap35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/03bd8bb7-3385-5fc2-f8b0-a6088bc9eed8.png)|
|:-:|

こういう感じの設定にしています。「Suggest Box Query Options」という独自のプロパティがあり、見たまんまですが、どのモデルのどのフィールドをサジェストの対象にする？という設定になります。ここではDirectoryモデルのPrimaryEmailを対象にしています。
また、その下に2つの選択肢があります。それも見たまんまですが、
「Use whole record as value」は、いざ候補から選択した時に値としてレコードを取得するという設定です。Directoryモデルならば、メールの他に名前などの情報もレコードとして持っているので、それが取得できるというわけです。
「User specified field as value」は、選択した列の値がそのまま取得できます。文字列なら文字列だけです。他の情報は手に入りません。

候補が選択されたタイミングで値を取得したいので、Suggest BoxのonValueChangeイベントで次のように記述します。

```javascript
if(newValue){
  widget.datasource.item.ApproverEmail = newValue;
}
```

こうすることで、選択された値がすぐにデータソースに設定されます。

また、この入力欄はあくまでサジェストするための枠として使いたいので、実際に設定された値はすぐ下のラベル入りパネルで表示するようにしています。


|![ap36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/1d617e2d-585a-17dc-d5fc-3762da90d8c9.png)|
|:-:|

この角丸でグレーになっている感じの表現は、本家テンプレートから拝借して設定してみています。

```css
.app-AddRequest-SelectedUserPanel {
  border-radius: 32px;
  background-color: #f6f6f6;
  margin: 4px 6px 5px 0px;
}
```

画面下部の「申請ボタン」は次のように設定しています。

**onclick**

`requestFunc(widget);`

SCRIPTSのほうで実際の関数は定義しています。後ほど解説します。

**enabled**

`(@datasource.item.ApproverEmail) && @widget.parent.parent.valid && (!@datasource.loading || !@datasource.creating || !@datasource.deleting)`

ボタンが有効になるタイミングをバインディング式で作り込んでいます。
`@widget.parent.parent.valid`
こいつのおかげで、フォームの各入力ウィジェットがvalidな状態かどうかをまとめて監視できています。
例えばTextBoxウィジェットにはData Validationなるプロパティがあり、必須かどうかを示す`required`プロパティはフォームを作成すると自動的に`@datasource.model.fields.Description.required`のようにフィールドの定義に合わせて設定されるようになっています。

`(!@datasource.loading || !@datasource.creating || !@datasource.deleting)`
これのおかげで、データソースで何かしらの処理を行っている最中か否かも監視できています。フォームを自動作成するとSUBMITボタンに最初から付いています。

`(@datasource.item.ApproverEmail)`
これだけ特別に追加しています。今回は値が決まったらダイレクトにデータソースに放り込んでいるので、ウィジェットではなくそちらを監視するようにしています。もともとはUserPickerでウィジェット自身のvalueプロパティを見るようにしたのですが、思った通りに反映されなかったという経緯があります。。

申請ボタン左の閉じるボタンの設定は下記です。

**onclick**

`closeAddRequestFunc(widget);`

これもSCRIPTSの方で定義しています。
enabledのほうも仕込みがありますが、データソースの処理中か否かの部分のみ適用しています。

## リクエスト詳細

|![ap37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/ec447aec-486e-1478-1149-048876949cf9.png)|
|:-:|

リクエストの詳細情報を見るための画面です。これもポップアップで作っています。

縦方向に3つのパネルでできています。
1つ目は単なるタイトルです。
2つ目は、DrivePickerとドキュメントのURLと詳細、それとコメント欄をつけています。
3つ目は、ステータスの状態と、可能であれば再申請のボタンが表示されるようにしています。

まず、DrivePicker・ドキュメントURL・詳細ですが、これはステータスによって編集できるか否かを制御したいので、enabledに下記設定をしています。
`@datasource.item.Status == "否認"`
タイトルとURLを入力/編集できるタイミングは全くの新規作成時か、差し戻しを受けた後に限定しています。
Draftなど、下書き状態を持てるように機能追加した場合は、そのステータスも対象になると思います。

コメント欄は、このリクエストに紐付いたコメントがリストで並ぶようにしています。コメントは、承認者が承認か否認を選択するタイミングで設定でき、かつ同じリクエストに対して複数回否認があったり、否認→承認の2回選択があったりと複数のコメントが入力される可能性があります。無理やりReqestsモデルのフィールドに入れられなくも無いですが、せっかくなのでキチンとモデルを分けています。

|![ap38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/5dbd4a99-48ad-04fa-0eb9-4e6d98bfc86f.png)|
|:-:|

Listウィジェットのデータソースの設定は「Requests: Comments (relation)」となっており、これは**リレーションデータソース**と言います。これは、RequestsとCommentsにはリレーションがはられていて、かつ**コメント表示用Listの上位パネルのデータソースをRequestsにしている**ので、**現在選択されているRequestにぶら下がるCommentsの一覧**に相当するデータを引っ張ってきてくれるデータソースということになります。
リレーションデータソースは謎感がありますが、こういう使い方の時にはイメージしやすいんじゃないでしょうか。

`@AddedOn`のところは、コメントの登録日時とバインディングしていますが、下記のように設定しています。
`@datasource.item.AddedOn#formatDate('yyyy/MM/dd HH:mm:ss')`
日付型フィールドの場合は、こんな感じでフォーマットを指定できます。

画面下部の「再申請ボタン」の設定は下記です。

**onclick**

`reapplicateFunc(widget);`
詳細はSCRIPTSで!

**enabled**

`@datasource.item.Status == "否認"`
再申請を押せるのはステータスが否認の時だけなので、そのように設定しています。

**visible**

`@datasource.item.Status == "否認" && !@datasource.loading`
visibleは見える見えないの設定ですね。enabledで押せるか否かも設定はしましたが、押せない時はそもそも表示されている必要すらないボタンでしょうということで、visibleもいじっています。また、処理中も押せないようにすべきですが、いっそのこと見えないようにしてみています。代わりにすぐ下にあるスピナーがローディング中は表示されるようにしています。
再申請ボタンのinvisibilityTypeはabsentにしていて、これは非表示時はレイアウトから構造上もいなくなる設定です。スピナーも同じにしているので、再申請ボタンを押した後はボタンの位置にスピナーが表示されるように見えます。エディタだとわからないので注意が必要です。

「閉じるボタン」の設定は下記です。

`closeRequestFunc(widget);`
詳細は(略

## 承認待ち一覧

|![ap39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/6c4dc5b1-f7be-75a0-7418-cdaac1ad3e0b.png)|
|:-:|

承認待ち一覧です。データソースに「Approves」を指定してテーブルを置いただけのシンプルな構成です。

ページ自体のonAttachでは`startLoadingForApproveList();`を設定しており、これはリクエスト一覧と同一の処理です。

```javascript
function startLoadingForApproveList() {
  //URLから処理対象のリクエストIDを取得する。
  google.script.url.getLocation(function(location) {
    var requestId = location.parameter.requestId;
    app.datasources.Approves.load(function(){
      if (requestId) {
        app.datasources.Approves.selectKey(requestId);
        var item = app.datasources.Approves.item;
        if (item) {
          app.popups.Approve.visible = true;
        }        
      }});
  });
}
```

テーブル行のonClickには`Show Approve (app.popups.Approve.visible = true;)`を設定してステータス選択画面がポップアップ表示されるようにしています。

## ステータス選択画面

|![ap40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/c137038f-94e5-188c-295e-6b6f80d5011c.png)|
|:-:|

最後にステータス選択画面です。ポップアップトップレベルのContentパネルのデータソースに、Approvesを選択した上で、タイトル、ドキュメントURL、詳細をそれぞれラベルウィジェットにバインディングしています。

特徴的なのは、コメントの部分です。
他の部分は現在選択されているApprovesのレコード内容を表示する形ですが、コメントはApprovesではなくCommentsモデルに保存が必要で、かつその後Approves(Requests)に紐付ける必要があるものです。

画面上どの様に設定しているかというと、下記のような感じになります。

|![ap41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/28c4d10a-75c1-acb5-cb8d-802ee8e1f197.png)|
|:-:|

「コメント」という表示用のラベルと、`@Text`が設定されているテキストボックスの親としてパネルを用意していますが、そこのデータソースを`Comments (create)`としています。これは作成データソースと呼ばれるもので、一時的にDBに反映させずにデータを保持しておけるデータソースで、`createItem`などの命令が実行されたタイミングで初めてデータベース(Cloud SQL)などに書き込みに行くものです。Formウィジェットを配置すると設定されるデータソースも実はこれになっています。

このように、部分的に新規作成しなければ行けない要素についても、データソースの設定をすることができます。データソースの設定をすることができるということは、バインディングの設定が可能になるということで、実際パネルの子要素のテキストをボックスでは、valueプロパティをいつものように`@datasource.item.Text`と設定しています。

では、実際の保存処理をどうしているかですが、それはボタンを押したタイミングに設定しています。

「承認ボタン」の設定値は下記です。

**onClick**

`approveFunc(widget);`
解説不要ですね。visible,enableはデータソースの処理状態の監視のみです。右横のスピナーも同様。

「否認ボタン」の設定値は下記です。

**onClick**

`rejectFunc(widget);`

「閉じるボタン」の設定値は下記です。

**onClick**

`closeFunc(widget);`

これで、大体画面系は網羅しました。レイアウトの設定値は特に紹介しませんでしたが、レイアウトはそれこそ自由なのでお気に入りの見た目づくりを楽しむと良いのでは無いでしょうか！

# 5 スクリプトをこしらえる

既に解説した部分はありますが、すっ飛ばした部分もありますのでまとめてここで紹介します。

まずスクリプトの一覧はこんな感じです。

|![ap42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26085/32945dee-8bac-16ed-070e-b3fe8757eeaa.png)|
|:-:|

クライアントスクリプトは管理しやすいように、画面名とまったく同じ名称で作成しています。
共通的に使う処理だけ**Common**に書いています。
ちなみにほんとに管理上の意味しかなく、関数はすべてグローバルに定義されます。
なので、うっかり同じ名前の関数をあちこちで書くと**爆死**します。(厳密な動作検証はしてませんが)

同じ名前の関数定義してるよ！みたいなエラーをいい感じに出してくれたりはしないので、最初からルールをキメて関数を書いていく必要があったりします。
(prototypeなどで頑張る事自体は可能ですが、それやりださないと困るレベルのコードは書かずに済む道を探ったほうがいいでしょう)

サーバースクリプトは一つだけ。メール配信の部分だけです。

それでは、まずは解説が終わっている部分から。

## ApproveList、RequestList

`startLoadingForApproveList()`と`startLoadingForRequestList()`です。内容的に同種で同じ関数名で書きたくなりますが爆死を避けるためにFor~と限定的にしています。本家でもこんな感じですね。

次に、Commonです。

## Common

```javascript
function createRequestObj(requestRecord) {
  return {
    "requester":requestRecord.OwnerEmail,
    "approver":requestRecord.ApproverEmail,
    "url":requestRecord.DocumentUrl,
    "key":requestRecord._key
  };
}
```

これだけです。requestRecordというのは、Requestsモデルのレコードオブジェクトを想定しており、それを必要最低限のパラメータしか持たないjsonに入れ替えているだけです。サーバー側にメールの送信内容を伝達するために用意しています。

そして、残りは各画面でボタンを押したときの処理になっていきます。

## AddRequest, Approve, Request

AddRequest

```javascript
function requestFunc(widget) {  
  widget.datasource.createItem(function(record){  
    app.datasources.Requests.saveChanges(function(){
      widget.root.visible = false;
      widget.root.descendants.SuggestBox1.value = null;
      google.script.run.sendRequestMailToRequester(createRequestObj(record));            
      google.script.run.sendRequestMailToApprover(createRequestObj(record));
    });    
  });
}

function closeAddRequestFunc(widget) {
  app.popups.AddRequest.visible = false;
  widget.root.descendants.SuggestBox1.value = null;
  widget.datasource.clearChanges();
}
```

**requestFunc**

リクエスト追加画面、なので入力されたデータを元に即`createItem()`をしています。ただ、**Manual save mode**にしているのでそれだけではDBに変更が反映されません。そこで、createItemが終わり次第、続けて`saveChanges()`を繰り出しています。
それが終わったらようやく後続の処理を進めます。
`widget.root.visible = false;`で画面(ポップアップ)を閉じます。色々な入力ウィジェットは、`createItem()`時点でクリアされるのですが、SuggestBox1だけはその制御下では無いので、`SuggestBox1.value = null;`で明示的に入力値をクリアしています。
`google.script.run.sendRequestMailToRequester(createRequestObj(record));`では、サーバースクリプトの`sendRequestMailToRequester()`関数を呼び出しています。commonで解説しましたが、`createRequestObj()`関数で、レコードオブジェクトをサーバースクリプトに必要なだけのjsonに作り変えています。
`google.script.run`でサーバースクリプトを呼び出す時、普通は成功時と失敗時をそれぞれ`withSuccessHandler()`と`withFailureHandler()`でハンドリングするのですが、簡単のために無視しています。メール送るだけですし。細かくやるなら、メール送信のQuotaのチェックなどを挟むなどでしょうか。
メールは申請者と承認者それぞれに送る仕様としているので、2回サーバースクリプトを呼び出しています。

**closeAddRequestFunc**

閉じるボタン押したときの処理ですね。visibleとSuggestBoxのクリアは同じですが、追加で`clearChanges()`も呼び出しています。フォームの入力値がクリアされます。

Approve

```javascript
function approveFunc(widget) {
  var request = widget.datasource.item;
  widget.root.properties.loading = true;
  app.datasources.Comments.createItem(function(record){
    request.Status = "承認";
    record.Request = request;
    widget.datasource.saveChanges(function(){
      widget.root.properties.loading = false;
      widget.root.visible = false;
      google.script.history.replace(null, "", null);
      app.datasources.Approves.load();
      google.script.run.sendApprovedMailToRequester(createRequestObj(request));      
    });          
  });
}

function rejectFunc(widget) {
  var request = widget.datasource.item;
  widget.root.properties.loading = true;
  app.datasources.Comments.createItem(function(record){
    request.Status = "否認";
    record.Request = request;
    widget.datasource.saveChanges(function(){
      widget.root.properties.loading = false;
      widget.root.visible = false;
      google.script.history.replace(null, "", null);
      app.datasources.Approves.load();
      google.script.run.sendRejectedMailToRequester(createRequestObj(request));            
    });
  });
}

function closeApproveFunc(widget) {
  app.popups.Approve.visible = false;
  google.script.history.replace(null, "", null);
}
```

**approveFunc**

承認時の処理です。
処理しやすいように、一旦Requestsのレコードを変数に保持しています。
また、解説してませんでしたが、ページのカスタムプロパティに処理中を示すloadingというプロパティを置いているので、それをtrueに切り替えます。
`app.datasources.Comments.createItem()`という形で、コメントモデルを直接指定してcreateItem()をしています。いつもなら`widget.datasource.createItem()`とするところですが、承認ボタンに紐づくデータソースはApproves(Requests)になってしまうので使えません。新規作成したいのはコメントデータです。
CommentsはManual save modeではないのでcreateItemが終わった時点でDBにデータはあります。それが完了したタイミングで、今度は元のリクエストデータのステータスを「承認」に変更。さらに`record.Request = request;`とやることで、**コメントデータに申請データを紐付け**ています。
リクエストデータにコメントを追加、と書きたくなるところですがそれだと上手くいかないので注意が必要です。
そして、Requests(Approves)はManual save modeなので、saveChangesを叩き込みます。
読込フラグを折り、画面を閉じ、とやっていきますが
`google.script.history.replace(null, "", null);`
も実行しています。これは先述したURLいじりなワケですが、ここでやっておかないとポップアップを閉じた後、ブラウザのリロードをされるとまたポップアップが表示されてしまいます。それを防ぐためにクリアしている形です。
`app.datasources.Approves.load();`
でApprovesのデータを読み直しています。ステータスに変更を入れているので、承認を押したレコードは表に表示されなくなります。あとはメールを送るだけです。

**rejectFunc**

もうちょっと共通化させて書けばよかったなと思うくらいapproveFuncと同一な処理です。ステータス変更部分とメール送信関数だけが違いますね。

**closeApproveFunc**

閉じる時にもURLはいじっておいてます。

Request

```javascript
function reapplicateFunc(widget) {
  var request = widget.datasource.item;
  request.Status = "再申請";
  widget.root.properties.loading = true;
  widget.datasource.saveChanges(function(){
    widget.root.properties.loading = false;
    widget.root.visible = false;
    google.script.history.replace(null, "", null);
    google.script.run.sendRequestMailToRequester(createRequestObj(request));
    google.script.run.sendReapplicationMailToApprover(createRequestObj(request));
  });
}

function closeRequestFunc(widget) {
  app.popups.Request.visible = false;
  google.script.history.replace(null, "", null);
}
```

**reapplicateFunc**

ステータスを変えてメールを送り直しています。
replaceの下りはApproveの時と同じ理由です。

**closeRequestFunc**

これもApproveの時と同じですね。

## Mail

サーバー側は、名前で想像できるようなメール送信処理をつらつらと記述しています。

```javascript
//メール送信
function sendEmail_(to, subject, body) {
  try {
    MailApp.sendEmail({
      to: to,
      subject: subject,
      htmlBody: body,
      noReply: true
    });
    return true;
  } catch (e) {
     console.error(JSON.stringify(e));
    return false;
  }
}

function sendRequestMailToRequester(request) {  
  var to = request.requester;
  var subject = "ワークフローシステムで承認依頼を送信しました。";
  var htmlBody = "<div>承認者の対応をお待ち下さい。</div>" + 
      "<div>承認者 : " + request.approver + "</div>" +
      "<div>対象のドキュメント : " + request.url + "</div>" + 
      "<div>システムURL : " + ScriptApp.getService().getUrl() + "?requestId=" + request.key + "#RequestList</div>";
  sendEmail_(to, subject, htmlBody);  
}

function sendRequestMailToApprover(request) {   
  var to = request.approver;
  var subject = "ワークフローシステムから承認依頼が届いています。";
  var htmlBody = "<div>ドキュメントをご確認のうえ、承認をお願い致します。</div>" + 
      "<div>申請者 : " + request.requester + "</div>" +
      "<div>対象のドキュメント : " + request.url + "</div>" + 
      "<div>システムURL : " + ScriptApp.getService().getUrl() + "?requestId=" + request.key + "#ApproveList</div>";
  sendEmail_(to, subject, htmlBody);  
}

function sendReapplicationMailToApprover(request) {   
  var to = request.approver;
  var subject = "ワークフローシステムから承認依頼が届いています。(再申請)";
  var htmlBody = "<div>ドキュメントをご確認のうえ、承認をお願い致します。</div>" + 
      "<div>申請者 : " + request.requester + "</div>" +
      "<div>対象のドキュメント : " + request.url + "</div>" + 
      "<div>システムURL : " + ScriptApp.getService().getUrl() + "?requestId=" + request.key + "#ApproveList</div>";
  sendEmail_(to, subject, htmlBody);  
}

function sendApprovedMailToRequester(request) {   
  var to = request.requester;
  var subject = "ワークフローシステムから承認通知が届いています。";
  var htmlBody = "<div>下記申請は承認されました。詳細をご確認ください。</div>" + 
      "<div>承認者 : " + request.approver + "</div>" +
      "<div>対象のドキュメント : " + request.url + "</div>" + 
      "<div>システムURL : " + ScriptApp.getService().getUrl() + "?requestId=" + request.key + "#RequestList</div>";
  sendEmail_(to, subject, htmlBody);  
}

function sendRejectedMailToRequester(request) {   
  var to = request.requester;
  var subject = "ワークフローシステムから否認通知が届いています。";
  var htmlBody = "<div>下記申請は否認されました。詳細をご確認ください。</div>" + 
      "<div>否認者 : " + request.approver + "</div>" +
      "<div>対象のドキュメント : " + request.url + "</div>" + 
      "<div>システムURL : " + ScriptApp.getService().getUrl() + "?requestId=" + request.key + "#RequestList</div>";
  sendEmail_(to, subject, htmlBody);  
}
```

ポイントは下記でしょうか。

- HTMLメール化して書式を整形している
- アプリのアクセス用URLを`ScriptApp.getService().getUrl()`で動的に取得している
- `sendEmail_`のように、最後にアンダーバーをつけて、クライアントスクリプトから呼び出し不可にしている (呼び出されないよというサインにもなる)
- noReplyオプションをtrueにして、送信者の考慮をしないで済むようにしている

# 7 おわりに

- めちゃめちゃ難しいテンプレートなんとかしたい
- AdventCalendarに作成手順乗せたら丁度いいんじゃないか

そんな想いを元にやりはじめましたが、AdventCalendar開始時点はちょっと違うアプリを想定していたので、いざ終わってみるとまとまりが無い感じになっちゃいました。~~しかも年内に終わらなかったし!~~
さらに言うとこの記事自体も長くなったものの駆け足で作っちゃったので、伝えたい要素が伝わっているか自信無しです。不明点リクエストもらえると嬉しいです。

とはいえ、App Makerでこうしたらこう動く、というTipsはそれなりに出せたような気がするので、これらを参考にいろいろいじる人が増えたら良いなあと思います。

以上です。良いApp Makerライフを！