フルスタックエンジニアに

フルスタックエンジニアを目指すブログです。主にRuby on RailsやJavaScriptについて書いていきたいと思います。

JavaScriptフレームワーク比較用サンプルアプリ 「TODO-Module-AngularJS」を作る

TODO-Module-AngularJS-eye-catch

前回TODO-ModuleのAPIを作成しましたが、今回はそのAPIを使ったSPA(シングルページアプリケーション)のクライアントを AngularJSを使って作っていきたいと思います。

デモアプリとソースコードはこちらに公開しています。

このクライアントアプリの動作確認にはAPIが必要なので、 前回までの記事を参考にローカルで動作させておくかか、 デモアプリのAPIを利用してください。

www.full-stack-engineer.com www.full-stack-engineer.com

動作環境

  • OS X Yosemite
  • node v0.10.37
  • npm 2.7.3

gulpを使っていくのでNode.jsが動く環境を用意してください。 Node.jsの環境構築の説明はここではしません。
CoffeeScriptSassを使って書いていきます。

アプリケーションを作成する

% mkdir todo-module-angularjs
% cd todo-module-angularjs

/package.json

{
  "private": true,
  "engines": {
    "node": ">=0.10.0"
  },
  "devDependencies": {
    "autoprefixer-core": "4.0.2",
    "browser-sync": "1.8.2",
    "connect-modrewrite": "0.7.11",
    "del": "1.1.1",
    "gulp": "3.6.0",
    "gulp-angular-templatecache": "1.6.0",
    "gulp-cache": "0.2.2",
    "gulp-coffee": "2.3.1",
    "gulp-csso": "0.2.6",
    "gulp-gh-pages": "0.5.0",
    "gulp-if": "1.2.1",
    "gulp-imagemin": "2.0.0",
    "gulp-inject": "1.2.0",
    "gulp-jshint": "1.5.3",
    "gulp-load-plugins": "0.8.0",
    "gulp-minify-html": "0.1.6",
    "gulp-ng-annotate": "0.5.2",
    "gulp-ng-config": "1.0.0",
    "gulp-postcss": "3.0.0",
    "gulp-sass": "1.3.3",
    "gulp-size": "1.1.0",
    "gulp-sourcemaps": "1.3.0",
    "gulp-uglify": "1.0.1",
    "gulp-useref": "1.0.2",
    "jshint-stylish": "1.0.0",
    "main-bower-files": "2.5.0",
    "opn": "1.0.0",
    "wiredep": "2.0.0"
  }
}
% npm install

/node_modulesにnpmモジュールがインストールされます。

/bower.json

{
  "name": "todo-module-angularjs",
  "private": true,
  "dependencies": {
    "bootstrap": "3.3.1",
    "angular": "1.3.14",
    "angular-route": "1.3.14",
    "angular-resource": "1.3.14",
    "angular-messages": "1.3.14"
  }
}

gulpbowerが入ってない人は以下のコマンドでインストール

% npm install bower gulp -g
% bower install

gulpfileを用意します

/gulpfile.js

/*global -$ */
'use strict';
var gulp = require('gulp');
var $ = require('gulp-load-plugins')();
var browserSync = require('browser-sync');
var reload = browserSync.reload;
var modRewrite = require('connect-modrewrite');

var nodeEnv = process.env.NODE_ENV || "development";

gulp.task('styles', function () {
  return gulp.src('app/styles/main.sass')
    .pipe($.sourcemaps.init())
    .pipe($.sass({
      outputStyle: 'nested', // libsass doesn't support expanded yet
      precision: 10,
      includePaths: ['.'],
      indentedSyntax: true,
      onError: console.error.bind(console, 'Sass error:')
    }))
    .pipe($.postcss([
      require('autoprefixer-core')({browsers: ['last 1 version']})
    ]))
    .pipe($.sourcemaps.write())
    .pipe(gulp.dest('.tmp/styles'))
    .pipe(reload({stream: true}));
});

gulp.task('scripts', function () {
  return gulp.src('app/scripts/**/*.coffee')
    .pipe($.coffee())
    .pipe($.ngAnnotate())
    .pipe(gulp.dest('.tmp/scripts'));
});

gulp.task('jshint', function () {
  return gulp.src('app/scripts/**/*.js')
    .pipe(reload({stream: true, once: true}))
    .pipe($.jshint())
    .pipe($.jshint.reporter('jshint-stylish'))
    .pipe($.if(!browserSync.active, $.jshint.reporter('fail')));
});

