GoogleAppsScriptを使って、Gmailで特定のラベルが付いたメールがあったら、Slackへ通知した上で既読にしてみた

最近Slackからの通知をトリガーに作業を行うことが多いため、「メールをトリガーに作業を行う」という習慣がなくなりつつあります。

ただ、Webサービスからのメールなど、メールでしか通知を受け取れないこともあります。特に、「不定期に連絡があるけれど、見落とすとちょっと大変なこと」がメールに含まれている場合は、うっかり見落とすと気まずいことになります。

対策として、Gmailのラベル機能を使って見落とさないように努力していますが、それでも残念ながらうっかりしてしまいます。

 
そこで、「GoogleAppsScriptを使って、Gmailで特定のラベルが付いたメールがあったら、Slackへ通知した上で既読にする」ものを作ってみました。

 
目次

 

環境

なお、Slackへ通知するためのBot userはすでに作成済とします。

そのBotには scope として chat:write を付与し、 xoxb 始まりのトークンも取得済とします。

 

仕様

  • 受診したメールには、Gmailのラベルが設定済
  • メールの確認は急ぎでは無いので、1日数回というタイミングで良い
  • メール本文はSlackには通知しない
    • GmailのURLだけ通知してもらい、そのURLを踏んでメールを確認する
    • 「メール本文を解読してSlackに連携する」事も考えたが色々手間なので、メールを直接見るような導線だけ用意する
  • Slackへの通知を行ったら、メール自体は既読にする
    • 再度Slack通知されてしまうことを防ぐため

 

Gmail上のメールを一意に識別する値について

Gmail上のメールを一意に識別する値として、GASでは以下の2つの値が取得できるようです。

 
今回のSlack通知は自分宛のみなため前者が実装できれば十分です。

ただ、他の人にもURLを共有できるようにしたいかもしれないので、両方の値を使った実装を試してみます。

 

実装

全体

全体はこんな感じです。これを一つの gs ファイルとして保存します。

const TARGET_LABEL = PropertiesService.getScriptProperties().getProperty("TARGET_LABEL")
const MAIL_FILTER = `label:${TARGET_LABEL} is:unread` // 対象ラベルかつ未読
const GMAIL_LABEL_URL = 'https://mail.google.com/mail/u/0/#label'
const GMAIL_MESSAGE_ID_URL = 'https://mail.google.com/mail/u/0/#search/rfc822msgid:'

// その他、Scriptのプロパティに以下を設定済という前提 (クラシックエディタから設定)
// TARGET_LABEL  検索対象のラベル
// SLACK_BOT_TOKEN  Slackのbot token
// SLACK_NOTIFICATION_TO  Slackの通知先

function main() {
  // 対象スレッドを取得
  const targetThreads = GmailApp.search(MAIL_FILTER, 0, 10)  // 最新より10件

  if (!targetThreads.length) {
    return
  }

  // 対象スレッドから、対象メールのURLを取得
  const urls = createUrlsFromThreadsByLabel(targetThreads)  // ラベルを使う場合
  // const urls = createUrlsFromThreadsByMessageId(targetThreads)  // Message-IDを使う場合

  // デバッグ用途として、実行ログに出力しておく
  console.log(urls)

  // Slackで通知
  const {responseStatusCode, responseBody} = notify(urls)

  if (responseStatusCode === 200 && responseBody.ok) {
    // 全部終わってからメールを既読にする
    targetThreads.map(t => t.markRead())
  } else {
    throw new Error(responseBody.error)
  }
}

function createUrlsFromThreadsByLabel(threads) {
  return threads
  .map(thread => thread.getMessages())
  .flat()  // messagesの配列を平坦化して扱いやすくする
  .map(message => {
    const mailId = message.getId()
    return `${GMAIL_LABEL_URL}/${TARGET_LABEL}/${mailId}`  // ラベルを使ってURL化
  })
}

function createUrlsFromThreadsByMessageId(threads) {
  return threads
  .map(thread => thread.getMessages())
  .flat()  // messagesの配列を平坦化して扱いやすくする
  .map(message => {
    const messageId = message.getHeader('Message-ID')
    return `${GMAIL_MESSAGE_ID_URL}${messageId}`  // Message-IDを使ってURL化
  })
}

function notify(urls) {
  const message = `
  メールが届きましたので、確認してください。

  ${urls.join('\n')}
  `

  const response = callWebApi("chat.postMessage", {
    channel: PropertiesService.getScriptProperties().getProperty("SLACK_NOTIFICATION_TO"),
    text: message
  })

  const responseStatusCode = response.getResponseCode()
  const responseBody = JSON.parse(response.getContentText())

  return {responseStatusCode, responseBody}
}

// 移植
// https://qiita.com/seratch/items/2158cb0abed5b8e12809
function callWebApi(apiMethod, payload) {
  const token = PropertiesService.getScriptProperties().getProperty("SLACK_BOT_TOKEN")
  return UrlFetchApp.fetch(
    `https://www.slack.com/api/${apiMethod}`,
    {
      method: "post",
      contentType: "application/x-www-form-urlencoded",
      headers: { "Authorization": `Bearer ${token}` },
      payload: payload,
    }
  )
}

 

個別の解説

全体のソースコードだけだと後で見返したときにわからなくなるため、個別に調べたことなどを記載します。

 

対象スレッドを取得する

GASの GmailApp.search() を使い、対象スレッドを検索します。

