読者です 読者をやめる 読者になる 読者になる

dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

follow us in feedly

SlackのOutgoing WebhookとGoogle Apps ScriptでBotを作ったときにつまずいたところメモ

Bot

Slack の Bot にメンションしたら Outgoing Webhook で GAS のスクリプトを実行するようなものが作りたくて
とりあえず連携するところまで作ったんだけど、思ったよりハマったところがあったのでメモ。

基本的な作り方に関しては、こちらの記事の通りに作成するとうまくいく。
初心者がGASでSlack Botをつくってみた - CAMPHOR- Tech Blog

主な流れ
  • GAS で doPost() メソッドを定義したスクリプトを作り、「公開 > ウェブ アプリケーションとして導入...」で URL を取得する
  • https://api.slack.com/web から Slack の API token を取得し、GAS のスクリプトでその token を使用する
  • Slack の Outgoing Webhooks を作成し、「URL(s)」に GAS の URL を貼り付ける
  • 「Trigger Words(s)」にスクリプト実行のキーワードとなる文字列を入れる(, 区切りで複数指定可能)

bot のアイコンが表示されない

chat.postMessageusernameBot 名を正確に入力しているにも関わらず、Bot のアイコンに指定した画像が表示されなかった。
icon_url または icon_emoji で指定してあげる必要があったんですね。初歩的。


GAS のスクリプトが更新されない

「ウェブ アプリケーションとして導入」した後にスクリプトを変更した際、バージョンを上げずに単に「更新」してた。

f:id:dackdive:20170118230328p:plain:w320

ここですね。
少しでもスクリプトを更新したら、毎回プロジェクト バージョンで「新規作成」を選びバージョンを1つ上げる必要があった。

これどうにかならないかなと思ったんだけど今のところわからず。
「最新のコードをテスト」で doGet() についてはテストできるんだけど、その URL に POST しても doPost() は見つからないと言われた。

あと、アプリケーションにアクセスできるユーザーを「全員(匿名ユーザーを含む)」にするのも必要。


bot へメンションしてるのに Webhook が反応しない

これが一番ハマったところ。
せっかく Bot アカウントも作っているので、@my-bot のようにメンションでスクリプト実行したかったが何回やっても反応しなかった。

調べてみたところ、メンションしているときは Bot 名でなく Bot の UserId で指定する必要があることがわかった。
参考:SlackのOutgoing Webhookで@つきのmentionを捕まえる - beatsync.net

また、Bot の User Id は画面からはわからなかったので、確認するにはこちらの記事を参考に Slack API を叩いて確認した。
参考:slackに参加しているメンバーのUser IDを調べる方法 - /var/www/yatta47.log

$ curl https://slack.com/api/users.list?token=[TOKEN]

https://api.slack.com/web で取得した token を使って上記のように API を叩き、レスポンスを Online JSON Viewer などで整形して目的の Bot の User Id を探す。


おまけ:GAS スクリプト側を動作確認するには

$ curl -X POST -v -F 'hoge=fuga' https://script.google.com/macros/s/***/exec

とかした。

[Apex]レコードが承認プロセスでロック中かどうか判定する

Salesforce

メモ。
レコードの ID を元に、そのレコードが現在承認プロセスの最中で、かつロックされているかどうか Apex で判定したい。

ここを参考にできた。
apex - Check if a record is in approval process - Salesforce Stack Exchange

Approval.isLocked(id)

を使う。

参考:Approval クラス | Apex 開発者ガイド | Salesforce Developers

isLocked は id だけでなく id のリストだったり SObject またはそのリストも引数として受け取れるらしい。

1個だけ注意点として、このメソッドは

作成 > ワークフローと承認プロセス > プロセスの自動化設定

より、「Apex でのレコードのロックおよびロック解除を有効化」に✔を入れておかないといけない。

f:id:dackdive:20170110225827p:plain

参考:SFDC:Apexトリガによる承認申請時のレコードロック解除について - tyoshikawa1106のブログ

設定しておかないと、以下のエラーが出る。

System.NoAccessException: Apex approval lock/unlock api preference not enabled.

[Salesforce]カスタムオブジェクトのメタデータをCSVに変換する

Salesforce 小ネタ Node

動機

開発時、特定のカスタムオブジェクトの項目一覧をさっと確認したい。
基本的にメタデータを git 管理しているので、src/objects/MyObj__c.object のようなローカルの XML ファイルをパースして
人が読める形式に加工できれば十分。