gulp.task('html', ['injector:js', 'styles', 'scripts', 'templates'], function () {
  var assets = $.useref.assets({searchPath: ['.tmp', 'app', '.']});

  return gulp.src('.tmp/*.html')
    .pipe(assets)
    .pipe($.if('*.js', $.uglify()))
    .pipe($.if('*.css', $.csso()))
    .pipe(assets.restore())
    .pipe($.useref())
    .pipe($.if('*.html', $.minifyHtml({conditionals: true, loose: true})))
    .pipe(gulp.dest('dist'));
});

gulp.task('images', function () {
  return gulp.src('app/images/**/*')
    .pipe($.cache($.imagemin({
      progressive: true,
      interlaced: true,
      // don't remove IDs from SVGs, they are often used
      // as hooks for embedding and styling
      svgoPlugins: [{cleanupIDs: false}]
    })))
    .pipe(gulp.dest('dist/images'));
});

gulp.task('fonts', function () {
  return gulp.src(require('main-bower-files')({
    filter: '**/*.{eot,svg,ttf,woff,woff2}'
  }).concat('app/fonts/**/*'))
    .pipe(gulp.dest('.tmp/fonts'))
    .pipe(gulp.dest('dist/fonts'));
});

gulp.task('extras', function () {
  return gulp.src([
    'app/*.*',
    '!app/*.html',
  ], {
    dot: true
  }).pipe(gulp.dest('dist'));
});

gulp.task('ngconfig', function(){
    return gulp.src(['app/config/' + nodeEnv + '.json'])
        .pipe($.ngConfig("env"))
        .pipe(gulp.dest(".tmp/scripts"));
});

gulp.task('injector:js', ['ngconfig'], function(){
    return gulp.src(['app/index.html'])
        .pipe($.inject(
            gulp.src(['.tmp/scripts/' + nodeEnv + '.js']),
            {ignorePath: ['src', '.tmp'], addRootSlash: false}
        ))
        .pipe(gulp.dest(".tmp"))
});

gulp.task('templates', function(){
  return gulp.src('app/scripts/**/*.html')
    .pipe($.angularTemplatecache("templates.js", {module: "app"}))
    .pipe(gulp.dest(".tmp/scripts"))
});

gulp.task('deploy', ['build'], function(){
  return gulp.src('dist/**/*')
    .pipe($.ghPages());
});

gulp.task('clean', require('del').bind(null, ['.tmp', 'dist']));

gulp.task('serve', ['styles', 'scripts', 'injector:js', 'templates', 'fonts'], function () {
  browserSync({
    notify: false,
    port: 9000,
    server: {
      baseDir: ['.tmp', 'app'],
      routes: {
        '/bower_components': 'bower_components'
      },
      middleware: [
        modRewrite([
          '!\\.\\w+$ /index.html [L]' 
        ])
      ]
    }
  });

  // watch for changes
  gulp.watch([
    '.tmp/*.html',
    'app/scripts/**/*.js',
    '.tmp/scripts/**/*.js',
    'app/images/**/*',
    '.tmp/fonts/**/*'
  ]).on('change', reload);

  gulp.watch('app/styles/**/*.css', ['styles']);
  gulp.watch('app/scripts/**/*.html', ['templates']);
  gulp.watch('app/scripts/**/*.coffee', ['scripts']);
  gulp.watch('app/index.html', ['injector:js']);
  gulp.watch('app/config/*.json', ['injector:js']);
  gulp.watch('app/fonts/**/*', ['fonts']);
  gulp.watch('bower.json', ['wiredep', 'fonts']);
});

// inject bower components
gulp.task('wiredep', function () {
  var wiredep = require('wiredep').stream;

  gulp.src('app/*.html')
    .pipe(wiredep({
      ignorePath: /^(\.\.\/)*\.\./
    }))
    .pipe(gulp.dest('app'));
});

gulp.task('build', ['jshint', 'html', 'images', 'fonts', 'extras'], function () {
  return gulp.src('dist/**/*').pipe($.size({title: 'build', gzip: true}));
});

gulp.task('default', ['clean'], function () {
  gulp.start('build');
});

/app/index.html