const TARGET_LABEL = PropertiesService.getScriptProperties().getProperty("TARGET_LABEL")
const MAIL_FILTER = `label:${TARGET_LABEL} is:unread` // 対象ラベルかつ未読

const targetThreads = GmailApp.search(MAIL_FILTER, 0, 10)  // 最新より10件

 
なお、検索で使える演算子については以下に記載がありました。
https://support.google.com/mail/answer/7190?hl=ja

今回はラベルが付いていて、かつ、未読のものを対象とするため、 label:${TARGET_LABEL} is:unread としています。

また、 search で複数条件のANDを取りたいため、条件をスペースで区切っています。
https://developers.google.com/apps-script/reference/gmail/gmail-app#searchquery

 

対象スレッドから各メールのURLを取得する

今回は

  • GmailMessage.getId() の戻り値を使うパターン
    • createUrlsFromThreadsByLabel()
  • GmailMessage.getHeader('Message-ID') の戻り値を使うパターン
    • createUrlsFromThreadsByMessageId()

の2つを、以下を参考にして実装しました。

 

GmailMessage.getId() の戻り値を使うパターン

メソッドチェーンを利用して取得しています。

const GMAIL_LABEL_URL = 'https://mail.google.com/mail/u/0/#label'

function createUrlsFromThreadsByLabel(threads) {
  return threads
  .map(thread => thread.getMessages())
  .flat()  // messagesの配列を平坦化して扱いやすくする
  .map(message => {
    const mailId = message.getId()
    return `${GMAIL_LABEL_URL}/${TARGET_LABEL}/${mailId}`  // ラベルを使ってURL化
  })
}

GASの GmailApp.search() の戻り値は GmailThread[] であることから、 GmailThread.getMessages () を使ってメールを配列で取得します。
https://developers.google.com/apps-script/reference/gmail/gmail-thread#getMessages()

二次元配列になるため、 flat() で平坦化したあと、 GmailMessage.getId() でメールIDを取得しています。
https://developers.google.com/apps-script/reference/gmail/gmail-message#getheadername

メールIDが取得できたら、ラベルを使ったURL化を行っています。

 

GmailMessage.getHeader('Message-ID') の戻り値を使うパターン

こちらも、全体の流れは前掲のパターンと同じです。

const GMAIL_MESSAGE_ID_URL = 'https://mail.google.com/mail/u/0/#search/rfc822msgid:'

function createUrlsFromThreadsByMessageId(threads) {
  return threads
  .map(thread => thread.getMessages())
  .flat()  // messagesの配列を平坦化して扱いやすくする
  .map(message => {
    const messageId = message.getHeader('Message-ID')
    return `${GMAIL_MESSAGE_ID_URL}${messageId}`  // Message-IDを使ってURL化
  })
}

異なる点としては

となります。

 

Slackでの通知

以下の記事を参考に、必要な部分を移植・修正しました。
Google Apps Script (GAS) で Slack 連携を実装する前に知っておくとよい 5 つのこと - Qiita

 

GASによるメールの既読処理

GmailThread.markRead() を使い、対象のスレッドを既読にします。
https://developers.google.com/apps-script/reference/gmail/gmail-thread#markRead()

targetThreads.map(t => t.markRead())

 

定期実行について

今回はGASのトリガー機能にて 日付ベースのタイマー で毎日 午前9時〜10時 に定期実行するよう登録しました。

今回は急ぎではないためこの程度のトリガーとしましたが、必要に応じてトリガー設定を調整します。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/slack_notification_from_mail

Dell Inspiron 3585 の Windows 10 が起動しなくなったので、初期化したときのメモ

ふと気づいたら、手元の Inspiron 3585 の Windows 10 が起動しなくなっていました。

そこで Inspironをリカバリしようとしたところ、いくつか悩んだことがあったため、メモを残します。

 
目次

 

環境

 

症状

  • 電源を入れると、Dellのロゴは出るものの、ローディングから進まない
  • しばらく放置すると、PC自体が再起動 → ローディング → 再起動 ... と無限ループする

 

対応

スタートアップ修復を試す → NG

Dellリカバリメディアが手元にあったので、まずはスタートアップ修復を試しました。
Windows 10でのスタートアップ修復方法 (リカバリディスク使用) - Dell Community

F12 キーを押したまま電源を入れ、メディアからブートした後、上記記事に従いスタートアップ修復を行いました。

しばらくすると Dell ロゴが出てローディング中の表示になりました。スタートアップ修復は時間がかかるかもしれないとのことだったので、しばらく放置することにしました。

ただ、2日ほど放置してもローディング中のままでした。スタートアップ修復はできなかったようです。

 

Windows 10 の再インストール → OK

次に、リカバリメディアを使って、Windows 10 を再インストールすることにしました。
デル製コンピュータ用のWindows 10リカバリメディアの作成 | Dell 日本

 
Windows 10 を再インストールしていたところ、途中でTPMをクリアするかどうかを聞かれました。特に使ってないとは思いますが、念のためTPMはクリアせずそのままにしました。

その後、しばらく待つとWindows 10 の初期設定になりました。

初期設定時に Microsoft アカウントは作成したくなかったため、この段階で物理的なネットワーク接続をすべて切っておきました。

その状態で初期設定を進めると、ローカルアカウントを作成する画面になりました。これで良さそうだったため、ローカルアカウントを作成して初期設定を終えました。

 