Node 界隈のパッケージとか使えば簡単にできるんじゃないかなと思って試してみたらできたので、メモ。


作る

想定しているディレクトリ構成は以下。

├── src
│   ├── package.xml
│   └── objects
│       └── MyObj__c.object
├── build.xml
├── package.json
└── index.js

必要なパッケージをインストールする。

$ npm install -D fs-extra
$ npm install -D xml2js
$ npm install -D json2csv

コードは以下のようになった。

const fs = require('fs-extra');
const xml2js  = require('xml2js');
const json2csv = require('json2csv');
const csv2md = require('csv2md');

const parser = new xml2js.Parser();
const PATH_TO_TARGET = 'src/objects/MyObj__c.object';
const CSV_FIELDS = [
  'label',
  'fullName',
  'type',
  'required',
  // 'externalId',
  // 'caseSensitive',
  // 'length',
  // 'trackTrending',
  // 'unique',
];
const dataList = [];

fs.readFile(__dirname + '/' + PATH_TO_TARGET, function(err, data) {
  parser.parseString(data, function (err, result) {
    // console.dir(result);

    for (const field of result.CustomObject.fields) {
      const data = {};
      // console.log(field);
      for (const csvField of CSV_FIELDS) {
        data[csvField] = field[csvField] ? field[csvField][0] : null;
      }
      // console.log(data);
      dataList.push(data);
    }
    // console.log('Done');
  });

  const csv = json2csv({ data: dataList, fields: CSV_FIELDS });
  console.log(csv);
});

CSV_FIELDS で定義しているプロパティは以下を参照。
https://developer.salesforce.com/docs/atlas.ja-jp.204.0.api_meta.meta/api_meta/customobject.htm

項目によって出力されるものとそうでないものがあるので、とりあえず最低限必要なものだけにしてみた。

試してみる

こういうメタデータに対して

<?xml version="1.0" encoding="UTF-8"?>
<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">
    <fields>
        <fullName>Account__c</fullName>
        <deleteConstraint>SetNull</deleteConstraint>
        <externalId>false</externalId>
        <label>取引先</label>
        <referenceTo>Account</referenceTo>
        <relationshipLabel>Myobj</relationshipLabel>
        <relationshipName>Myobjs</relationshipName>
        <required>false</required>
        <trackTrending>false</trackTrending>
        <type>Lookup</type>
    </fields>
    <fields>
        <fullName>Checkbox__c</fullName>
        <externalId>false</externalId>
        <label>元チェックボックス</label>
        <required>false</required>
        <trackTrending>false</trackTrending>
        <type>Date</type>
    </fields>
    <fields>
        <fullName>Discount_Percentage__c</fullName>
        <externalId>false</externalId>
        <label>Discount_Percentage</label>
        <precision>3</precision>
        <required>false</required>
        <scale>0</scale>
        <trackTrending>false</trackTrending>
        <type>Number</type>
        <unique>false</unique>
    </fields>
    <fields>
        <fullName>Discount_Range__c</fullName>
        <externalId>false</externalId>
        <formula>IF((Discount_Percentage__c &gt;=5 &amp;&amp; Discount_Percentage__c&lt;=10) , &apos;Level-1&apos;,
IF((Discount_Percentage__c &gt;=10 &amp;&amp; Discount_Percentage__c&lt;=20), &apos;Level-2&apos;,
IF((Discount_Percentage__c &gt;=20 &amp;&amp; Discount_Percentage__c&lt;=30), &apos;Level-3&apos;,
IF((Discount_Percentage__c &gt;=30 &amp;&amp; Discount_Percentage__c&lt;=40), &apos;Level-4&apos;,



&apos;No Disount&apos;))))</formula>
        <formulaTreatBlanksAs>BlankAsZero</formulaTreatBlanksAs>
        <label>Discount_Range</label>
        <required>false</required>
        <trackTrending>false</trackTrending>
        <type>Text</type>
        <unique>false</unique>
    </fields>
    <fields>
        <fullName>Lead__c</fullName>
        <deleteConstraint>SetNull</deleteConstraint>
        <externalId>false</externalId>
        <label>リード</label>
        <referenceTo>Lead</referenceTo>
        <relationshipLabel>Myobj</relationshipLabel>
        <relationshipName>Myobjs</relationshipName>
        <required>false</required>
        <trackTrending>false</trackTrending>
        <type>Lookup</type>
    </fields>
    <fields>
        <fullName>PicklistValues1__c</fullName>
        <externalId>false</externalId>
        <label>PicklistValues</label>
        <picklist>
            <picklistValues>
                <fullName>aaaaa</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>bbbbb</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>ccccc</fullName>
                <default>false</default>
            </picklistValues>
            <sorted>false</sorted>
        </picklist>
        <required>false</required>
        <trackTrending>false</trackTrending>
        <type>Picklist</type>
    </fields>
    <fields>
        <fullName>PicklistValues__c</fullName>
        <externalId>false</externalId>
        <label>PicklistValues</label>
        <picklist>
            <picklistValues>
                <fullName>aaaaaaaaaaa</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>bbbbbbbbbbb</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>cccccccccc</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>ddddddddddd</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>eeeeeeeeee</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>ffffffffffffffff</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>gggggggggg</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>hhhhhhhhh</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>iiiiiiiiiii</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>jjjjjjjjjjjj</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>kkkkkkkkkk</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>lllllllllll</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>mmmmmmmmmm</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>nnnnnnnnnnnnn</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>oooooooo</fullName>
                <default>false</default>
            </picklistValues>
            <sorted>false</sorted>
        </picklist>
        <required>false</required>
        <trackTrending>false</trackTrending>
        <type>Picklist</type>
    </fields>
    <fields>
        <fullName>Status__c</fullName>
        <externalId>false</externalId>
        <label>ステータス</label>
        <picklist>
            <picklistValues>
                <fullName>対応中</fullName>
                <default>false</default>
            </picklistValues>
            <picklistValues>
                <fullName>完了</fullName>
                <default>false</default>
            </picklistValues>
            <sorted>false</sorted>
        </picklist>
        <required>false</required>
        <trackTrending>false</trackTrending>
        <type>Picklist</type>
    </fields>
    <fields>
        <fullName>Text2__c</fullName>
        <externalId>false</externalId>
        <label>テキスト入力欄2</label>
        <length>100</length>
        <required>false</required>
        <trackTrending>false</trackTrending>
        <type>Text</type>
        <unique>false</unique>
    </fields>
    <fields>
        <fullName>Text__c</fullName>
        <externalId>false</externalId>
        <label>テキスト入力欄</label>
        <length>255</length>
        <required>false</required>
        <trackTrending>false</trackTrending>
        <type>Text</type>
        <unique>false</unique>
    </fields>
    <label>Myobj</label>
    <!-- 略 -->