<!doctype html>
<html>
  <head>
    <title>TODO-Module AngularJS</title>
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
    <meta charset="utf-8">

    <!-- build:css /styles/vendor.css -->
    <!-- bower:css -->
    <link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css" />
    <!-- endbower -->
    <!-- endbuild -->
    
    <!-- build:css /styles/main.css -->
    <link rel="stylesheet" media="all" href="/styles/main.css">
    <!-- endbuild -->
  </head>

  <body>

    <header>
      <nav class="navbar navbar-default" role="navigation">
        <div class="container-fluid">
          <div class="navbar-header">
            <button class="navbar-toggle collapsed" data-target="#navbar" data-toggle="collapse" type="button">
              <span class="sr-only"></span>
              <span class="icon-bar"></span>
              <span class="icon-bar"></span>
              <span class="icon-bar"></span>
            </button>
            <a class="navbar-brand" href="/">TODO-Module AngularJS</a>
          </div>
          <div class="collapse navbar-collapse" id="navbar">
            <ul class="nav navbar-nav navbar-right">
              <li><a href="/">ログイン</a></li>
              <li><a href="users/new">新規登録</a></li>
            </ul>
            <ul class="nav navbar-nav navbar-right">
              <li><p class="email navbar-text"></p></li>
              <li><a id="logout" href="/logout">ログアウト</a></li>
            </ul>
          </div>
        </div>
      </nav>
    </header>
    <div class="container-fluid">

      <div class="alert alert-dismissible fade in">
        <button type="button" class="close" data-dismiss="alert"><span>&times;</span></button>
        <p></p>
      </div>

    </div>

    <!-- build:js /scripts/vendor.js -->
    <!-- bower:js -->
    <script src="/bower_components/jquery/dist/jquery.js"></script>
    <script src="/bower_components/bootstrap/dist/js/bootstrap.js"></script>
    <script src="/bower_components/angular/angular.js"></script>
    <script src="/bower_components/angular-route/angular-route.js"></script>
    <script src="/bower_components/angular-resource/angular-resource.js"></script>
    <script src="/bower_components/angular-messages/angular-messages.js"></script>
    <!-- endbower -->
    <!-- endbuild -->

    <!-- build:js /scripts/main.js -->
    <!-- inject:js -->
    <!-- endinject -->
    <!-- endbuild -->
  </body>
</html>

/app/styles/main.sass

a
  cursor: pointer

#sidebar
  height: 100%
  background-color: #fafafa
  border: 1px solid #e5e5e5
  padding-left: 0px
  margin-bottom: 20px
  h3
    color: #AAA
    font-size: 12px
    padding-left: 15px
  ul
    li
      line-height: 30px
      a
        padding-left: 25px
        display: block
      &.current
        border-left: 5px solid #819dc1
        a
          padding-left: 20px

#tasks
  li
    @extend .clearfix
    border-bottom: 1px solid #ededed
    line-height: 50px
    padding-left: 5px

.new-task-well
  padding: 10px
  .form-group
    margin-bottom: 0px
% gulp serve

http://localhost:9000 にアクセスするとページが表示されます。

TODO-Module-AngularJS トップ1

main.coffeeを作る

まずはメインとなる/app/scripts/main.coffeeを作ります。 ここではAngularアプリケーションを作成し、ルーティングを定義します。

/app/scripts/main.coffee

app = angular.module('app', ['ngRoute', 'ngMessages', 'ngResource'])

app.config(($routeProvider, $locationProvider) ->

  $locationProvider.html5Mode(true)

  $routeProvider
    .when('/', {
      controller: 'LoginController'
      controllerAs: "login"
      templateUrl: 'sessions/login.html'
    }).when('/users/new', {
      controller: 'UserController'
      controllerAs: "user"
      templateUrl: 'users/new.html'
    }).when('/tasks', {
      controller: 'TasksController'
      controllerAs: "tasks"
      templateUrl: 'tasks/index.html'
    }).when('/tasks/:state', {
      controller: 'TasksController'
      controllerAs: "tasks"
      templateUrl: 'tasks/index.html'
    }).when('/tasks/:id/edit', {
      controller: 'TaskEditController'
      controllerAs: "edit"
      templateUrl: 'tasks/edit.html'
    }).when('/logout', {
      controller: 'LogoutController'
      controllerAs: "logout"
      template: ''
    }).otherwise({
      redirectTo: '/'
    })
)

/app/index.html

<html ng-app="app">
・
・
    <!-- build:js /scripts/main.js -->
    <!-- inject:js -->
    <!-- endinject -->
    <script src="/scripts/main.js"></script>
    <!-- endbuild -->
    <body>
</html>

エラーが出ていないことを確認しましょう。

authServiceを作る