Windows Update の途中で AMD RAID driver のエラー

引き続きWindows 10 の Windows Update を行っていたところ、以下のエラーで Windows 10 の 20H2 を入れることができませんでした。

AMD Ryzen™ or AMD Ryzen™ Threadripper™ configured in SATA or NVMe RAID mode. A driver is installed that causes stability problems on Windows. This driver will be disabled. Check with your software/driver provider for an updated version that runs on this version of Windows.

 
調べてみたところ、MicrosoftDell のサイトに情報がありました。

 
そこで、 AMDのサイトから AMD RAID Installer (SATA, NVMe RAID) をダウンロード・インストールしました。

なお、現在の AMD RAID Installer (SATA, NVMe RAID) のバージョンは 2.20.19.037 でした。記事中のバージョンとは異なりましたが、ダウンロード後に実行したところ AMD RAID Driver (SATA, NVMe RAID)9.3.0.00296 をインストールできました。

インストール後に Inspiron を再起動したところ、Windows Update を最後まで実行できました。

 
リカバリ後、しばらく Windows 10 を使ってみましたが、特に問題なさそうでした。

 

その他の設定

キーボード設定

リカバリ後のWindows10でキーを入力していたところ、英語キーボードの設定となっていることに気づきました。

そこで、以下を参考にキーボード設定を変更するため

時刻と言語 > 言語 > 下の方にある 日本語 をクリック > オプション > レイアウトを変更する > 日本語キーボード(106/109)

としたところ、日本語キーボードでの操作が可能になりました。
Windows 10 キーボードの設定「英語、日本語、その他の言語のキーボードの追加」-パソブル

 

出力デバイスの設定

リカバリ後のWindows10で音が出なくなっていることに気づきました。

サウンド出力デバイス を確認すると、 なし になっていました。スピーカーが認識されていないようです。

そこで、以下のMicrosoftのサイトを参考に作業を進めてみました。 Windows 10 のサウンドの問題の解決

オーディオデバイスが見つからないため、「5.サウンド設定を確認する」まで進めても特に何も変化はありませんでした。

次に、「6. オーディオドライバーを修正する」より、Dellのサイトでサウンドに関するドライバをダウンロード・インストールしたものの改善しませんでした。
ドライバおよびダウンロード | Dell 日本

コンピュータの管理からデバイスマネージャーからオーディオを見ると、 AMD High Definition Audio Device に警告マークが出ていました。

そこで、上記のMicrosoftのサイトに従い、 デバイスのアインインストール から このデバイスのドライバー ソフトウェアを削除する にチェックを入れてアンインストールを行いました。

アンインストール後に再起動したところ、出力デバイスとして スピーカー/ヘッドホン (Realtek Audio) が表示されました。

Youtubeを開き、音が出ることも確認できました。

Rails + OpenAPI なAPIにて、レスポンスボディがない場合、committee の assert_response_schema_confirm を使うとエラーになる

Rails + OpenAPI で作られているAPIのテストコードを書く時、

を使うことで、OpenAPIスキーマのテストもしやすいので便利です。

 
ただ、レスポンスボディがないAPIの場合、committee の assert_response_schema_confirm() でエラーになったため、メモを残します。

 
目次

 

環境

 

作成したAPI

rails new rails_committee_app --api のようにAPIモードで作成したAPIアプリに対し、こんな感じのRails Controllerがあったとします。

# app/controllers/fruits_controller.rb

class FruitsController < ApplicationController
  def index
    render json: { fruits: [{ name: 'りんご' }, { name: 'みかん' }]}
  end

  def create
    # 処理したつもり

    head :created
  end
end

このControllerのレスポンスは

  • index メソッドの場合、fruits のリストが返る
  • create メソッドの場合、何も返さない

となります。

 
また、ルーティングのところで、Controllerの

  • index メソッドはGET
  • create はPOST

にそれぞれ対応させているとします。

# confing/routes.rb

Rails.application.routes.draw do
  resources :fruits, only: %i[index create]
end

 
 
また、OpenAPIスキーマ

openapi: 3.0.0
info:
  title: Rails API with committee
  description: committee を使ったRails APIです
  version: 0.0.1
servers:
  - url: http://localhost:7401
    description: development
tags:
  - name: fruits
    description: 果物
components:
  schemas:
    Fruits:
      type: object
      properties:
        fruits:
          type: array
          items:
            $ref: '#/components/schemas/Fruit'
    Fruit:
      type: object
      properties:
        name:
          description: 名前
          type: string
          example: りんご
paths:
  /fruits:
    get:
      summary: 果物一覧
      tags:
        - fruit
      responses:
        '200':
          description: 取得成功
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Fruits'
    post:
      summary: 果物の登録
      tags:
        - fruit
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Fruit'
        required: true
      responses:
        '201':
          description: 登録成功

だったとします。

 
curlで動作確認をすると、GETの場合は