</CustomObject>

結果がこう。

$ node index.js
"label","fullName","type","required"
"取引先","Account__c","Lookup","false"
"元チェックボックス","Checkbox__c","Date","false"
"Discount_Percentage","Discount_Percentage__c","Number","false"
"Discount_Range","Discount_Range__c","Text","false"
"リード","Lead__c","Lookup","false"
"PicklistValues","PicklistValues1__c","Picklist","false"
"PicklistValues","PicklistValues__c","Picklist","false"
"ステータス","Status__c","Picklist","false"
"テキスト入力欄2","Text2__c","Text","false"
"テキスト入力欄","Text__c","Text","false"


TODO

とりあえずで作ったので、ブラッシュアップしないといけない点は多々。

  • Name などの標準項目がメタデータとして出力されてないので調べる
    • retrieve する時の指定のしかたが悪いかも
  • src/objects 以下の全オブジェクトに対して実行できるようにしたい
  • CLI 化したい
    • 出力するプロパティや対象のオブジェクトは引数で指定できるようにしたい
  • 選択リスト(Picklist)などは選択肢の値もほしい
  • Markdown 形式にも対応したい
    • csv2md というのが使えるかと思ったけど、CLI としてしか使えない?

Markdown については、csv2md でもとりあえず以下のようにして変換することはできる。

$ npm i -D csv2md
$ node index.js | ./node_modules/csv2md/bin/csv2md
| label | fullName | type | required |
|---|---|---|---|
| 取引先 | Account__c | Lookup | false |
| 元チェックボックス | Checkbox__c | Date | false |
| Discount_Percentage | Discount_Percentage__c | Number | false |
| Discount_Range | Discount_Range__c | Text | false |
| リード | Lead__c | Lookup | false |
| PicklistValues | PicklistValues1__c | Picklist | false |
| PicklistValues | PicklistValues__c | Picklist | false |
| ステータス | Status__c | Picklist | false |
| テキスト入力欄2 | Text2__c | Text | false |
| テキスト入力欄 | Text__c | Text | false |