まずはセッションを管理するauthServiceから作成します。

/app/scripts/components/auth.service.coffee

angular.module('app').factory('authService',
  ($http, $q, $resource, $rootScope, apiHost) ->

    user = null
    error = null
    Session = $resource("#{apiHost}/v1/sessions")

    setUser = (_user) ->
      if _user?
        user = _user
        localStorage.user = angular.toJson(user)
        $http.defaults.headers.common['Authorization'] = user.authentication_token
        $rootScope.$broadcast('authService:changeCurrentUser')

    setUser(angular.fromJson(localStorage.user))

    return {
      currentUser: -> user

      login: (email, password) ->

        deferred = $q.defer()

        session = new Session(email: email, password: password)
        session.$save((user, headers) =>
          @setUser(user)
          deferred.resolve()
        , ->
          error = 'ログインに失敗しました'
          deferred.reject()
        )

        return deferred.promise

      logout: ->
        user = null
        localStorage.removeItem('user')
        $http.defaults.headers.common['Authorization'] = null
        $rootScope.$broadcast('authService:changeCurrentUser')

      getError: -> error

      setUser: setUser
    }
)

このサービスは主にcurrentUser, login, logoutメソッドを提供します。
loginメソッドではAPIを叩いてログイン処理を行い、成功した場合はユーザー情報をローカルストレージに保存します。
currentUserメソッドはログイン中であればユーザー情報を返します。
logoutメソッドはローカルストレージからユーザー情報を削除します。

ここでapiHostという変数をインジェクションしています。 これはAPIのホスト名なのですが、開発環境と本番環境でホスト名を変える必要があったりするので ソース内にハードコーディングせずにインジェクションすることによって、簡単に変えることができます。

このような設定値を簡単に扱えるgulpプラグインがgulp-ng-configです。

gulpfile.jsにはすでにタスクは定義されています。 環境変数のNODE_ENVに応じて読み込まれるファイルが変わるようになっています。 デフォルトはdevelopmentになっているので、何も指定せずにgulp serveを実行すると

<!-- inject:js -->
<!-- endinject -->

この間にdevelpment.jsのscriptタグが挿入される仕組みです。

本番環境で実行したい場合は

% NODE_ENV=production gulp serve

と起動するとproduction.jsが読み込まれます。

/app/config/development.json

{
    "apiHost": "http://localhost:3000"
}

/app/scripts/main.coffee

-app = angular.module('app', ['ngRoute', 'ngMessages', 'ngResource'])
+app = angular.module('app', ['ngRoute', 'ngMessages', 'ngResource', 'env'])

/app/index.html

・
・
<!-- build:js /scripts/main.js -->
<!-- inject:js -->
<!-- endinject -->
<script src="/scripts/main.js"></script>
<script src="/scripts/components/auth.service.js"></script>
<!-- endbuild -->
・
・

これでapiHosthttp://localhost:3000が入りました。 ローカルのAPIではなくデモアプリのAPIを利用したい場合は http://localhost:3000の代わりにhttp://todo-module.herokuapp.com/を設定してください。
本番環境の設定値を定義したい場合は同様に/app/config/production.jsonを作成し

% NODE_ENV=production gulp serve

のようにgulpを起動するとproduction.jsonが読み込まれます。

LayoutControllerを作る

次にページ全体を管理するLayoutControllerを作りましょう。

/app/scripts/layout/layout.controller.coffee

angular.module('app').controller('LayoutController',
  ($scope, $location, $timeout, authService) ->

    vm = this
    vm.flash = null

    $scope.$on('Layout:flash', (event, flash) ->
      vm.flash = flash
    )

    $scope.$on('Layout:clearFlash', () ->
      vm.flash = null
    )

    updateCurrentUser = ->
      vm.currentUser = authService.currentUser()

    $scope.$on('authService:changeCurrentUser', updateCurrentUser)
    updateCurrentUser()

    return
)

/app/index.html

