Typescript, React, Redux で文字数などをカウントするwebアプリを作成

# Typescriptで文字数などをカウントするアプリを作成した際のメモ書き
(2016.06.25 OS X El Capitan)

## 作成するアプリ

テキストファイルをアップロードすると or 直接フォームのテキストエリアに何かテキストを入力すると、
テキストの要約統計量（文字数など）がリアルタイムで表示されるアプリを作成することを目的とする.

完成したアプリ: [summarize-text-data](http://NariseT.github.io/summarize-text-data)
[Material UI で少しオシャレにした](http://qiita.com/NariseT/items/e754bd5fa9ff781d9659): [summarize-text-data-appbar](https://nariset.github.io/summarize-text-data-appbar/)

## Node.js, npm パッケージ
### Node.js, npm パッケージのインストール

node や typescript やプロジェクト管理用のパッケージがインストールされていない場合、
必要なパッケージのグローバルへのインストールを行う.

```
brew install node
npm i -g typescript typings gulp webpack
```

なお、グローバルな npm パッケージをすべて削除するには、以下のようにする
(http://stackoverflow.com/questions/9283472/command-to-remove-all-npm-modules-globally より).

```
npm ls -gp --depth=0 | awk -F/ '/node_modules/ && !/\/npm$/ {print $NF}' | xargs npm -g rm
```

### Node.js, npm のアップデートとバージョン確認

以下のコマンドで Node.js, npm パッケージのアップデートができる.

```
brew update
brew upgrade
npm update -g
```

node のバージョン確認方法.

```
node -v
v6.2.2
```

npm でグローバルにインストールしたパッケージのバージョン確認方法.

```
npm ls -g --depth=0
/usr/local/lib
├── gulp@3.9.1
├── npm@3.10.2
├── typescript@1.8.10
├── typings@1.3.0
└── webpack@1.13.1
```

## summarize-text-data プロジェクト

以下をターミナルで入力してプロジェクトのディレクトリ構造を作成.

```
mkdir summarize-text-data
cd summarize-text-data
mkdir src
mkdir src/actions
mkdir src/models
mkdir src/reducers
mkdir src/components
mkdir src/containers
mkdir gulp
mkdir gulp/tasks
```

`npm init` と入力して、Enter を連打（すべてデフォルトのまま）.

アプリの作成には、React, Redux を使用する. ファイルアップロードには、react-dropzone を使用.
必要なモジュールと型定義ファイルのインストールを行う.

```
npm i react react-dom redux react-redux es6-shim react-dropzone --save-dev
typings i dt~react --save --global
typings i dt~react-dom --save --global
typings i dt~redux --save --global
typings i dt~react-redux --save --global
typings i dt~es6-shim --save --global
typings i dt~react-dropzone --save --global
```

プロジェクトのタスク管理を行うためのモジュールをインストール.

```
npm i gulp gulp-typescript gulp-plumber gulp-notify require-dir merge2 run-sequence webpack-stream browser-sync --save-dev
```

[Atom エディタ](https://atom.io/) で、プロジェクトルート（summarize-text-data/）直下に tsconfig.json の作成を行う.

```tsconfig.json
{
    "compilerOptions": {
        "target": "es5",
        "sourceMap": true,
        "declaration": true,
        "module": "commonjs",
        "jsx": "react",
        "noImplicitAny": false,
        "noEmitOnError": true,
        "removeComments": true,
        "experimentalDecorators": true
    },
    "compileOnSave": false,
    "filesGlob": [
        "./typings/index.d.ts",
        "./src/*.tsx",
        "./src/**/*.ts",
        "./src/**/*.tsx"
    ],
    "files": [
    ],
    "atom": {
        "rewriteTsconfig": true
    }
}
```

## Action の作成

このアプリで必要になる機能を列挙.

- アップロードされたファイル内容をフォームのテキストエリアにコピーする機能
- アップロードされたファイルがテキストファイルでなかった場合、エラーメッセージを表示する機能
- フォームのテキストエリアの内容の要約統計量を計算して表示する機能

src/actions/types.ts にこれらの機能名を列挙しておく.

```src/actions/types.ts
const COPY_FILE_CONTENTS = 'COPY_FILE_CONTENTS';
const REJECT_FILE = 'REJECT_FILE';
const SHOW_TEXT_SUMMARY = 'SHOW_TEXT_SUMMARY';

export default {
    COPY_FILE_CONTENTS,
    REJECT_FILE,
    SHOW_TEXT_SUMMARY
}
```

次に、action の型を決めて、action creater を作成する.
以下のような action creater を作成する.

- copyFileContents は、アップロードされたファイル名とその内容を引数に取り、CopyFileContentsAction 型の action を生成.
- rejectFile は、ファイルに関するエラーメッセージを引数に取り、rejectFileAction型の action を生成.
- showTextSummary は、フォームのテキストエリアのテキストを引数に取り、showTextSummaryAction型の action を生成.

### action の型の作成

```src/models/ActionBase.ts
interface ActionBase {
    type: string;
}

export default ActionBase;
```

```src/models/CopyFileContentsAction.ts
import ActionBase from './ActionBase'

interface CopyFileContents extends ActionBase {
    fileName: string;
    contents: string;
}

export default CopyFileContents;
```

```src/models/RejectFileAction.ts
import ActionBase from './ActionBase'

interface RejectFile extends ActionBase {
    errorMessage: string;
}

export default RejectFile;
```

```src/models/ShowTextSummaryAction.ts
import ActionBase from './ActionBase'

interface ShowTextSummary extends ActionBase {
    text: string;
}

export default ShowTextSummary;
```

### action creaters の作成

```src/actions/creaters.ts
import CopyFileContents from '../models/CopyFileContentsAction';
import RejectFile from '../models/RejectFileAction';
import ShowTextSummary from '../models/ShowTextSummaryAction';
import types from './types';

const copyFileContents = (fileName: string, contents: string): CopyFileContents => {
    return {
        type: types.COPY_FILE_CONTENTS,
        fileName,
        contents
    };
}

const rejectFile = (errorMessage: string): RejectFile => {
    return {
        type: types.REJECT_FILE,
        errorMessage
    }
}

const showTextSummary = (text: string): ShowTextSummary => {
    return {
        type: types.SHOW_TEXT_SUMMARY,
        text
    }
}

export default {
    copyFileContents,
    rejectFile,
    showTextSummary
}
```

## Reducer の作成

状態を設計してから、アクションが状態をどう変化させるのかを Reducer に記述する.

### 状態の設計

アプリが持つ状態のプロパティは、以下の３つとする.

1. アップロードされた file名 or エラーメッセージ
1. フォームのテキストエリアに表示するテキスト
1. フォームのテキストエリアのテキストの要約統計量

つまり、summarize-text-data は、以下のような状態を持つとする.

```src/models/AppState.ts
export default class AppState {
    fileNameOrErrorMessage: string;
    text: string;
    summary: {
        character_count: number,
        character_and_space_count: number,
        word_count: number, 
        line_number: number
    }
}
```

### action がどう状態を遷移させるのかを reducer に記述

- copyFileContents と状態の関係から

ファイルがアップロードされると、ファイル名を表示して、内容をフォームのテキストエリアにコピーする処理が必要なので、
「アップロードされたファイルの情報を読み取って、fileNameOrErrorMessage、text を更新する処理」.

- rejectFile と状態の関係から

ファイルのエラーメッセージを受け取り、表示する処理が必要なので、
「ファイルのエラーメッセージを受け取り、fileNameOrErrorMessage を更新する処理」.

- showFormSummary と状態の関係から

フォームのテキストエリアの内容が変化すると、その要約統計量を計算して表示する処理が必要なので、
「テキストエリアの内容の変化に応じて、text、summary を更新する処理」.

上記の処理ができるように reducer を作成.
残念ながら AppState からプロパティを reduce することはできなかった.

```src/reducers/copyFileAndShowSummary.ts
import AppState from '../models/AppState'
import CopyFileContents from '../models/CopyFileContentsAction';
import RejectFile from '../models/RejectFileAction';
import ShowTextSummary from '../models/ShowTextSummaryAction';
import types from '../actions/types';
import * as ES6 from 'es6-shim';

let initialState: AppState = {
    fileNameOrErrorMessage: 'No file selected',
    text: '',
    summary: {
        character_count: 0,
        character_and_space_count: 0,
        word_count: 0, 
        line_number: 0
    }
}

const copyFileAndShowSummary = (state: AppState = initialState, action: any) => {
    switch (action.type) {
        
        case types.COPY_FILE_CONTENTS:
            let copy_action: CopyFileContents = action;
            return ES6.Object.assign({}, state, {
                fileNameOrErrorMessage: copy_action.fileName,
                text: copy_action.contents
            })
        
        case types.REJECT_FILE:
            let reject_action: RejectFile = action;
            return ES6.Object.assign({}, state, {
                fileNameOrErrorMessage: reject_action.errorMessage
            })
        
        case types.SHOW_TEXT_SUMMARY:
            let summarize_action: ShowTextSummary = action;
            let line_number = summarize_action.text.split('\n').length;
            if (summarize_action.text === "") {
                line_number = 0
            }
            let char_and_space_count = summarize_action.text.length;
            // convert [\r\n\t\s]+ to spaces
            let text = summarize_action.text.replace(/[\r\n\t\s]+/g, " ").trim();
            let word_count = text.split(" ").length;
            if (text === "") {
                word_count = 0
            }
            // remove spaces
            text = text.replace(/ /g, "");
            let char_count = text.length;
            return ES6.Object.assign({}, state, {
                text: summarize_action.text,
                summary: {
                    character_count: char_count,
                    character_and_space_count: char_and_space_count,
                    word_count: word_count,
                    line_number: line_number
                }
            })
        
        default:
            return state
    
    }
}

export default copyFileAndShowSummary
```

```src/reducers/appReducers.ts
import { combineReducers } from 'redux'
import copyFileAndShowSummary from './copyFileAndShowSummary'

const appReducers = combineReducers({
  copyFileAndShowSummary
})

export default appReducers
```

## HTML components の作成

必要な HTML components を考える.

1. file をアップロードするコンポネント
2. file 内容を表示するコンポネント（onChange 機能あり）
3. テキストの要約統計量を表示するコンポネント

### 1. file をアップロードするコンポネント作成

#### Presentational components の作成

```src/components/FileDropzone.tsx
import * as React from 'react'
import * as Dropzone from 'react-dropzone';

export interface FileDropzoneProps extends React.Props<any> {
    fileNameOrErrorMessage: string,
    copyFileContentsProp: any,
    rejectFileProp: any,
    showTextSummaryProp: any
}

export default class FileDropzone extends React.Component<FileDropzoneProps, any> {
    
    private onDropAccepted = (files) => {
        let reader = new FileReader();
        reader.onload = (e) => {
            let fileName = files[0].name;
            let contents = reader.result;
            this.props.copyFileContentsProp(fileName, contents);
            this.props.showTextSummaryProp(contents);
        }
        reader.readAsText(files[0]);
    };
    
    private onDropRejected = () => {
        let errorMessage = 'This file is not valid.';
        this.props.rejectFileProp(errorMessage);
    };
    
    render () {
        return (
            <div>
                <Dropzone 
                    onDropAccepted = {this.onDropAccepted}
                    onDropRejected = {this.onDropRejected} 
                    accept = "text/plain" 
                    multiple = {false}
                >
                    <div>Try dropping a text file here, or click to select a file to upload.</div>
                </Dropzone>
                <div>
                    <h1>{this.props.fileNameOrErrorMessage}</h1>
                </div>
            </div>
        );
    }
}
```

react-dropzone では、`onDropAccepted = {this.onDropAccepted}` とすると、
this.onDropAccepted 関数の引数 files に、自動的にアップロードされたファイルのリストが格納されるようだ.

ハマったところは、java のように以下のような感じで関数を作成すると、
this.props.rejectFileProp が undefined となっていた.

```
private onDropRejected () {
    this.props.rejectFileProp(errorMessage);
};
```

おそらく以下のようなことが原因だろう. ここでは、アロー関数を使用することで解決した.
[【TypeScript】thisの使い方にハマった！thisを保持する3つの方法](http://kuroeveryday.blogspot.jp/2015/04/this.html)

#### Container components

```src/containers/FileDropzoneContainer.ts
import { connect } from 'react-redux';
import creaters from '../actions/creaters';
import FileDropzone from '../components/FileDropzone';
import AppState from '../models/AppState';

const mapStateToProps = (state) => {
    let this_state: AppState = state.copyFileAndShowSummary;
    return {
        fileNameOrErrorMessage: this_state.fileNameOrErrorMessage
    }
}

const mapDispatchToProps = (dispatch) => {
    return {
        copyFileContentsProp: (fileName, contents) => {
            dispatch(creaters.copyFileContents(fileName, contents))
        },
        rejectFileProp: (errorMessage) => {
            dispatch(creaters.rejectFile(errorMessage))
        },
        showTextSummaryProp: (contents) => {
            dispatch(creaters.showTextSummary(contents))
        },
    }
}

const FileDropzoneContainer = connect(
    mapStateToProps,
    mapDispatchToProps
)(FileDropzone)

export default FileDropzoneContainer
```

### テキストファイルの内容を表示するフォームの作成

#### Presentational components

```src/components/TextArea.tsx
import * as React from 'react'

export interface TextAreaProps extends React.Props<any> {
    showTextSummaryProp: any,
    text: string
}

export default class TextArea extends React.Component<TextAreaProps, any> {
    private showTextSummary = (e) => {
        this.props.showTextSummaryProp(e.target.value)
    };
    
    render () {
        return (
            <div>
                <textarea type="text" onChange={this.showTextSummary} value={this.props.text} />
            </div>
        );
    }
}
```

#### Container components

```src/containers/TextAreaContainer.ts
import { connect } from 'react-redux';
import creaters from '../actions/creaters';
import TextArea from '../components/TextArea';
import AppState from '../models/AppState';

const mapStateToProps = (state) => {
    let this_state: AppState = state.copyFileAndShowSummary;
    return {
        text: this_state.text
    }
}

const mapDispatchToProps = (dispatch) => {
    return {
        showTextSummaryProp: (text) => {
            dispatch(creaters.showTextSummary(text))
        }
    }
}

const TextAreaContainer = connect(
    mapStateToProps,
    mapDispatchToProps
)(TextArea)

export default TextAreaContainer
```

### 要約統計量を表示するコンポネントの作成

#### Presentational components

```src/components/Summary.tsx
import * as React from 'react'

export interface SummaryProps extends React.Props<any> {
    character_count: number,
    character_and_space_count: number,
    word_count: number,
    line_number: number
}

export default class Summary extends React.Component<SummaryProps, any> {

    render () {
        return (
            <div>
                <li>character count: {this.props.character_count}</li>
                <li>character and space count: {this.props.character_and_space_count}</li>
                <li>word count: {this.props.word_count}</li>
                <li>line number: {this.props.line_number}</li>
            </div>
        );
    }
}
```

#### Container components

```src/containers/SummaryContainer.ts
import { connect } from 'react-redux';
import Summary from '../components/Summary';
import AppState from '../models/AppState';

const mapStateToProps = (state) => {
    let this_state: AppState = state.copyFileAndShowSummary;
    return {
        character_count: this_state.summary.character_count,
        character_and_space_count: this_state.summary.character_and_space_count,
        word_count: this_state.summary.word_count,
        line_number: this_state.summary.line_number,
    }
}

const mapDispatchToProps = (dispatch) => {
    return {
    }
}

const SummaryContainer = connect(
    mapStateToProps,
    mapDispatchToProps
)(Summary)

export default SummaryContainer
```

### App.tsx の作成

コンポネントをまとめる App.tsx の作成.

```src/components/App.tsx
import * as React from 'react';
import FileDropzoneContainer from '../containers/FileDropzoneContainer';
import TextAreaContainer from '../containers/TextAreaContainer';
import SummaryContainer from '../containers/SummaryContainer';

const App = () => (
  <div>
    <FileDropzoneContainer />
    <TextAreaContainer />
    <SummaryContainer />
  </div>
)

export default App
```

## index.tsx, index.html の作成

```src/index.tsx
import * as React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import appReducer from './reducers/appReducers';
import App from './components/App';

let store = createStore(appReducer)

render(
    <Provider store={store}>
        <App />
    </Provider>,
    document.getElementById('root')
)
```

```index.html
<html>
<head>
    <title>Summarize text Data</title>
</head>
<body>
    <h1>Summarize text Data</h1>
    <div id="root"></div>
    <script src="build.js"></script>
</body>
</html>
```

## タスクを管理するファイルの作成

### typescript をコンパイルするタスク

```gulp/tasks/scripts.js
var gulp = require('gulp');
var ts = require('gulp-typescript');
var plumber = require('gulp-plumber');
var notify = require('gulp-notify');
var merge = require('merge2');

var tsProject = ts.createProject('tsconfig.json');
gulp.task('scripts', function() {
    var tsResult = tsProject.src()
        .pipe(plumber({errorHandler: notify.onError('<%= error.message %>')}))
        .pipe(ts(tsProject));

    return merge([
        tsResult.js
            .pipe(gulp.dest('dist/js')),
        tsResult.dts
            .pipe(gulp.dest('dist/dts'))
    ]);
});
```

### コンパイルされた javascript ファイルをまとめるタスク

```gulp/tasks/webpack.js
var gulp = require('gulp');
var webpack = require('webpack-stream');

gulp.task('webpack', function() {
    return gulp.src('dist/js/src/index.js')
        .pipe(webpack({
            output: {
                filename: 'build.js',
            },
            devtool: 'inline-source-map'
        }))
        .pipe(gulp.dest('.'));
});
```

### ブラウザで表示するタスク

```gulp/tasks/browser.js
var gulp = require('gulp');
var browserSync = require('browser-sync');

gulp.task('browser', function() {
    browserSync.init({
        server: '.',
        open: 'external'
    });
});

gulp.task('browser-reload', function () {
    browserSync.reload();
});
```

### gulpfile.js の作成

```gulpfile.js
var gulp = require('gulp');
var runSequence = require('run-sequence');

require('require-dir')('./gulp/tasks', {recurse: true});

gulp.task('build', function() {
    runSequence('scripts', 'webpack')
});

gulp.task('server', function() {
    runSequence('build', 'browser')
});

gulp.task('default', ['server'], function() {
    gulp.watch('src/**/*', ['build']);
    gulp.watch('*.html', ['browser-reload']);
    gulp.watch('*.js', ['browser-reload']);
});
```

### デフォルトタスクの実行

以下を実行しておくことで、ファイルに変更があると自動で更新が行われるはず.

```
gulp
```