npm run + Tabキーでnpm scriptを補完する

Node

知らなかったのでメモ。
参考:npm run should have autocomplete prompt for available scripts · Issue #8030 · npm/npm

# zsh の場合
$ npm completion >> ~/.zshrc

~/.bashrc も可。

$ npm run <TAB>
build-storybook  storybook        test

楽になった。

Material-UIでrefを使う

React Material-UI

メモ。
以下のようなコンポーネント<input type="text"> 項目を Material-UI の TextField に置き換えようと思ったが
ref で参照しているのをそのまま TextField でも使えるのか迷った。

import React, { PropTypes } from 'react';

export default class AddTodoForm extends React.Component {
  handleSubmit(e) {
    e.preventDefault();
    const node = this.refs.input;
    const text = node.value.trim();
    if (!text) {
      return;
    }
    this.props.onSubmit(text);
    node.value = '';
  }

  render() {
    return (
      <div>
        <form onSubmit={(e) => this.handleSubmit(e)}>
          <input type="text" ref="input" />
          <button type="submit">
            Add Todo
          </button>
        </form>
      </div>
    );
  }
}

AddTodoForm.propTypes = {
  onSubmit: PropTypes.func.isRequired,
};


先に結論

値の参照だけしたいときは

this.refs.xxx.getValue()

とし、DOM Node を取得する場合は

this.refs.xxx.getInputNode()

を使う。

両方ともドキュメントには載っておらず、前者は以下の Stack Overflow から
javascript - How get data from material-ui TextField, DropDownMenu components? - Stack Overflow

後者は以下のようにブラウザの開発者コンソールから無理やり見つけた。

f:id:dackdive:20161123195515p:plain


string ref を使う場合

 import React, { PropTypes } from 'react';