<html>
    ・
    ・
  <head>


    <base href="/">
    <style>
      [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak {
        display: none !important;
      }
    </style>
  </head>

  <body ng-controller="LayoutController as layout" ng-cloak>

    <header>
      <nav class="navbar navbar-default" role="navigation">
        <div class="container-fluid">
          <div class="navbar-header">
            <button class="navbar-toggle collapsed" data-target="#navbar" data-toggle="collapse" type="button">
              <span class="sr-only"></span>
              <span class="icon-bar"></span>
              <span class="icon-bar"></span>
              <span class="icon-bar"></span>
            </button>
            <a class="navbar-brand" href="/">TODO-Module AngularJS</a>
          </div>
          <div class="collapse navbar-collapse" id="navbar">
            <ul ng-if="!layout.currentUser" class="nav navbar-nav navbar-right">
              <li><a href="/">ログイン</a></li>
              <li><a href="users/new">新規登録</a></li>
            </ul>
            <ul ng-if="layout.currentUser" class="nav navbar-nav navbar-right">
              <li><p class="email navbar-text" ng-bind="layout.currentUser.email"></p></li>
              <li><a id="logout" href="/logout">ログアウト</a></li>
            </ul>
          </div>
        </div>
      </nav>
    </header>

    <div class="container-fluid">

      <div ng-if="layout.flash" class="alert alert-dismissible fade in" ng-class="layout.flash.class">
        <button type="button" class="close" data-dismiss="alert"><span>&times;</span></button>
        <p>{{layout.flash.message}}</p>
      </div>

    </div>
      ・
      ・
    <!-- build:js /scripts/main.js -->
    <!-- inject:js -->
    <!-- endinject -->
    <script src="/scripts/main.js"></script>
    <script src="/scripts/components/auth.service.js"></script>
    <script src="/scripts/layout/layout.controller.js"></script>
    <!-- endbuild -->
  </body>
</html>

ブラウザで確認してみましょう。 TODO-Module-AngularJS トップ2

右上のメニューからログアウトが消えましたね。 ng-ifを使ってログイン時とそうでない時のリンクを出し分けています。
またFlashメッセージがセットされた時に表示されるようにしました。
ついでに、ページが読み込まれてからAngularが実行されるまでの間に画面がちらつくのを防ぐために、 bodyタグにng-cloakをつけておきます。

LoginControllerを作る

次にログインページを作りましょう。

/app/scripts/sessions/login.controller.coffee

angular.module('app').controller('LoginController',
  ($scope, $location, authService) ->

    vm = this
    vm.submit = ->
      authService.login(vm.email, vm.password).then(
        ->
          $location.path('/tasks')
          $scope.$emit('Layout:flash', {class: 'alert-info', message: 'ログインしました。'})
        , ->
          alert authService.getError()
      )

    return
)

/app/scripts/sessions/logout.controller.coffee

angular.module('app').controller('LogoutController',
  ($scope, $location, authService) ->

    authService.logout()
    $location.path('/')

    $scope.$emit('Layout:flash', {class: 'alert-info', message: 'ログアウトしました。'})

    return
)

/app/scripts/sessions/login.html

<h1>ログイン</h1>

<div class="well">
  <form id="new_user" name="loginForm" ng-submit="login.submit()" novalidate>

    <div class="form-group email required user_email" ng-class="{'has-error': loginForm.email.$dirty && loginForm.email.$invalid}">
      <label class="control-label" for="user_email">
        <abbr title="required">*</abbr>
        メールアドレス
      </label>
      <input class="form-control" type="email" name="email" id="user_email" ng-model="login.email" required/>
      <span class="help-block" ng-messages="loginForm.email.$error" ng-if="loginForm.email.$dirty">
        <span ng-message="required">入力してください。</span>
        <span ng-message="email">無効なメールアドレスです。</span>
      </span>
    </div>

    <div class="form-group password required user_password" ng-class="{'has-error': loginForm.password.$dirty && loginForm.password.$invalid}">
      <label class="control-label" for="user_password">
        <abbr title="required">*</abbr>
        パスワード
      </label>
      <input class="form-control" type="password" name="password" id="user_password" ng-model="login.password" required minlength="8"/>
      <span class="help-block" ng-messages="loginForm.password.$error" ng-if="loginForm.password.$dirty">
        <span ng-message="required">入力してください。</span>
        <span ng-message="minlength">8文字以上で入力してください。</span>
      </span>
    </div>

    <input ng-disabled="!loginForm.$valid" type="submit" value="Login" class="btn btn-primary btn-block">

  </form>
</div>

/app/index.html

  ・
  ・
<div class="container-fluid">

  <div ng-if="layout.flash" class="alert alert-dismissible fade in" ng-class="layout.flash.class">
    <button type="button" class="close" data-dismiss="alert"><span>&times;</span></button>
    <p>{{layout.flash.message}}</p>
  </div>

  <ng-view></ng-view> <!-- これを追加 -->
</div>
  ・
  ・
<!-- build:js /scripts/main.js -->
<!-- inject:js -->
<!-- endinject -->
<script src="/scripts/main.js"></script>
<script src="/scripts/templates.js"></script>
<script src="/scripts/components/auth.service.js"></script>
<script src="/scripts/layout/layout.controller.js"></script>
<script src="/scripts/sessions/login.controller.js"></script>
<!-- endbuild -->
  ・
  ・

ログイン画面が表示されましたね。 TODO-Module-AngularJS ログイン

Railsアプリでユーザーを作っておいて、そのアカウントでログインしてみましょう。 ログインできれば以下のようにURLがhttp://localhost:9000/tasksになってFlashメッセージが表示されます。 また、右上のメニューにメールアドレスが表示され、ログアウトボタンも出現します。 TasksControllerを作っていないのでエラーが出ますが、問題ありません。

Chromeのデベロッパーツールでローカルストレージにユーザー情報が保存されているのも確認できます。 ログアウトするとこの情報が消えることも確認して下さい。

TODO-Module-AngularJS ローカルストレージ

UserControllerを作る

次はユーザー登録画面を作ります

/app/scripts/users/user.controller.coffee

angular.module('app').controller('UserController',
  ($scope, $resource, $location, authService, apiHost) ->

    vm = this

    User = $resource("#{apiHost}/v1/users")

    vm.submit = ->
      angular.forEach($scope.newUserForm, (element, fieldName) ->
        return if fieldName[0] == '$'
        element.$pristine = false
        element.$dirty = true
      )

      return unless $scope.newUserForm.$valid

      user = new User(user: {
        email: vm.email,
        password: vm.password,
        password_confirmation: vm.password_confirmation
      })

      user.$save((user, headers) ->
        authService.setUser(user)
        $location.path('/tasks')
      , (error) ->
        alert error.data.join('\n')
      )

    return
)

/app/scripts/users/new.html

<h1>新規登録</h1>

<div class="well">
  <form id="new_user" name="newUserForm" ng-submit="user.submit()" novalidate="novalidate">

    <div class="form-group email required user_email" ng-class="{'has-error': newUserForm.email.$dirty && newUserForm.email.$invalid}">
      <label class="control-label" for="user_email">
        <abbr title="required">*</abbr>
        メールアドレス
      </label>
      <input id="user_email" class="form-control" type="email" name="email" ng-model="user.email" required/>
      <span class="help-block" ng-messages="newUserForm.email.$error" ng-if="newUserForm.email.$dirty">
        <span ng-message="required">入力してください。</span>
        <span ng-message="email">無効なメールアドレスです。</span>
      </span>
    </div>

    <div class="form-group password required user_password" ng-class="{'has-error': newUserForm.password.$dirty && newUserForm.password.$invalid}">
      <label class="control-label" for="user_password">
        <abbr title="required">*</abbr>
        パスワード
      </label>
      <input id="user_password" class="form-control" type="password" name="password" ng-model="user.password" required minlength="8"/>
      <span class="help-block" ng-messages="newUserForm.password.$error" ng-if="newUserForm.password.$dirty">
        <span ng-message="required">入力してください。</span>
        <span ng-message="minlength">8文字以上で入力してください。</span>
      </span>
    </div>

    <div class="form-group password required user_password_confirmation" ng-class="{'has-error': newUserForm.password_confirmation.$dirty && newUserForm.password_confirmation.$invalid}">

      <label class="control-label" for="user_password_confirmation">
        <abbr title="required">*</abbr>
        パスワード(確認)
      </label>

      <input id="user_password_confirmation" class="form-control" type="password" name="password_confirmation"
        ng-model="user.password_confirmation" required minlength="8" compare-to="user.password"/>

      <span class="help-block" ng-messages="newUserForm.password_confirmation.$error" ng-if="newUserForm.password_confirmation.$dirty">
        <span ng-message="required">入力してください。</span>
        <span ng-message="minlength">8文字以上で入力してください。</span>
        <span ng-message="compareTo">パスワードが一致しません。</span>
      </span>
    </div>

    <input type="submit" value="Save" class="btn btn-primary btn-block">
  </form>
</div>

パスワードとパスワード(確認)の内容が一致してるかのバリデーションをしたいのですが、 AngularJSには用意されていないので、ディレクティブを作成します。

/app/scripts/components/compare-to.directive.coffee

angular.module('app').directive('compareTo', -> {
  require: 'ngModel'
  scope:
    compareTo: '='
  
  link: (scope, element, attributes, ngModel) ->

    ngModel.$validators.compareTo = (modelValue) ->
      modelValue == scope.compareTo

    scope.$watch('compareTo', ->
      ngModel.$validate()
    )
})

/app/index.html

<!-- build:js /scripts/main.js -->
<!-- inject:js -->
<!-- endinject -->
  ・
  ・
<script src="/scripts/components/compare-to.directive.js"></script>
<script src="/scripts/users/user.controller.js"></script>
<!-- endbuild -->

ユーザー登録ができることを確認しましょう。 TODO-Module-AngularJS 新規登録

タスク機能を作る

ではタスク周りを作っていきます。 まずはタスクのデータを管理するtaskServiceを作ります。

/app/scripts/components/task.service.coffee

angular.module('app').factory('taskService',
  ($resource, apiHost) ->

    @resource = $resource("#{apiHost}/v1/tasks/:id/:action", {id: '@id'}, {
      update: {method: 'PUT'}
      complete: {method: 'PUT', params: {action: 'complete'}}
      revert: {method: 'PUT', params: {action: 'revert'}}
    })

    return {
      query: =>
        @tasks = @resource.query() unless @tasks
        return @tasks
        
      resource: =>
        @resource
    }
)

CRUDの他にcompleterevertを定義します。

/app/scripts/tasks/tasks.controller.coffee

angular.module('app').controller('TasksController',
  ($scope, $resource, $http, $routeParams, $location, $filter, taskService) ->

    vm = this

    vm.state = $routeParams.state || 'inbox'

    vm.task_count = {}
    vm.tasks = taskService.query()

    vm.submit = ->
      if vm.new_task_title?.trim()
        Task = taskService.resource()
        task = new Task({title: vm.new_task_title})
        task.$save( ->
          vm.tasks.push(task)
          $scope.$emit('Layout:flash', {class: 'alert-info', message: '作成しました。'})
        )
        vm.new_task_title = ''

    vm.done = (task) ->
      task.$complete(->
        $scope.$emit('Layout:flash', {class: 'alert-info', message: '完了にしました。'})
      )

    vm.delete = (task) ->
      task.$delete(->
        $scope.$emit('Layout:flash', {class: 'alert-info', message: 'ゴミ箱に入れました。'})
      )

    vm.revert = (task) ->
      task.$revert(->
        $scope.$emit('Layout:flash', {class: 'alert-info', message: '収集箱に戻しました。'})
      )

    $scope.$watch('tasks', ->
      angular.forEach(['inbox', 'completed', 'deleted'], (state) ->
        vm.task_count[state] = $filter('filter')(vm.tasks, {aasm_state: state}).length
      )
    , true)

    $(window).bind('hashchange', ->
      vm.state = $location.hash() || 'inbox'
    )

    return
)

/app/scripts/tasks/index.html

<div class="row">
  <div class="col-sm-2" id="sidebar">
    <h3>収集</h3>
    <ul class="list-unstyled">
      <li ng-class="{current: tasks.state == 'inbox'}">
        <a class="clearfix" href="/tasks">
          <div class="pull-left">
            <i class="glyphicon glyphicon-inbox"></i>
            収集箱
          </div>
          <div class="pull-right inbox-count">{{tasks.task_count["inbox"]}}</div>
        </a>
      </li>
    </ul>
    <h3>終了</h3>
    <ul class="list-unstyled">
      <li ng-class="{current: tasks.state == 'completed'}">
        <a class="clearfix" href="/tasks/completed">
          <div class="pull-left">
            <i class="glyphicon glyphicon-ok-sign"></i>
            完了
          </div>
          <div class="pull-right completed-count">{{tasks.task_count["completed"]}}</div>
        </a>
      </li>
      <li ng-class="{current: tasks.state == 'deleted'}">
        <a class="clearfix" href="/tasks/deleted">
          <div class="pull-left">
            <i class="glyphicon glyphicon-trash"></i>
            ゴミ箱
          </div>
          <div class="pull-right deleted-count">{{tasks.task_count["deleted"]}}</div>
        </a>
      </li>
    </ul>
  </div>
  <div class="col-sm-10">
    <form id="new_task" class="new_task" ng-submit="tasks.submit()">
      <div class="input-group">
        <input class="form-control" placeholder="タスク" type="text" id="task_title" ng-model="tasks.new_task_title">
        <span class="input-group-btn">
          <input type="submit" name="commit" value="登録" class="btn btn-success">
        </span>
      </div>
    </form>

    <ul class="list-unstyled" id="tasks">
      <li class="task clearfix" ng-repeat="task in tasks.tasks | filter:{aasm_state: tasks.state} | orderBy:'id':true">
        <div class="pull-left">
          <a ng-href="tasks/{{task.id}}/edit">{{task.title}}</a>
        </div>
        <div class="pull-right">
          <a ng-click="tasks.done(task)" ng-if="task.aasm_state == 'inbox'" class="task-complete btn btn-primary">完了</a>
          <a ng-click="tasks.revert(task)" ng-if="task.aasm_state != 'inbox'" class="task-revert btn btn-default">戻す</a>
          <a ng-click="tasks.delete(task)" ng-if="task.aasm_state != 'deleted'" class="task-delete btn btn-danger">削除</a>
        </div>
      </li>
    </ul>
  </div>
</div>

/app/index.html

<!-- build:js /scripts/main.js -->
<!-- inject:js -->
<!-- endinject -->
  ・
  ・
<script src="/scripts/components/task.service.js"></script>
<script src="/scripts/tasks/tasks.controller.js"></script>
<!-- endbuild -->

タスク画面が表示されました。 タスクを登録したり完了にしてみたりしましょう。

TODO-Module-AngularJS タスク

最後にタスク編集画面をつくります。

/app/scripts/tasks/task-edit.controller.coffee

angular.module('app').controller('TaskEditController',
  ($scope, $location, $routeParams, taskService) ->

    vm = this

    taskService.query().$promise.then((tasks) ->
      $.each(tasks, (index, task) ->
        if task.id == parseInt($routeParams.id)
          vm.task = task
          return false
      )
    )

    vm.submit = ->

      return unless $scope.taskForm.$valid
      
      vm.task.$update( ->
        $location.path('/tasks')
        $scope.$emit('Layout:flash', {class: 'alert-info', message: '更新しました。'})
      )

    return
)

/app/scripts/tasks/edit.html

<h2>タスク編集</h2>
<div class="well">
  <form class="edit_task" name="taskForm" ng-submit="edit.submit()" novalidate="novalidate">

    <div class="form-group string required task_title" ng-class="{'has-error': taskForm.title.$dirty && taskForm.title.$invalid}">
      <label class="control-label" for="task_title">
        <abbr title="required">*</abbr>
        タイトル
      </label>
      <input class="form-control" type="text" name="title" id="task_title" ng-model="edit.task.title" required>
      <span class="help-block" ng-messages="taskForm.title.$error" ng-if="taskForm.title.$dirty">
        <span ng-message="required">入力してください。</span>
      </span>
    </div>

    <div class="form-group text optional task_memo">
      <label class="control-label" for="task_memo">メモ</label>
      <textarea class="form-control" id="task_memo" ng-model="edit.task.memo"></textarea>
    </div>
    <input type="submit" name="commit" value="Save" class="btn btn-primary btn-block">
  </form>
</div>

/app/index.html

<!-- build:js /scripts/main.js -->
<!-- inject:js -->
<!-- endinject -->
  ・
  ・
<script src="/scripts/tasks/task-edit.controller.js"></script>
<!-- endbuild -->

タスク編集も行うことができるようになりました。

TODO-Module-AngularJS タスク編集

認証チェックをする

一通りの機能はできましたが、 今のままだとログインしていない状態でhttp://localhost:3000/tasksにアクセスが出来てしまいますのでちゃんとログインチェックを行いましょう。
URLが変わって処理が開始される前に$routeChangeStartイベントが発生するので、 ここでログインチェックをします。

/app/scripts/main.coffee

app.run(($rootScope, $location, $route, authService) ->

  $rootScope.$on('$routeChangeStart', (event, next, current) ->

    if next.controller == 'LoginController' || next.controller == 'UserController'
      if authService.currentUser()
        $location.path('/tasks')
    else
      unless authService.currentUser()
        $location.path('/')
  )
)

これでログインしてない状態でhttp://localhost:3000/tasksにアクセスしてもログイン画面に飛ばされます。 またログインしてる状態でhttp://localhost:3000などにアクセスしてもタスク画面に飛ばされます。

これで完成です!
REST APIを叩く部分は$resourceが全部やってくれるので非常に楽ですね。 AngularJS独自の記法が多いので初めは難しいと感じるかもしれませんが、 慣れてしまえば非常にサクサク作ることができます。

次の記事 www.full-stack-engineer.com