% curl -X 'GET' -H  "Content-Type: application/json" 'http://localhost:7401/fruits/'
{"fruits":[{"name":"りんご"},{"name":"みかん"}]

 
POSTの場合は

% curl -H  "Content-Type: application/json"  -d '{"name": "バナナ"}' 'http://localhost:7401/fruits' -v 
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 7401 (#0)
> POST /fruits HTTP/1.1
> Host: localhost:7401
> User-Agent: curl/7.64.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 21
> 
* upload completely sent off: 21 out of 21 bytes
< HTTP/1.1 201 Created

と、GETとPOSTとも正常に動作しています。

 

テストコード

次に、このAPIに対してテストを書くことにしました。

 

エラーとなる例

rspecにて

require 'rails_helper'

RSpec.describe 'FruitsController', type: :request do
  let(:request_headers) { { 'Content-Type' => 'application/json', 'accept' => 'application/json' } }

  describe '#index' do
    it '200' do
      get "/fruits", headers: request_headers

      assert_request_schema_confirm
      assert_response_schema_confirm(200)
    end
  end

  describe '#create' do
    let(:request_params) { { 'name': "apple" }.to_json }

    it 'error by assert_response_schema_confirm' do
      post '/fruits', params: request_params, headers: request_headers

      assert_request_schema_confirm
      assert_response_schema_confirm(201)
    end
  end
end

というテストコードを書きました。

次にテストを実行したところ、 it 'error by assert_response_schema_confirm' のテストケースで

Committee::InvalidResponse: #/paths/~1fruits/post/responses/201 response definition does not exist

というエラーが発生し、テストが失敗しました。

 

対応例

OpenAPIのスキーマについて、POSTのところを見ると、

paths:
  /fruits:
    post:
...
      responses:
        '201':
          description: 登録成功

Controllerでは何もレスポンスを返さないため、OpenAPIのスキーマ的には正しいです。

ただ、 committee の assert_response_schema_confirm は「OpenAPIのスキーマにHTTPレスポンスボディが存在する時に使う」ことを前提にしているのかもしれません。

 
そこで、そもそもPOSTの場合はレスポンスボディの検証は不要なことから

it '201' do
  post '/fruits', params: request_params, headers: request_headers

  assert_request_schema_confirm

  # HTTPステータスコードを確認するだけ
  expect(response.status).to eq 201
end

HTTPステータスコードだけを確認するようにしたところ、テストがパスしました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_committee-sample

書籍レビュアーとして参加した、 #Python実践レシピ が発売になります

書籍レビュアーとして参加した「Python実践レシピ」が2022/01/13 (電子本)・2022/01/19 (物理本) に発売されます。
Pythonエンジニア育成推進協会監修 Python実践レシピ:書籍案内|技術評論社

 
先日、発売前の書籍をご恵贈いただきました。ありがとうございます。

(左) 今回の書籍、 (右) 改訂前の書影

 
そこで、この記事では書籍の紹介をしていきたいと思います。

 
目次

 

書籍について

想定読者

技術評論社のWebサイトでは「こんな方におすすめ」として

  • Pythonでプログラミングしている方
  • Pythonの基本的な文法を学習して,実際にプログラミングを行いたい方

を挙げています。

たしかに、プログラムやPythonに初めてさわるという方向けには書かれていないため、いきなり読んでも「よく分からない」となってしまいそうです。

 
一方、技術評論社のWebサイトでおすすめとされている方、例えば

  • 一度Pythonを書いてみて「Pythonをより使いこなしたい」と感じている方
  • 別の言語をさわっており「Pythonは batteries included と言われるが、ライブラリにはどんなものがあるか知りたい」と考えている方

であれば、よく目にする標準ライブラリやサードパーティライブラリが記載されているため、より深く知りたいという時のはじめの一歩として役に立つ場面は多そうです。

そういえば自分も、「Pythonでアプリケーションを書いてみたけど、Pythonでのメジャーなライブラリをより詳しく知りたい」と思ったときに、改訂前の書籍を手にとったことを思い出しました。

 
また、紹介されている標準ライブラリやサードパーティの数が多いため、Pythonをよく使っている方であっても、紹介されているライブラリやその使い方について新しい発見があると思います。

自分の場合は

19.1 イベントループでの非同期処理―asyncio

について、理解を深めることができました。

書籍では標準の asyncio ライブラリの他、それを取り巻くライブラリにも触れられていることから、「 asyncio を理解したら次は何をおさえたら良いか」が分かりやすかったです。

また、非同期処理(並行・並列処理)に関するイメージイラストが付いているため、頭の中に各処理の特徴を浮かべつつ本文を読み進めることができました。

 

内容

こちらも技術評論社のWebページより引用します。

Pythonでプログラムを作成するときに役立つ機能とライブラリを網羅した,実践的なレシピ集です。

 
取り上げられているライブラリは、標準モジュールのうちよく使うものおよび標準モジュールをカバーするようなものです。昔からあるメジャーなものに加え、Python3.9から追加されたIANAタイムゾーンデータベースを扱う zoneinfo があるなど、幅広く収録されています。

各ライブラリについて、どのようなライブラリかという解説からコマンドラインでの使い方まで記載されているため、「ライブラリについてどんな機能があるかをざっくりつかみたい」という時に辞書的に使えそうです。

また、各ライブラリのページには公式ドキュメントへのリンクがあります。そのため、「もう少し詳しく知りたいので公式ドキュメントを読みたいけどどこにあるんだっけ」となった時でも、記載されているリンク先を読めば解決します。

 
とはいえ、必要になった時に辞書的に使うChapterばかりではなく、

Chapter1 Pythonの環境

Chapter2 コーディング規約

あたりは、最初に読んでおいても良さそうだと感じました。

というのも

などの場合に、Pythonの環境を作りつつコーディング規約に従って書くことができる手助けになるためです。

 
他にも、似たようなライブラリ、例えば

  • urllib.requestRequest
  • unittestpytest

について、それらがどのように違うのかを比較することにも使えそうです。

 
自分の場合、テストコードでモックの作り方を忘れてしまうことがあるため、

16.3 モックを利用してユニットテストを行う―unittest.mock

にて

  • モックオブジェクトの作り方
  • patch関数の使い方
    • デコレータであったり、コンテキストマネージャであったり

などがまとまっているのでありがたいです。

 
以上が、書籍「Python実践レシピ」についての紹介です。

 

レビューについて

今回、初めてレビュアーになったため、その様子を残しておきます。

レビュアー参加のきっかけは @JunyaFff さんからのお誘いでした。ありがとうございました。

冒頭の画像にある改訂前の書籍について、自分がPythonをさわりたての頃にお世話になったため、レビュアーとして参加できることを嬉しく思いました。

 
ただ、初めてのレビュアーとなるため、どのように進めれば良いか不安でした。

そこで

あたりを読んでみました。

 
実際のレビュー期間に入ると、初めてのレビュアーにとってサポートが充実しているような体制に感じました。

例えば、レビュアー向けに資料が配布された上で説明会が開催されました。参加することでレビュアーとしてやること・やらないことが明確になりました。

また、レビューを進める上で気になったことはSlackで相談できたり、「どこをいつまでに」が明確であるなどもありがたかったです。

 
加えて、自分のレビューのほかに他の方のレビュー内容も同時に読めたこともありがたかったです。

経験豊富な方のレビューを読むことでレビューのやり方を学びつつ、書籍とは関係ない自分の文章も分かりやすくなっているか振り返る良い機会になりました。

 
そんな感じなのでうまくレビュアーがつとまったかどうかは分かりませんが、

  • ライブラリのメジャーバージョンアップをしたら、動作しなくなったソースコードを見つけたこと
    • 締め切り近いのに、著者の方が対応してくださり、ありがとうございました
  • レビュアーとして経験豊富な方から、「気になるポイントが似ている気がする」と言われたこと

などがあったことから、最低限の役割は果たせたかなと思いました。

貴重な機会をいただき、ありがとうございました。

 

最後に

この書籍を通して、Pythonの標準モジュールやサードパーティモジュールに対する理解が深まれば幸いです。

どうぞよろしくお願いします!

2021年の振り返りと2022年の目標

例年通り、2021年の振り返りと2022年の目標っぽいものを書いてみます。

 

2021年の振り返り

2020年の振り返りと2021年の目標 - メモ的な思考的な で立てた目標を振り返ってみます。

 

新しい環境に溶け込む

2021年の新年から新しい環境でした。一度も物理出社することなく、1年間論理出社で過ごしました。

論理出社のみとはいえ、本人的には文化的・技術的にもコンフリクトを起こすことなく、働きやすい中で過ごせました。

 
文化的なことのうち一番良いと感じたのは、「そもそも」「〜とは」などで物事の背景や言葉の定義の認識合わせを丁寧に行っていることでした。

また、基本的には何事もオープンなため、余計なことを気にせず進められたりします。

あとは

  • ソースコードレビューでは、指摘の背景や理由などがしっかり説明される
  • 論理的に説明できていないと、「わからない」など適切にツッコミが入る

あたりも働きやすく感じています。

他にもあるような気もしますが、当たり前になっているのでうまく言語化できないだけかもしれません。。。

 
技術的なところでは、2021年は

をメインに書いていました。

いずれも入社前は実戦でほとんどさわっていない技術でしたが、

  • 学ぶのに良い環境だったこと
    • 社内に見本となるソースコードが存在する
    • わからないところは質問できる
  • 各技術に詳しい同僚たちを目にして「自分が一番下手なのは明らかなので、あとは上達するだけだ」とある意味開き直れたこと
  • 他にも色々配慮していただいたこと

によりだいぶキャッチアップできました。ありがたい限りです。

 

レーニングの継続と筋トレの復活

お盆あたりにぎっくり腰になってしまったこともあり、一年間トレーニングを継続できなかったのが残念でした。

ぎっくり腰から回復してから4ヶ月くらい経過しましたが、日常生活を送れるようになりました。

とはいえ、重いものを持つと腰に張る感覚があるので、まだまだ無理できません。

 
そんな中でも、ドラクエウォークは継続していました。今年も360万歩くらい歩いていたようです。

その他

イベント

記録を残せていませんでしたが、「興味はあるけど、用語を知ってるレベル」の技術に関するオンライン勉強会に参加していました。

 

Github

素振りが多かったこともあり、2020年に比べて増えてますが、まだまだですね。。。

 

2022年の目標っぽいもの

2021年の結果を元に、2022年は

  • 身近な技術の素振り・深堀りをして、技術のキャッチアップを継続
  • 腰痛を再び起こさないよう、ストレッチや筋トレの継続

を目標っぽいものにします。

 
というところで、今年もよろしくお願いします。

React + React Hook Form v7 なフォームに、MUI v5 の DateTime Picker を組み込んでみた

React Hook Form に MUI の DateTime Picker を組み込んだところ、いくつか悩んだことがあったため、メモを残します。

 
目次

 

環境

  • React 17.0.2
  • React Router 6.2.1
  • @mui/material 5.2.4
  • @mui/lab 5.0.0-alpha.60
  • date-fns 2.27.0
  • react-hook-form 7.22.2

 
前回の記事の環境に対し、 yarn upgrade-interactive にて今回使いそうなライブラリのバージョンを上げています。

 
2022/07/18 追記 ここから

2022/07/18時点の最新パッケージを使う場合、 import するパッケージや locale に変更が入っています。こちらにまとめていますので、よろしければご確認ください。
React17 + MUI DateTimePicker + React Hook Form なアプリを yarn upgrade --latest したら破壊的変更が入っていたので修正した - メモ的な思考的な

2022/07/18 追記 ここまで

 

作るもの

日時が表示されています。

EDIT DATETIME ボタンをクリックすると、モーダル に MUIのDateTime Picker が表示されます。

 
内容を変更します。

 
保存するとモーダルが閉じ、最初の画面の日時が更新されます。

 

実装の流れ

MUI で Modal を作る

MUI の Modal のサンプルコードを見ながら、 Modal を作ってみます。
React Modal component - MUI

サンプルコードと異なり、 style を適用せず Modal を表示してみます。

MuiModal.tsx

import {Button, Modal} from '@mui/material'
import {useState} from 'react'

const Component = (): JSX.Element => {
  const [open, setOpen] = useState(false)
  const handleOpen = () => setOpen(true)
  const handleClose = () => setOpen(false)

  return (
    <>
      <Button onClick={handleOpen} variant="contained">
        Open
      </Button>

      <Modal open={open} onClose={handleClose}>
        <h2>hello</h2>
      </Modal>
    </>
  )
}
export default Component

 
OPENボタンをクリックすると、ボタンとモーダルが重なった形で表示されました。また、モーダルエリアの色がなく、分かりづらいです。

 
Boxコンポーネントsx prop を使って、サンプルコード通りのスタイルを当ててみます。
React Box component - MUI

MuiModalWithStyle.tsx

import {Box, Button, Modal} from '@mui/material'
import {useState} from 'react'

const style = {
  position: 'absolute' as 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '2px solid #000',
  boxShadow: 24,
  p: 4
}

const Component = (): JSX.Element => {
  const [open, setOpen] = useState(false)
  const handleOpen = () => setOpen(true)
  const handleClose = () => setOpen(false)

  return (
    <>
      <Button onClick={handleOpen} variant="contained">
        Open
      </Button>

      <Modal open={open} onClose={handleClose}>
        <Box sx={style}>
          <h2>hello</h2>
        </Box>
      </Modal>
    </>
  )
}
export default Component

 
モーダルの位置が調整され、モーダルエリアも分かりやすくなりました。

 

React Hook Form を使ってフォームを作る

続いて、 React Hook Form を使ってフォームを作ります。
Home | React Hook Form - Simple React forms validation

今回は Get Started に従って作ります。
Get Started | React Hook Form - Simple React forms validation

まずは、 useForm hook にて registerhandleSubmit メソッドを使えるようにします。

const {register, handleSubmit} = useForm()

 
次に

  • formに handleSubmit(onSubmit) を追加
  • .....register('name') にて、 name という名前の Input を追加

コンポーネントに追加します。

<form onSubmit={handleSubmit(onSubmit)}>
  <input {...register('name')} />
  <input type="submit" />
</form>

 
最後に、 handleSubmit に渡す onSubmit を定義します。

今回はコンソールにデータを出力するのみとします。

なお、onSubmit で型指定が必要になるため、input name と型を指定します。

type FormInput = {
  name: string
}

const Component = (): JSX.Element => {
// ...
  const onSubmit: SubmitHandler<FormInput> = (data) => console.log(data)
  return (
// ...

 
全体像はこんな感じです。

MuiModalWithReactHookForm.tsx

import {Box, Button, Modal} from '@mui/material'
import {useState} from 'react'
import {SubmitHandler, useForm} from 'react-hook-form'

const style = {
  position: 'absolute' as 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '2px solid #000',
  boxShadow: 24,
  p: 4
}

type FormInput = {
  name: string
}

const Component = (): JSX.Element => {
  const [open, setOpen] = useState(false)
  const handleOpen = () => setOpen(true)
  const handleClose = () => setOpen(false)

  const {register, handleSubmit} = useForm()
  const onSubmit: SubmitHandler<FormInput> = (data) => console.log(data)

  return (
    <>
      <Button onClick={handleOpen} variant="contained">
        Open
      </Button>

      <Modal open={open} onClose={handleClose}>
        <Box sx={style}>
          <form onSubmit={handleSubmit(onSubmit)}>
            <input {...register('name')} />
            <input type="submit" />
          </form>
        </Box>
      </Modal>
    </>
  )
}
export default Component

 
OPENボタンをクリックし、値を入力後に送信ボタンをクリックすると、コンソールに入力値が表示されました。

 

MUI DateTime Picker と組み合わせる

セットアップ

続いて、React Hook Form を MUI の DateTime Picker と組み合わせて使ってみます。
React Date Time Picker component - MUI

なお、DateTime Picker は @mui/lab のインストールが必要です。
About the lab - MUI

% yarn add @mui/lab

 
他にも日付を処理するライブラリが必要になります。

今回は以前使用した date-fns を使います。

 

先ほどの React Hook Form では、<input {...register('name')} /> のように、 uncontrolled component を hook に登録していました。

 
ただ、MUI などの UI コンポーネントライブラリを使いたい場合は、Controller コンポーネントを使うことで React Hook Form に組み込めます。

 

まずは、Modal の中に LocalizationProviderController を配置してみます。

Controller コンポーネントについては

  • name
    • input を識別するためのユニークな名前
  • defaultValue
    • DateTime Picker のデフォルト値
  • render

を指定します。

LocalizationProviderInside.tsx

<Modal open={open} onClose={handleClose}>
  <LocalizationProvider dateAdapter={AdapterDateFns}>
    <Box sx={style}>
      <form onSubmit={handleSubmit(onSubmit)}>
        <Controller
          name="inputValue"
          control={control}
          defaultValue={new Date()}
          render={({field}) => {
            return (
              <DateTimePicker
                {...field}
                label="input"
                renderInput={(props) => <TextField {...props} />}
                onChange={(newValue) => {
                  setValue('inputValue', newValue)
                }}
              />
            )
          }}
        />
      </form>
    </Box>
  </LocalizationProvider>
</Modal>

 
画面には MUI DataTime Picker が表示できたものの、Warning も出ています。

Warning: Failed prop type: Invalid prop children supplied to Unstable_TrapFocus. Expected an element that can hold a ref. Did you accidentally use a plain function component for an element instead? For more information see https://mui.com/r/caveat-with-refs-guide

 
そのため、 Modal の外に LocalizationProvider を、 Modal の中に Controller を配置することで、Warning が出なくなります。

LocalizationProviderOutside.tsx

<Modal open={open} onClose={handleClose}>
  <LocalizationProvider dateAdapter={AdapterDateFns}>
    <Box sx={style}>
      <form onSubmit={handleSubmit(onSubmit)}>
        <Controller
        />
      </form>
    </Box>
  </LocalizationProvider>
</Modal>

 

Localization のために mask と inputFormat を指定

DateTime Picker の Localization は、 LocalizationProvider に locale を渡せばよいです。
Localization - React Date Picker component - MUI

ただ、

<LocalizationProvider dateAdapter={AdapterDateFns} locale={ja}>

と指定しただけでは、画面は正しく表示されるものの、Warning が出ます (例: LocalizationProviderOutsideWithLocale.tsx)。

The mask "// :" you passed is not valid for the format used P HH:mm. Falling down to uncontrolled not-masked input.

 
そこで、Controller の中にある DateTime Picker に mask を追加しますが、それでもまだ同じエラーが出ます。

LocalizationProviderOutsideWithLocaleMask.tsx

<Controller
  render={({field}) => {
    return (
      <DateTimePicker
        {...field}
        label="input"
        mask="____/__/__ __:__:__"
        renderInput={(props) => <TextField {...props} />}
        onChange={(newValue) => {
          setValue('inputValue', newValue)
        }}
      />
    )
  }}
/>

 
さらに、maskinputFormat を指定します。

今回は date-fns を使っているため、 date-fns のフォーマットで指定します。
date-fns - modern JavaScript date utility library

LocalizationProviderOutsideWithLocaleMaskInputFormat.tsx

<Controller
  render={({field}) => {
    return (
      <DateTimePicker
        {...field}
        label="input"
        mask="____/__/__ __:__:__"
        inputFormat="yyyy/MM/dd HH:mm:ss"
        renderInput={(props) => <TextField {...props} />}
        onChange={(newValue) => {
          setValue('inputValue', newValue)
        }}
      />
    )
  }}
/>

 

ここまでで機能ができたため、最後に

の2つに分けてみます。

 
まずは、入力した日時を表示するコンポーネントについてです。

コンポーネントでは、Modal の開閉に関係する state や、表示する日時の state を、 Modal のコンポーネントに渡します。

DateTimePickerWithReactHookForm.tsx

import {Button} from '@mui/material'
import {useState} from 'react'
import DateTimePickerModal from '@/components/pages/datetime_picker_with_react_hook_form/DateTimePickerModal'
import {format} from 'date-fns'

const Component = (): JSX.Element => {
  const [dateTimeLabel, setDateTimeLabel] = useState<Date | null>(new Date())

  const [open, setOpen] = useState(false)
  const handleOpen = () => setOpen(true)
  const handleClose = () => setOpen(false)

  return (
    <>
      <h1>Datetime Picker with React Hook Form</h1>
      <h2>Current value: {dateTimeLabel && format(dateTimeLabel, 'yyyy/MM/dd HH:mm:ss')}</h2>

      <Button onClick={handleOpen} variant="contained">
        Edit DateTime
      </Button>

      <DateTimePickerModal
        datetimeLabel={dateTimeLabel}
        setDateTimeLabel={setDateTimeLabel}
        open={open}
        handleClose={handleClose}
      />
    </>
  )
}
export default Component

 
続いて、 Modal のコンポーネントです。

  • 日時の state を Controller の defaultValue に設定
  • DateTimePicker の onChange にて、 React Hook Form の setValue を使って Controller の name に指定した項目 inputValue に値を設定
  • form の onSubmit で指定した関数 ( onSubmit ) にて、日時の set hook を使って値を設定し、 Modal を閉じる

などしています。

DateTimePickerModal.tsx

import {Controller, SubmitHandler, useForm} from 'react-hook-form'
import {Box, Button, Modal, TextField} from '@mui/material'
import {DateTimePicker, LocalizationProvider} from '@mui/lab'
import AdapterDateFns from '@mui/lab/AdapterDateFns'
import {ja} from 'date-fns/locale'
import {Dispatch, SetStateAction} from 'react'

type Props = {
  datetimeLabel: Date | null
  setDateTimeLabel: Dispatch<SetStateAction<Date | null>>
  open: boolean
  handleClose: () => void
}

type Input = {
  inputValue: Date | null
}

const style = {
  position: 'absolute' as 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '2px solid #000',
  boxShadow: 24,
  p: 4
}

const Component = ({open, handleClose, datetimeLabel, setDateTimeLabel}: Props): JSX.Element => {
  const {control, handleSubmit, setValue} = useForm<Input>()

  const onSubmit: SubmitHandler<Input> = (data) => {
    console.log(data)
    setDateTimeLabel(data['inputValue'])
    handleClose()
  }

  return (
    <>
      <LocalizationProvider dateAdapter={AdapterDateFns} locale={ja}>
        <Modal open={open} onClose={handleClose}>
          <Box sx={style}>
            <form onSubmit={handleSubmit(onSubmit)}>
              <Controller
                name="inputValue"
                control={control}
                defaultValue={datetimeLabel}
                render={({field}) => {
                  return (
                    <DateTimePicker
                      {...field}
                      label="input"
                      inputFormat="yyyy/MM/dd HH:mm:ss"
                      mask="____/__/__ __:__:__"
                      renderInput={(props) => <TextField {...props} />}
                      onChange={(newValue) => {
                        setValue('inputValue', newValue)
                      }}
                    />
                  )
                }}
              />

              <Button type="submit" variant="contained">
                Save
              </Button>
            </form>
          </Box>
        </Modal>
      </LocalizationProvider>
    </>
  )
}
export default Component

 
以上にて、作るものが完成しました。

 

ソースコード

Github に上げました。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample

 
今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/pull/7

React + localeText 設定済の MUI DataGrid にて、フィルタ用 Custom Operator の value を標準の Operator と同じ値にすると、自動で翻訳される

以前、MUI DataGrid の列の filterOperators に自作の Custom Operator を設定して、独自のフィルタ機能を実装したことがありました。
React + MUI のDataGridにて、ある列が複数の日付を持つデータに対し、valueFormatter・sortComparator・filterModelを使って表示・ソート・フィルタしてみた - メモ的な思考的な

 
その時のフィルタには label の値が表示されていました。

ただ、MUI の DataGrid には Localization 機能があることから、自作の Custom Operator も Localization 対応したいと考えました。
Data Grid - Localization - MUI

また、自作の Custom Operator は標準の Operator とほとんど同じ機能であることから、できればフィルタに表示する時のラベルも標準と同じにしたいと考えました。

 
そこで、試しに実装してみたところ動作したため、メモを残します。

なお、公式ドキュメントに記載が見当たらなかったため、今回試した方法が将来は無効になるかもしれません。

 
目次

 

環境

  • React.js 17.0.2
  • React Router 6.0.1
  • @mui/x-data-grid 5.1.0

 
2021/12/02頃に x-data-grid の 5.1.0 系がリリースされたため、更新しておきます。
@mui/x-data-grid - npm

% yarn upgrade @mui/x-data-grid

 

DataGrid の Localization

まずは DataGrid に対し、公式ドキュメントに従って Localization を行います (今回は日本語化)。 Data Grid - Localization - MUI

 
手順としては以下のとおりです。

 
なお、注意点としては DataGrid の localeText に設定する際、公式ドキュメントの通りにやると「 props が無い」旨のエラーが出てしまいました。

そこで、手元では以下のようにすると動作しました。

// エラーが出る公式ドキュメントの書き方
<DataGrid localeText={nlNL.props.MuiDataGrid.localeText} />

// 手元で動作した書き方
<DataGrid localeText={jaJP.components.MuiDataGrid.defaultProps.localeText} />

 

Custom Operator の label について

公式ドキュメントでは labelFrom と設定した Custom Operator を使うことで、フィルタの選択肢に From が表示されています。
Create a custom operator - Data Grid - Filtering - MUI

今回はこの label 部分を日本語化してみます。

 

公式ドキュメントの Custom Operator に記載はありませんでしたが、

export const isOperator = {
  value: 'is',
  getApplyFilterFn: (filterItem: GridFilterItem) => {
    // ...
}

のように、

  • label は設定しない
  • value は標準の Operator の値と一致させる

としたところ、 locale に従って自動的に翻訳されました。

 

一方、

export const isBlankOperator = {
  value: 'isBlank',
  getApplyFilterFn: (filterItem: GridFilterItem) => {
  // ...
}

のように、

  • label が無い
  • value の値が標準の Operator に存在しない

場合は

Uncaught Error: Missing translation for key filterOperatorIsBlank.

というエラーになりました。

 
また、

export const isBlankOperator = {
  label: 'blank',
  value: 'isBlank',
  getApplyFilterFn: (filterItem: GridFilterItem) => {
    // ...
}

のように、 label を用意すると、 label の値がフィルタに表示されました。

 

標準 Operator のありかについて

標準 Operator の value の値を確認するには、Githubにあるファイルを見ます。

今のところ、このディレクトリになります。
https://github.com/mui-org/material-ui-x/tree/master/packages/grid/_modules_/grid/models/colDef

  • gridStringOperators.ts
  • gridDateOperators.ts

などがあるため、参照して実装すれば良さそうです。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample

 
今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/pull/6