+import RaisedButton from 'material-ui/RaisedButton';
+import TextField from 'material-ui/TextField';

 export default class AddTodoForm extends React.Component {
   handleSubmit(e) {
     e.preventDefault();
-    const node = this.refs.input;
+    const node = this.refs.input.getInputNode();
     const text = node.value.trim();
     if (!text) {
       return;
@@ -16,10 +18,8 @@ export default class AddTodoForm extends React.Component {
     return (
       <div>
         <form onSubmit={(e) => this.handleSubmit(e)}>
-          <input ref="input" />
-          <button type="submit">
-            Add Todo
-          </button>
+          <TextField id="todo-title" ref="input" />
+          <RaisedButton label="Add Todo" onTouchTap={(e) => this.handleSubmit(e)} />
         </form>
       </div>
     );
diff --git a/src/containers/App.js b/src/containers/App.js
index 8068f95..4ee5acf 100644
--- a/src/containers/App.js
+++ b/src/containers/App.js
@@ -18,4 +18,3 @@ export default class App extends React.Component {
     );
   }
 }


arrow function による ref を使う場合

上述した string ref は現在推奨されていないらしい
(参考:http://reactjs.cn/react/docs/more-about-refs.html#the-ref-string-attribute
ので、arrow 関数を使った場合の書き方も載せておく。

https://facebook.github.io/react/docs/refs-and-the-dom.html

 import React, { PropTypes } from 'react';
+import RaisedButton from 'material-ui/RaisedButton';
+import TextField from 'material-ui/TextField';

 export default class AddTodoForm extends React.Component {
   handleSubmit(e) {
     e.preventDefault();
-    const node = this.refs.input;
+    const node = this.input.getInputNode();
     const text = node.value.trim();
     if (!text) {
       return;
@@ -16,10 +18,8 @@ export default class AddTodoForm extends React.Component {
     return (
       <div>
         <form onSubmit={(e) => this.handleSubmit(e)}>
-          <input ref="input" />
-          <button type="submit">
-            Add Todo
-          </button>
+          <TextField id="todo-title" ref={(input) => this.input = input} />
+          <RaisedButton label="Add Todo" onTouchTap={(e) => this.handleSubmit(e)} />
         </form>
       </div>
     );
     );
   }
 }

API Blueprintによるドキュメント開発環境【2016年冬】

API ドキュメント作成ツール

ここから少しアップデートしたのでメモ。

リポジトリ


特徴

markdown -> html への変換には aglio、ローカルサーバーは --server オプションを使う

ローカルで markdown ファイルを html に変換するのに aglio を使うところは以前と同じ。
改めて調べてみると、--server というオプションでライブリロード機能つきのローカルサーバーの起動までやってくれるらしく、browserSync は不要だった。

また、ポート番号については README に記載されていないが、-p オプションで指定できた。
参考:Support for different port? · Issue #12 · danielgtaylor/aglio

$ ./node_modules/aglio/bin/aglio.js
Usage: node_modules/aglio/bin/aglio.js [options] -i infile [-o outfile -s]

オプション:
  -i, --input           Input file
  -o, --output          Output file
  -t, --theme           Theme name or layout file        [デフォルト: "default"]
  -f, --filter          Sanitize input from Windows    [真偽] [デフォルト: true]
  -s, --server          Start a local live preview server
  -h, --host            Address to bind local preview server to
                                                       [デフォルト: "127.0.0.1"]
  -p, --port            Port for local preview server         [デフォルト: 3000]
  -v, --version         Display version number               [デフォルト: false]
  -c, --compile         Compile the blueprint file           [デフォルト: false]
  -n, --include-path    Base directory for relative includes
  --verbose             Show verbose information and stack traces
                                                             [デフォルト: false]
  --theme-variables     Color scheme name or path to custom variables
                                                         [デフォルト: "default"]
  --theme-condense-nav  Condense navigation links      [真偽] [デフォルト: true]
  --theme-full-width    Use full window width         [真偽] [デフォルト: false]
  --theme-template      Template name or path to custom template
                                                         [デフォルト: "default"]
  --theme-style         Layout style name or path to custom stylesheet
  --theme-emoji         Enable support for emoticons   [真偽] [デフォルト: true]

例:
  node_modules/aglio/bin/aglio.js -i        Render to HTML
  example.apib -o output.html
  node_modules/aglio/bin/aglio.js -i        Start preview server
  example.apib -s
  node_modules/aglio/bin/aglio.js           Theme colors
  --theme-variables flatly -i example.apib
  -s
  node_modules/aglio/bin/aglio.js           Disable options
  --no-theme-condense-nav -i example.apib
  -s

See https://github.com/danielgtaylor/aglio#readme for more information

(コマンド例)

$ aglio -i src/index.md -p 8080 --server


複数ファイルの分割は <!-- include(xxx.md) --> で実現

参考:https://github.com/danielgtaylor/aglio#including-files

エントリーポイントとなるファイルで

FORMAT: 1A

<!-- include(xxx.md) -->

としておくと、別のファイルを読み込むことができる。
これで API 定義を複数のファイルに分割することができる。

これ自体は以前調べたときにも把握はしてたようだが、モックサーバー用ライブラリ api-mock の制限で断念したようだった。
が、次に示すようにモックサーバを api-mock から drakov というライブラリに変更したのでこの問題は解決した。


モックサーバには api-mock のかわりに drakov を使用

以前調べたときは api-mock を使おうとしていたらしいが、
今 Node のバージョン 7.1.0、npm のバージョン 3.10.9 でインストールしようとしたところエラーになってしまった。

$ npm i -g api-mock
/usr/local/bin/api-mock -> /usr/local/lib/node_modules/api-mock/bin/api-mock

> protagonist@1.2.6 install /usr/local/lib/node_modules/api-mock/node_modules/protagonist
> node-gyp rebuild

  CXX(target) Release/obj.target/libmarkdownparser/drafter/ext/snowcrash/ext/markdown-parser/src/ByteBuffer.o
clang: error: invalid deployment target for -stdlib=libc++ (requires OS X 10.7 or later)
make: *** [Release/obj.target/libmarkdownparser/drafter/ext/snowcrash/ext/markdown-parser/src/ByteBuffer.o] Error 1
gyp ERR! build error
gyp ERR! stack Error: `make` failed with exit code: 2
gyp ERR! stack     at ChildProcess.onExit (/usr/local/lib/node_modules/npm/node_modules/node-gyp/lib/build.js:276:23)
gyp ERR! stack     at emitTwo (events.js:106:13)
gyp ERR! stack     at ChildProcess.emit (events.js:191:7)
gyp ERR! stack     at Process.ChildProcess._handle.onexit (internal/child_process.js:215:12)
gyp ERR! System Darwin 14.5.0
gyp ERR! command "/usr/local/Cellar/node/7.1.0/bin/node" "/usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js" "rebuild"
gyp ERR! cwd /usr/local/lib/node_modules/api-mock/node_modules/protagonist
gyp ERR! node -v v7.1.0
gyp ERR! node-gyp -v v3.4.0
gyp ERR! not ok

この Issue が関係している?Node のバージョン 6 系からうまくインストールできないらしい。
install failed at protagonist package · Issue #53 · localmed/api-mock

しかたないので
https://apiblueprint.org/tools.html#mock-servers
の一番上にあった drakov にした。

drakov は対象の markdown ファイルを指定する際

$ drakov -f "src/**/*.md"

というように正規表現で指定することができるので、ファイルが複数に分割されていても問題ない。
" でくくらないと期待通りに動かないので注意(シェル環境が zsh だったからかも)

また、--watch オプションをつけておくと markdown ファイルの変更を検知して自動的にリロードしてくれる。

(コマンド例)

$ drakov -f "src/**/*.md" -p 8081 --watch


TODO

結局いまだに dredd というツールを調べられていない。

React(v15)&ReduxでMaterial-UIを使ってみる

React Redux Material-UI

メモです。
基本的な使い方を試してみた。


リポジトリ

Redux の basic チュートリアル にそって作った Todo アプリをベースに、Material-UI を適用してみる。

https://github.com/zaki-yama/redux-todo-app/pull/2

(Material-UI 適用前のコードには base というタグをつけてます)

なお、公式 example リポジトリも存在するよう。


インストール

$ npm install --save material-ui

でインストール


導入手順

react-tap-event-plugin をインストールする

http://www.material-ui.com/#/get-started/installation によると、react-tap-event-plugin というパッケージが必要らしい。
Material-UI と同様に npm でインストールする。

$ npm install --save react-tap-event-plugin

このパッケージにより、Material-UI のすべてのコンポーネントは onTouchTap というイベントハンドラが使えるようになる。

マウスクリックやスマホタブレットのタッチ・タップイベントを共通のイベントハンドラで処理できるようになる、のかな?

また、上記ページには

This dependency is temporary and will go away once the official React version is released.

と書いており、試したときの React のバージョンは 15.3.2 だったので
メジャーバージョンが 1 以上だからこのプラグイン無しでも動くということかな?と思ったけど、結局インストールしないとうまく動かなかった。

react-tap-event-plugin をインストールした後は、以下のコードをアプリの先頭に記述する必要がある。

import injectTapEventPlugin from 'react-tap-event-plugin';

// Needed for onTouchTap
// http://stackoverflow.com/a/34015469/988941
injectTapEventPlugin();

よって Redux の場合、アプリのエントリーポイント(src/index.js)に以下のように追記するはず。

 import { render } from 'react-dom';
 import { Provider } from 'react-redux';
 import { createStore } from 'redux';
+import injectTapEventPlugin from 'react-tap-event-plugin';
 import todoApp from './reducers';
 import App from './containers/App';
  
+// Needed for onTouchTap
+// http://stackoverflow.com/a/34015469/988941
+injectTapEventPlugin();
+
 const store = createStore(todoApp);
  
 render(
   <Provider store={store}>
     <App />
   </Provider>,
   document.getElementById('root'),
 );


Roboto フォントを適用する

またまた
http://www.material-ui.com/#/get-started/installation#roboto-font
を読むと、

Material-UI was designed with the Roboto font in mind

とあるので、Roboto フォント を読み込むようにする。

html ファイルに

<!-- src/index.html -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500">

と書き、CSS でも

/* src/main.css */
html {
  font-family: 'Roboto', sans-serif;
}

とする。
確認したところ html 側の記述だけでも Material-UI コンポーネントに Roboto フォントが適用されるが、
アプリ全体のフォントを統一するため CSS でも指定することにした。

なお、話は逸れるが webpack で CSS を扱う方法はいくつか考えられる。

├── dist .................. ビルド後のファイルを置く場所
├── src
│   ├── actions
│   ├── components
│   ├── constants
│   ├── containers
│   ├── index.html
│   ├── index.js
│   ├── main.css
│   └── reducers
└── webpack.config.js

というディレクトリ構成の場合、

1) CSSCSS のままで html から読み込む場合

html 側は

<link rel="stylesheet" type="text/css" href="main.css">

とし、webpack.config.js では file-loader を使って dist ディレクトリに CSS ファイルもコピーされるようにする。

   context: __dirname + '/src',
   entry: {
     javascript: './index.js',
-    html: './index.html'
+    html: './index.html',
+    css: './main.css',
   },
   output: {
     path: __dirname + '/dist',
@@ -27,6 +28,10 @@ module.exports = {
       {
         test: /\.html$/,
         loader: 'file?name=[path][name].[ext]'
+      },
+      {
+        test: /\.css$/,
+        loader: 'file?name=[path][name].[ext]'
       }
     ]
   }

2) JavaScript にバンドルする場合

CSSJavaScript 内で import してしまうという場合、html の代わりに src/index.js で

import './main.css';

とし、webpack.config.js では css-loader と style-loader を使うようにする。

       {
         test: /\.html$/,
         loader: 'file?name=[path][name].[ext]'
-      }
+      },
+      {
+        test: /\.css$/,
+        loader: 'style!css',
+      },
     ]

