Slackアプリで勤怠アプリを作ってみる①

# はじめに

この記事は 株式会社ピーアールオー（あったらいいな！を作ります） Advent Calendar 2019 の23日目の記事です。
前回[(Slackアプリのチュートリアルをやってみた)](https://qiita.com/kane-hiro/items/e42126e6f2acdf594f85)に続き、最終目標である勤怠アプリ作成のための練習として、ホーム画面とモーダルの表示をやっていこうと思います。

# できたもの
モーダルとアプリホームで、勤怠登録・集計をでやれてるっぽく動くSlackアプリ。
![キャプチャー3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359665/3ff06153-3f80-cbd6-1231-4e614bb10b1e.gif)

# やってみよう！

それではやってみようと思います！
今回以下の内容を実施していこうと思います。

- ホーム画面に勤怠登録用のボタンを作成
- モーダルを表示して勤怠を登録([node-json-db](https://www.npmjs.com/package/node-json-db))
- 登録内容をホーム画面に表示
- 集計結果をホーム画面に表示

最終的にはFirestoreに登録、AngularやReactなどのSPAで集計結果をパッと見えるような画面を作れればなーと思っています。
とりあえず今回は勤怠してるっぽく動くものを作ることを目標とします！

## 前提知識(使ったもの)(前回と同じ)
**[glitch](https://glitch.com/)**
無料でNodeなどのウェブアプリを公開できるサービスを利用しました。
色々な方が記事を書いていると思いますので、以下などを参考にしてください。
[ブラウザだけで完結するウェブアプリ作成環境 Glitch](https://laboradian.com/glitch/)

## モーダルのUIを作る

まずはモーダルUIを作成します！
Slackアプリは[Block Kit Builder](https://api.slack.com/tools/block-kit-builder)というUI作成ツールがあるので作るのは簡単です。
使い方はイジればわかると思うので省略します。
サイドバーで組み合わせたい要素を設定して、右側に表示されるJSONをコピペで完了です。

今回は以下で作成しようと思います。
※実際にはやりたいことができなかったので色々変更しました。できなかった理由は最後に書きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359665/75885123-f615-0d47-2d65-e4793298d6f1.png)

[Block Kit Builder URL](https://api.slack.com/tools/block-kit-builder?mode=modal&view=%7B%22type%22%3A%22modal%22%2C%22title%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22%E5%8B%A4%E6%80%A0%E5%A0%B1%E5%91%8A%22%2C%22emoji%22%3Atrue%7D%2C%22submit%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Submit%22%2C%22emoji%22%3Atrue%7D%2C%22close%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Cancel%22%2C%22emoji%22%3Atrue%7D%2C%22blocks%22%3A%5B%7B%22type%22%3A%22input%22%2C%22element%22%3A%7B%22type%22%3A%22datepicker%22%2C%22initial_date%22%3A%221990-04-28%22%2C%22placeholder%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Select%20a%20date%22%2C%22emoji%22%3Atrue%7D%7D%2C%22label%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22%E5%A0%B1%E5%91%8A%E6%97%A5%E6%99%82%22%2C%22emoji%22%3Atrue%7D%7D%2C%7B%22type%22%3A%22divider%22%7D%2C%7B%22type%22%3A%22input%22%2C%22label%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22%E6%A1%88%E4%BB%B6%E8%A8%AD%E5%AE%9A%22%2C%22emoji%22%3Atrue%7D%2C%22element%22%3A%7B%22type%22%3A%22static_select%22%2C%22placeholder%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Select%20an%20item%22%2C%22emoji%22%3Atrue%7D%2C%22options%22%3A%5B%7B%22text%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Strongly%20Agree%22%2C%22emoji%22%3Atrue%7D%2C%22value%22%3A%22value-0%22%7D%2C%7B%22text%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Agree%22%2C%22emoji%22%3Atrue%7D%2C%22value%22%3A%22value-1%22%7D%2C%7B%22text%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Neither%20agree%20nor%20disagree%22%2C%22emoji%22%3Atrue%7D%2C%22value%22%3A%22value-2%22%7D%2C%7B%22text%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Disagree%22%2C%22emoji%22%3Atrue%7D%2C%22value%22%3A%22value-3%22%7D%2C%7B%22text%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Strongly%20disagree%22%2C%22emoji%22%3Atrue%7D%2C%22value%22%3A%22value-4%22%7D%5D%7D%7D%2C%7B%22type%22%3A%22input%22%2C%22element%22%3A%7B%22type%22%3A%22plain_text_input%22%7D%2C%22label%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22%E4%BD%9C%E6%A5%AD%E6%99%82%E9%96%93%22%2C%22emoji%22%3Atrue%7D%7D%2C%7B%22type%22%3A%22input%22%2C%22label%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22%E4%BD%9C%E6%A5%AD%E5%86%85%E5%AE%B9%22%2C%22emoji%22%3Atrue%7D%2C%22element%22%3A%7B%22type%22%3A%22plain_text_input%22%2C%22multiline%22%3Atrue%7D%7D%2C%7B%22type%22%3A%22section%22%2C%22text%22%3A%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22%20%22%7D%2C%22accessory%22%3A%7B%22type%22%3A%22button%22%2C%22text%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22%E5%89%8D%E6%97%A5%E3%81%AE%E5%86%85%E5%AE%B9%E3%82%92%E3%82%B3%E3%83%94%E3%83%BC%E3%81%99%E3%82%8B%22%2C%22emoji%22%3Atrue%7D%2C%22value%22%3A%22click_me_123%22%7D%7D%2C%7B%22type%22%3A%22divider%22%7D%2C%7B%22type%22%3A%22actions%22%2C%22elements%22%3A%5B%7B%22type%22%3A%22button%22%2C%22text%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22%E6%A1%88%E4%BB%B6%E3%82%92%E8%BF%BD%E5%8A%A0%E3%81%99%E3%82%8B%22%2C%22emoji%22%3Atrue%7D%2C%22value%22%3A%22click_me_123%22%7D%5D%7D%2C%7B%22type%22%3A%22divider%22%7D%2C%7B%22type%22%3A%22input%22%2C%22label%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22%E3%81%9D%E3%81%AE%E4%BB%96%22%2C%22emoji%22%3Atrue%7D%2C%22element%22%3A%7B%22type%22%3A%22plain_text_input%22%2C%22multiline%22%3Atrue%7D%2C%22optional%22%3Atrue%7D%5D%7D)
## モーダルを呼び出して表示させる

モーダルを呼び出して表示させます。
呼び出しに関してはチュートリアルと同じです。
Home画面に設定したボタンが押された際に発行される、「action_id:add_data」を条件にモーダルを表示させます。

```javascript:ホーム画面ボタン要素部分
  let blocks = [
    {
      type: "section",
      text: {
        type: "mrkdwn",
        text: "*作業時間を登録!*"
      },
      accessory: {
        type: "button",
        action_id: "add_data",
        text: {
          type: "plain_text",
          text: "登録",
          emoji: true
        }
      }
    },
    {
      type: "divider"
    }
  ];
```

```javascript:add_data検知部分
app.post('/slack/actions', async(req, res) => {
  const { token, trigger_id, user, actions, type, container, view } = JSON.parse(req.body.payload);

  // ホーム画面 追加ボタン押し時
  if(actions && actions[0].action_id.match(/add_data/)) {
    modal.openModal(trigger_id);
  } 
}
```

modal.openModal(trigger_id)部分でUI用のJSON生成とリクエストを投げていきます。
SlackAPI「/views.open」にUI用のJSONを投げることでモーダルが表示されます。

モーダル関連に関しては「/views.open」の他に、更新や追加などもあるようです。

- views.open: モーダルの表示
- views.update: モーダルの更新
- views.push: モーダルの追加

詳細は以下を参考にしてください。
[Slack API 参照：ペイロードの表示](https://api.slack.com/reference/surfaces/views)
[Slack API 参照：Slackアプリでのモーダルの使用
](https://api.slack.com/surfaces/modals/using#updating_views)

```javascript:modal.openModal
const openModal = async trigger_id => {
  const args = {
    token: process.env.SLACK_BOT_TOKEN,
    trigger_id: trigger_id,
    view: JSON.stringify(createForm())
  };

  const result = await axios.post(`${apiUrl}/views.open`, qs.stringify(args));
  // console.log(result.data.view.id);
};
```

createForm()でモーダルのUI用のJSONを作成しています。

Block Kit Builderで作成したJSONをベースに作成していますが、日付の設定やオプションの値など別処理で整形しているので、細切れにして繋いでいます。
個人的なポイントとしては、**block_id**と**action_id**部分です。
どちらもBlock Kid Builderでは項目自体生成されません。

ですが、モーダルをSubmitした際に設定した値を取得するためのパスとして利用することになると思うので、必要だと思います。

<details><summary>ソース(BlockKitBuilderで作ったJSONを少し加工しているのみ&長いため折りたたみ)</summary><div>

```javascript:createForm()
const createForm = () => {
  let now = new Date();

  // タイプとタイトル
  var base = {
    type: "modal",
    title: {
      type: "plain_text",
      text: "Create a stickie note"
    },
    submit: {
      type: "plain_text",
      text: "Create"
    },
    blocks: []
  };

  // 報告日付部分
  base.blocks.push(
    {
      type: "input",
      block_id: "projDate",

      element: {
        type: "datepicker",
        action_id: "content",
        initial_date: now.toFormat("YYYY-MM-DD"),
        placeholder: {
          type: "plain_text",
          text: "Select a date",
          emoji: true
        }
      },
      label: {
        type: "plain_text",
        text: "報告日時",
        emoji: true
      }
    },
    {
      type: "divider"
    }
  );

  // 案件設定部分
  base.blocks.push(
    // 案件設定部分
    {
      type: "input",
      block_id: "projId",
      label: {
        type: "plain_text",
        text: "案件設定",
        emoji: true
      },
      element: {
        type: "static_select",
        action_id: "content",
        placeholder: {
          type: "plain_text",
          text: "Select an item",
          emoji: true
        },
        options: createProjOption()
      }
    },
    
    // 作業時間部分
    {
      type: "input",
      block_id: "projTime",
      label: {
        type: "plain_text",
        text: "作業時間",
        emoji: true
      },
      element: {
        type: "static_select",
        action_id: "content",
        placeholder: {
          type: "plain_text",
          text: "Select an item",
          emoji: true
        },
        options: createProjTime()
      }
    },

    // 作業内容部分
    {
      type: "input",
      block_id: "projText",
      label: {
        type: "plain_text",
        text: "作業内容",
        emoji: true
      },
      element: {
        type: "plain_text_input",
        action_id: "content",
        multiline: true
      },
      optional: true
    }
  );

  // その他部分
  base.blocks.push(
    {
      type: "input",
      block_id: "otherText",
      label: {
        type: "plain_text",
        text: "その他",
        emoji: true
      },
      element: {
        type: "plain_text_input",
        action_id: "content",
        multiline: true
      },
      optional: true
    }
  );

  return base;
};
```

</div></details>

## 少し動かしてみる

少し動かしてみます。
以下のようにモーダルが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359665/c3aca95b-b7f9-a9bd-8617-059a74415906.png)

要素ごとに必須バリデーションなどの設定ができるようなので便利です！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359665/9298e877-62b2-0ac2-c868-a183c9526dcc.png)

## Submitボタンを押してDBに保存する

次にSubmitボタン押し後の作成です。
前半部分は先ほどの追加ボタン押し時のソース。「view_submission」以降が登録時のソースです。
だいたいチュートリアルのソースと一緒です。

モーダルSubmit時「view_submission」タイプ送られてくるので判定します。
ペイロードにモーダルに設定された値が入っているので、取得します。

参照先のパスですが、私は「view.state.values」をconsole出力してちまちま探しました。


[SlackAPI 参照：相互作用ペイロード](https://api.slack.com/reference/interaction-payloads)
[SlackAPI 参照：イベントペイロードの表示](https://api.slack.com/reference/interaction-payloads/views#view_submission)


```javascript
app.post('/slack/actions', async(req, res) => {
  // console.log(JSON.parse(req.body.payload));

  const { token, trigger_id, user, actions, type, container, view } = JSON.parse(req.body.payload);

  // ホーム画面 追加ボタン押し時
  if(actions && actions[0].action_id.match(/add_data/)) {
    modal.openModal(trigger_id);
  } 
  
  else if(type === 'view_submission') {

    const ts = new Date();
    const { user, view } = JSON.parse(req.body.payload);
    
    // db登録用
    const data = {
      timestamp: ts.toLocaleString(),
      user : user.name,
      projDate : view.state.values.projDate.content.selected_date,
      projId : view.state.values.projId.content.selected_option.text.text,
      projTime : view.state.values.projTime.content.selected_option.text.text,
      projText : view.state.values.projText.content.value !== undefined ? view.state.values.projText.content.value:"",
      otherText : view.state.values.otherText.content.value !== undefined ? view.state.values.otherText.content.value :""

    }
    
    appHome.displayHome(user.id, data);
  }
});
```

先ほど整形したデータをdbに登録。
updateView(ホーム画面UI整形処理(DB値で表示内容変化))でホーム画面UIを作成し、ホーム画面を更新するよう(/views.publish)にリクエストを投げます。
[SlackAPI views.publish](https://api.slack.com/methods/views.publish)


```javascript
const displayHome = async (user, data) => {
  if (data) {
    // Store in a local DB
    db.push(`/data[]`, data, true);
  }

  const args = {
    token: process.env.SLACK_BOT_TOKEN,
    user_id: user,
    view: await updateView(user)
  };

  const result = await axios.post(
    `${apiUrl}/views.publish`,
    qs.stringify(args)
  );

  try {
    if (result.data.error) {
      console.log(result.data.error);
    }
  } catch (e) {
    console.log(e);
  }
};
```
## ホーム画面の作成

ホーム画面を作成していきます。
前述の「updateView(ホーム画面UI整形処理(DB値で表示内容変化))」部分です。

内容としては、画面表示用のUIをDBの値を参照して、表示する内容を制御しているのみになります。
特に新しいことは無いので省略します。

<details><summary>ソース(長いので折りたたみ)</summary><div>

```javascript:ホーム画面表示
const updateView = async user => {
  // Intro message -

  let blocks = [
    {
      type: "section",
      text: {
        type: "mrkdwn",
        text: "*作業時間を登録!*"
      },
      accessory: {
        type: "button",
        action_id: "add_data",
        text: {
          type: "plain_text",
          text: "登録",
          emoji: true
        }
      }
    },
    {
      type: "divider"
    }
  ];

  let newData = [];

  try {
    const rawData = db.getData(`/data/`);
    newData = rawData.slice().reverse();
  } catch (error) {
    //console.error(error);
  }

  var cnt = 0;

  var sumMap = new Map();
  let noteBlocks = [];

  if (newData) {
    for (const data of newData) {
      // 集計
      if (!sumMap.has(data.projId)) sumMap.set(data.projId, 0);
      sumMap.set(data.projId, sumMap.get(data.projId) + Number(data.projTime));

      // 表示件数を超過した場合はブロック表示しない
      if (cnt++ <= max) {
        // 登録履歴テキスト作成
        var text = "*" + data.projId + "* ";
        text += data.projTime + "h \n ";
        text += "作業内容: \n ";
        text += data.projText ? data.projText + "\n " : "";
        text += "その他: \n ";
        text += data.otherText ? data.otherText + "\n " : "";
        text += data.timestamp + " " + data.user;

        const note = [
          {
            type: "context",
            elements: [
              {
                type: "mrkdwn",
                text: text
              }
            ]
          },
          {
            type: "divider"
          }
        ];

        noteBlocks = noteBlocks.concat(note);
      }
    }
  }

  // 集計
  var sumText = "*集計結果* \n";
  var first = true;
  sumMap.forEach(function(value, key) {
    if (first) {
      first = false;
    } else {
      sumText += "\n";
    }
    sumText += "*" + key + "* : " + value + "h";
  });

  // 集計ブロック作成
  let sumBlocks = [
    {
      type: "context",
      elements: [
        {
          type: "mrkdwn",
          text: sumText
        }
      ]
    },
    {
      type: "divider"
    }
  ];

  // 集計、直近の登録順に設定
  blocks = blocks.concat(sumBlocks, noteBlocks);

  let view = {
    type: "home",
    title: {
      type: "plain_text",
      text: "regist data"
    },
    blocks: blocks
  };

  return JSON.stringify(view);
};
```

</div></details>

# 完成

ということで動かしていきます！

![キャプチャー3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359665/3ff06153-3f80-cbd6-1231-4e614bb10b1e.gif)

登録〜画面表示、集計まで上手く動きました！
ちなみに、別ユーザの勤怠もちゃんと集計されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359665/b30520fb-b01f-ac36-b979-eb047596c2f7.png)

# 感想(困ったこと)

**書き込み途中の内容ってどうやって引き継げばいいんだろう？**
私の技術力的な問題だと思いますが、モーダル内の入力欄に値を設定した状態でアクションを起こした時に、設定中の情報拾うことができなかったです。

具体的には、モーダルに勤怠記載途中に「プロジェクト追加ボタン」を押して「view.update」で入力欄を増やそうとしたのですが、アクション実行時のペイロードに設定中の情報が含まれていなかった(見逃しかもしれませんが)ので、更新後の画面に引き継ぐことができませんでした。
んー、今後色々調べて見ようと思います。

# ソース

ソースの全量です。興味のある方は参考にしてください。
※全く綺麗にしていないです！今後タイミングの良い時に綺麗にしてきます。(2019/12/25時点)
https://glitch.com/~admitted-inspiration

# 最後に

とりあえず勤怠アプリっぽく動かしてみました。
まだまだやることはありますが、今回はここまでです。

今後、以下をやっていく予定(たぶん)です。

- DB関連全般(DBをfirestoreとかに...)
- データ更新関係(同一ユーザ・日付・プロジェクトのデータに対して更新とかさせてないんですよね...)
- 集計関係(現状全ての期間の合算値を出しているので月単位とかに...)
- 現状、一度の登録で複数のプロジェクトを登録できないのでUIでなんとかする

初めGOの[tview](https://godoc.org/github.com/rivo/tview)でTUIでfirebaseのプロジェクト選択とかビルド環境ポチポチ選んでデプロイできるツール作りかけていたのですが、途中で「あれ？別にTUIの必要なくない？nodeでスクリプト作ればよくない？」とか思い始め、急に熱が冷めて記事を書き直したのでギリギリになりました。
アペンドカレンダー期間中に間に合ってよかったー。