どちらでもいいですが、2) の方が後々 SCSS とかにも応用できるので良さげ。


<MuiThemeProvider> でアプリケーション全体をラップする

コンポーネントを使う前の最後の準備として、アプリケーション全体を <MuiThemeProvider> というコンポーネントでラップする。
エントリーポイントになっている src/index.js でやってもいいと思うし、コンポーネントの root になっている containers/App.js でもいいはず。

containers/App.js

 import React from 'react';
+import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
+
 import AddTodo from './AddTodo';
 import VisibleTodoList from './VisibleTodoList';
 import FilterLinkList from '../components/FilterLinkList';

 export default class App extends React.Component {
   render() {
     return (
-      <div>
-        <AddTodo />
-        <VisibleTodoList />
-        <FilterLinkList />
-      </div>
+      <MuiThemeProvider>
+        <div>
+          <AddTodo />
+          <VisibleTodoList />
+          <FilterLinkList />
+        </div>
+      </MuiThemeProvider>
     );
   }
 }


Material-UI コンポーネントを使う

ここまでできるとようやく Material-UI のコンポーネントを使うことができる。

今回は簡単な例で、Todo の入力フォームとボタンをそれぞれ TextFieldRaisedButton に置き換えてみる。

元のコードは以下。

components/AddTodoForm.js

import React from 'react';

export default class AddTodoForm extends React.Component {
  handleSubmit(e) {
    e.preventDefault();
    const node = this.refs.input;
    const text = node.value.trim();
    if (!text) {
      return;
    }
    this.props.onSubmit(text);
    node.value = '';
  }

  render() {
    return (
      <div>
        <form onSubmit={(e) => this.handleSubmit(e)}>
          <input ref="input" />
          <button type="submit">
            Add Todo
          </button>
        </form>
      </div>
    );
  }
}

これを以下のようにする。

import React from 'react';
import RaisedButton from 'material-ui/RaisedButton';
import TextField from 'material-ui/TextField';

export default class AddTodoForm extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      input: '',
    };
  }

  handleChange(e) {
    this.setState({
      input: e.target.value,
    });
  }

  handleSubmit(e) {
    e.preventDefault();
    const text = this.state.input.trim();
    if (!text) {
      return;
    }
    this.props.onSubmit(text);
    this.setState({
      input: '',
    });
  }

  render() {
    return (
      <div>
        <form onSubmit={(e) => this.handleSubmit(e)}>
          <TextField
            id="todo-title"
            value={this.state.input}
            onChange={(e) => this.handleChange(e)}
          />
          <RaisedButton
            label="Add Todo"
            onTouchTap={(e) => this.handleSubmit(e)}
          />
        </form>
      </div>
    );
  }
}

フォームに入力されたテキストを管理するのに ref を使っていたのを local state で管理するようになったため全体のコード量が増えた。
ref のまま Material-UI のコンポーネントを管理することもできるみたいなんだけど、ドキュメントに載っていない方法なのでやめた。
あとで別の記事にでもメモする。

この変更によってアプリの UI は以下のように変更される。

before

f:id:dackdive:20161116014935p:plain

after

f:id:dackdive:20161116014944p:plain

ちょっとだけそれっぽい UI になった。


おわりに

ということで React/Redux なアプリに Material-UI を導入することができた。
意外と事前準備としてやることが多く、またそれがドキュメントからは微妙にわかりづらかったりして思った以上に手こずった。


トラブルシューティング的な

ボタンを使うと Unknown prop `onTouchTap` on <button> tag. が出る

→react-tap-event-plugin が入っていない。


リファレンス