まだ間に合う: 冬休みなので、1日でRuby on Rails + Herokuで新年の目標管理ツールを作る

# 概要
* 最近アプリばかり作っているので、冬休みに久しぶりにウェブサービスを作ることにした。
* Ruby on Railsを使って目標管理ツールを作った。(2019.11 Rails 6.0.1で動作確認)
* Herokuにデプロイした。

ちょっと遠回りで省略可能なステップもありますが、わかりやすさ重視のステップで書いていきます。

## 前提
* 1度くらいRuby on Railsに触れたことがある人

長くなりすぎるので、Ruby on Railsの基礎的な使い方に関しては省略。

# 作るもの
実際にデプロイ・公開したものはこちら http://achievements.nantekottai.com/

![スクリーンショット 2017-01-02 15.18.23.png](https://qiita-image-store.s3.amazonaws.com/0/14097/37afa13a-a8c6-2f63-fc9a-a97e4f913472.png)

* 目標管理ツール
* 目標リスト(List)に、複数の目標(Achievement)を登録できる
* 目標は「達成」することができる

# 使うもの
* Ruby on Rails 6
* Twitter Bootstrap 3
  * 間も無く4がリリースされるが、現行版は3なので3を使う。
  * [twitter-bootstrap-rails](https://github.com/seyhunak/twitter-bootstrap-rails)を使って導入
* Heroku
  * Herokuのアカウントが必要
  * [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli)をインストールしておく
* Postgres
  * HerokuではSQLiteは使えず、Postgresを使うことになる。 参考: [SQLite on Heroku](https://devcenter.heroku.com/articles/sqlite3) 

# エディタ
2016年現在、Railsの開発に使うエディタとしてはatomが良い感じ。

![atom-rails.png](https://qiita-image-store.s3.amazonaws.com/0/14097/abe69f9a-501c-2dcb-639d-e508a8b356ed.png)

特に、この記事 [AtomでRailsを爆速開発する環境を作ってみた](http://qiita.com/tacumai/items/e84e586b5bde2979a066) に従ってパッケージを入れると、大分快適な開発環境が整った。`browser-sync`すら不要。

2016年12月31日現在、`browser-plus`はうまく動作しなかったので、代わりに[platformio-ide-terminal](https://atom.io/packages/platformio-ide-terminal)を使うようにした。

# プロジェクトの作成

## プロジェクトを作成する前に
HerokuがサポートしているRubyのバージョンを確認しておきましょう。Herokuが対応していない古いバージョンを使っている場合は、最新のRubyをインストールしてから作業を開始してください。

https://devcenter.heroku.com/articles/ruby-support#supported-runtimes

## 新しいプロジェクトの作成
```
rails new my-achievements -d postgresql
```

Heroku上で運用することを考えると、最初からPostgresを使う設定でプロジェクトを作っておいた方が良い。参考: https://devcenter.heroku.com/articles/sqlite3

プロジェクトを生成したら、atomを起動する。

```
cd my-achievements
atom .
```

## DBの作成
初回なので、`development`と`test`用のPostgresのDBを作成する。

atom上で、`Ctrl`+`Shift`+`@`でターミナルを起動したら、`db:create`を実行する。(Rails 5から、`rake`コマンドではなく`rails`コマンドを使う形が推奨されるようになった)

```
rails db:create
```

ここでエラーが起きる場合は、Postgresのインストールに失敗している、サービスの起動の設定がされていない可能性があるので、`brew info postgres`などで確認する。

## 動作確認
まだ何もない状態だが、DBの作成までできたところで一旦起動確認しておく。

![atom-browser.png](https://qiita-image-store.s3.amazonaws.com/0/14097/c66feeb6-5273-87c4-6fc5-cf03830ef77a.png)

* atomのターミナル上で、`rails s`コマンドでサーバーを起動する。
* `Ctrl`+`Opt`+`O`で、ブラウザータブを開き、`http://localhost:3000/`を開く。
* 右上の雷マークを押しておくと、ライブリロードが有効になる。(ファイルを更新すると自動的にページが再読み込みされる)

## gitレポジトリの作成
Herokuにデプロイするタイミングでも必要になるので、プロジェクト用のgitレポジトリを作っておく。

```
git init
git add .
git commit -m "First commit"
```

# Scaffoldで土台を作る

一旦`Ctrl`+`C`でrailsサーバーを終了してから、`rails`コマンドで`List`のscaffoldを作り、マイグレーションを走らせる。その後、`rails s`でサーバーを再起動する。

```
rails g scaffold List name:string
rails db:migrate
rails s
```

ブラウザタブで`http://localhost:3000/lists`を開き、`List`のscaffoldが正しく動作することを確認する。

## 自動リロードの確認
`http://localhost:3000/lists`を開いた状態で、`index.html.erb`の中身を編集・保存すると、ブラウザの自動リロードが発生して画面に変更が反映されることを確認する。

# HamlとBootstrapの導入
## Hamlの導入

Gemfileに`haml-rails`を追加して、`bundle install`する。

```
gem "haml-rails", "~> 2.0"
```

`haml:erb2haml`コマンドを使うと、プロジェクト内のerbファイルをまとめてhamlファイルに変換できる。

```
rails haml:erb2haml
```

## Bootstrapの導入

```
gem "therubyracer"
gem "twitter-bootstrap-rails"
gem "jquery-rails"
```

Ruby 2.4.0 + Rails 5の組み合わせでは、`uglifier`で問題が発生することがあるため、修正されている`therubyracer`を使っておく。

lessを使わない場合は `gem "twitter-bootstrap-rails"` だけを追加する形でOK。

`bundle install`した後、`generate bootstrap:install static --no-coffeescript`でプロジェクトにBootstrap関連のアセットを追加する。lessやcoffeescriptは使わないので、`static`オプションと`--no-coffeescript`オプションを指定した。

```
rails generate bootstrap:install static --no-coffeescript
```

また、`bootstrap:layout`を実行すると、レイアウトテンプレートにbootstrapの雛形を適用できる。(現在の内容は失われるので注意)

```
rails g bootstrap:layout application # application.html.haml にbootstrapの雛形が適用される
```

ただし、生成されるレイアウトに含まれるnavbarとsidebarは今回は使わないので、消しておく。

```haml:application.html.haml
!!! 5
%html(lang="en")
  %head
    %meta(charset="utf-8")
    %meta(http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1")
    %meta(name="viewport" content="width=device-width, initial-scale=1.0")
    %title= content_for?(:title) ? yield(:title) : "MyAchievements"
    = csrf_meta_tags
    = stylesheet_link_tag "application", :media => "all"
    = favicon_link_tag 'apple-touch-icon-144x144-precomposed.png', :rel => 'apple-touch-icon-precomposed', :type => 'image/png', :sizes => '144x144'
    = favicon_link_tag 'apple-touch-icon-114x114-precomposed.png', :rel => 'apple-touch-icon-precomposed', :type => 'image/png', :sizes => '114x114'
    = favicon_link_tag 'apple-touch-icon-72x72-precomposed.png', :rel => 'apple-touch-icon-precomposed', :type => 'image/png', :sizes => '72x72'
    = favicon_link_tag 'apple-touch-icon-precomposed.png', :rel => 'apple-touch-icon-precomposed', :type => 'image/png'
    = favicon_link_tag 'favicon.ico', :rel => 'shortcut icon'
    = javascript_include_tag "application"
    / Le HTML5 shim, for IE6-8 support of HTML elements
    /[if lt IE 9]
      = javascript_include_tag "//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.2/html5shiv.min.js"


  %body
    = bootstrap_flash
    = yield
    %footer
      %p &copy; Company 2017

```
### Rails 5.1対策
Rails 5.1以降では、`favicon_link_tag`で下記のようなエラーが発生する

```
The asset "apple-touch-icon-144x144-precomposed.png" is not present in the asset pipeline.
```

これは、faviconで指定しているアイコンがassetパイプライン上に存在しないことによるもの。本来であればアイコンを用意することが正しい解決方法になるが、アイコンを用意するのが手間な場合は一旦これらの行をコメントアウトしてしまうのが良い。

### Asset `application.js` was not declared to be precompiled in production. エラーが出る場合
`app/assets/config/manifest.js`に`//= link application.js`を追加して、サーバーを再起動する。

# Herokuにデプロイする
一旦ベースの構造ができたところで、Herokuにデプロイしてみる。

## gitコミットの作成
作業中のファイルを、gitレポジトリにコミットしておく。

```
git add .
git commit -m "lists scaffold with haml and bootstrap"
```

## Herokuへのデプロイ
```
heroku create
```

アプリ・レポジトリができたら、pushするとデプロイが始まる。

```
git push heroku master
```

なお、Bundlerのバージョンが`2.0.1`の場合にデプロイエラーが発生してしまうようだ。`Could not detect rake tasks`が発生する場合は、`bundle --version`でバージョンを確認し、`2.0.1`だった場合は下記の手順を踏んで`2.0.2`にアップデートしよう。

* `rm Gemfile.lock`で一旦古いlockファイルを削除する。
* `gem install bundler -v 2.0.2`で`2.0.2`をインストールする。
* `bundle install`でlockファイルを作り直す。
* `git add`+`git commit`で変更をコミットする。
* `git push heroku master`でもう一度デプロイを試す。

また、herokuでサポートされていないバージョンのrubyを使っていた場合、`The Ruby version you are trying to install does not exist on this stack.`というエラーが発生する。https://devcenter.heroku.com/articles/ruby-support#supported-runtimes でサポートされているRubyのバージョンを確認し、Gemfileで指定しているrubyのバージョンを適宜変更して`bundle`し直す。

初回デプロイなので、DBのマイグレーションを実行する。

```
heroku run rails db:migrate
```

マイグレーションが終わったら、ブラウザでHerokuアプリケーションのURLを開いて、Listのscaffoldが正しく動くことを確認する。

## タイムゾーンの設定
デフォルトのタイムゾーン(GMT)だと日本人が使うには使い勝手が悪いので、タイムゾーンをJSTにする

```
heroku config:add TZ=Asia/Tokyo
```

# 機能実装
## ルートパスの設定
サイトのルートにアクセスした時に、リストの一覧画面(lists#index)が表示されるようにしておく。

```ruby:config/routes.rb
Rails.application.routes.draw do
  root "lists#index"
  resources :lists
end
```

## リストの下に目標を追加できるようにする
結論から言うと、ここで作るscaffoldの内容は、最終的にはほとんど不要になって削除してしまうことになる。なので、完成形が見えている場合はscaffoldを使わなくても良いのだが、データ構造が明らかになってそれが正しく動く様子を確認してからUIに合わせて内容を書き換えていく方が分かりやすいと思うので、少し遠回りだがscaffoldを使って作っていく。

### 目標(Achievement)のscaffold
```
rails g scaffold Achievement name:string list:references achieved:boolean
rails db:migrate
```

`List`が複数の`Achievement`オブジェクトを保持できるようなデータ構造にする。

```models/list.rb
class List < ApplicationRecord
  has_many :achievements
end
```

`List`が複数の`Achievement`を持つことを`has_many`で明示的に設定しておく。

`list:references`の代わりに、`list_id:integer`としても同じデータ構造になるが、`references`を使うとインデックスを作ってくれるので、必要性がなければ`references`を使った方が良い。

http://edgeguides.rubyonrails.org/active_record_migrations.html#special-helpers

![スクリーンショット 2017-01-02 18.59.04.png](https://qiita-image-store.s3.amazonaws.com/0/14097/4caa9ac7-d753-9638-c0ca-f27f2337bed9.png)

http://localhost:3000/achievements で目標の登録ができるようになるが、目標作成時に手動でリストを入力する、というのは使い勝手的によろしくない。

### URL構造を変える
目標はリスト毎に管理・表示されるものなので、まずはURLの構造から変えていきたい。`http://localhost:3000/lists/#{リストのID}/achievements`というURLでリスト毎の目標一覧画面を出すように`routes.rb`を書き換える。

```ruby:config/routes.rb
Rails.application.routes.draw do
  root "lists#index"
  resources :lists do
    resources :achievements
  end
end
```

これで、`lists/#{リストID}/achievements`というURLでアチーブメント一覧画面にアクセスできるようになるが、実際に`http://localhost:3000/lists/#{リストのID}/achievements`を開くと`new_achievement_path`の箇所でエラーになる。ルーティングの設定が変わったため、`new_achievement_path`というパスが存在しなくなったためだ。

ルーティング関連の問題が起きた時は`rails routes`で有効なパスの一覧を確認する。

```
               Prefix Verb   URI Pattern                                     Controller#Action
                 root GET    /                                               lists#index
    list_achievements GET    /lists/:list_id/achievements(.:format)          achievements#index
                      POST   /lists/:list_id/achievements(.:format)          achievements#create
 new_list_achievement GET    /lists/:list_id/achievements/new(.:format)      achievements#new
edit_list_achievement GET    /lists/:list_id/achievements/:id/edit(.:format) achievements#edit
     list_achievement GET    /lists/:list_id/achievements/:id(.:format)      achievements#show
                      PATCH  /lists/:list_id/achievements/:id(.:format)      achievements#update
                      PUT    /lists/:list_id/achievements/:id(.:format)      achievements#update
                      DELETE /lists/:list_id/achievements/:id(.:format)      achievements#destroy
                lists GET    /lists(.:format)                                lists#index
                      POST   /lists(.:format)                                lists#create
             new_list GET    /lists/new(.:format)                            lists#new
            edit_list GET    /lists/:id/edit(.:format)                       lists#edit
                 list GET    /lists/:id(.:format)                            lists#show
                      PATCH  /lists/:id(.:format)                            lists#update
                      PUT    /lists/:id(.:format)                            lists#update
                      DELETE /lists/:id(.:format)                            lists#destroy
```

`new_achievement`の代わりに`new_list_achievement`が登場しているため、これに置き換える。

### 親となるListの取得
ただし、この修正をするにあたっては、少し下準備をする必要がある。
まず、`achievements_controller`は`lists`の下にネストされた状態になった。今後、`achievements_controller`は`:list_id`で指定されたListに紐づいて動くようにする必要がある。

そこで、`achievements_controller`の`before_action`で各アクションの実行前にIDが`:list_id`となるListを@listとして参照できるように改造する。

```ruby:achievements_controller.rb
class AchievementsController < ApplicationController
  before_action :set_list # ここ追加
  before_action :set_achievement, only: [:show, :edit, :update, :destroy]
  ...
  private
    def set_list # private以下にこのメソッド追加
      @list = List.find(params[:list_id])
    end

    ...
    def set_achievement
    ...
end
```

ここで、`achievements/index.html.haml`を編集して、ページにリストの名前が表示されるようにしてみる。

```achievements/index.html.haml
%h1=@list.name

%table
...
```

加えて、新しいアチーブメントを追加するページ(achievements#new)へのリンク生成箇所も、新しい`new_list_achievement_path`を使う形に書き換える。

```achievements/index.html.haml
= link_to 'New Achievement', new_list_achievement_path(@list)
```

これで、目標一覧ページ(lists/#{リストのID}/achievements)を表示できるようになった。まだ目標は一つも登録されていないので何も表示されないし、登録したとしてもリスト関係なく全ての目標が表示されてしまうが、それは後で直していく。

### 新しい目標の作成ページの実装
次に、新しい目標の作成ページを修正する。

新規作成画面(new.html.haml)は、内部的に`_form.html.haml`を参照している。`_form.html.haml`では、目標の設定のためのフォームが定義されている。ここで使用している`form_for`タグは、元々`@achievement`の登録(・更新)のためのURL(achievements/:id)を生成するようになっていたが、URL構造の変更により(lists/:list_id/achievements/:id)を生成するように書き換えなければいけない。

```achievements/_form.html.haml
= form_for [@list, @achievement] do |f|
```

第一引数に@listと@achievementの両方を渡すことによって、正しいURLを生成できるようになる。

次に、`_form.html.haml`には`List`を指定するための欄があるが、`List`を手動で指定させることは無いし、変な値を入力されても困るので、この項目は削除してしまおう。

```haml:achievements/_form.html.haml
  .field #これいらないので消す
    = f.label :list #いらない
    = f.text_field :list #いらない
```

また、そもそも変な値を送れないようにコントローラの方でも制限しておこう。

```achievements_controller.rb
    def achievement_params
      params.require(:achievement).permit(:name, :achieved) #:list_idを消しておく
    end
```

もう一箇所、`new.html.haml`では、ページ下部に「Back」リンクがあり、そこで存在しないパス(`achievements_path`)が指定されているためエラーが発生している。

```
= link_to 'Back', achievements_path
```

これは、`list_achievements_path(@list)`とすることで動作するようになる。

最後に、フォームの値が送信されたらインスタンスを作る部分を修正する。

```ruby:achievements_controller.rb
  def create
    @achievement = @list.achievements.build(achievement_params)
```

新しく保存されるAchievementのインスタンスは、@listの子要素として生成されるように`@list.achievements.build`で生成する。こうすることで、自動的に`list_id`に@listのIDが入る。

目標の作成に成功した後、@achievementにリダイレクトするようになっているが、

```ruby:achievements_controller.rb
format.html { redirect_to @achievement, notice: 'Achievement was successfully created.' }
```

URL構造の変更によって、@achievement(achievement_path)に該当するものはなくなっている。`list_achievement_path`を表示するように直してもよいが、Nameしか表示項目がないものを表示するのは微妙なので、`list_achievements_path`を表示するように変更しておく。

```ruby:achievements_controller.rb
format.html { redirect_to list_achievements_path(@list), notice: 'Achievement was successfully created.' }
```

ここまでの修正で、新しい目標の作成まで動作するようになっているが、このままでは一覧表示画面が正しく表示されないので、修正する。

### 一覧画面の修正

```ruby:achievements_controller.rb
  # GET /achievements
  # GET /achievements.json
  def index
    @achievements = @list.achievements
  end
```

`lists/:list_id/achievements`において、全ての目標(Achievement.all)ではなく、@listにひもづく目標(@list.achievements)のみを表示させるようにする。

```achievements/index.html.haml
%td= link_to 'Show', list_achievement_path(@list, achievement)
%td= link_to 'Edit', edit_list_achievement_path(@list, achievement)
%td= link_to 'Destroy', list_achievement_path(@list, achievement), :method => :delete, :data => { :confirm => 'Are you sure?' }
```

また、今までと同じ要領でパスの修正を行う。

### その他の画面のパスの修正
同じ要領で、Show / Edit / Destroyアクションに関連する各パス生成箇所も修正する。

## UIの改良
### lists#showとachievements#indexを統合する
`lists#show`も`achievements#index`も、リストに含まれる目標の一覧を表示する、という意味合いでは同じ目的のページとなるので、どちらか一つに統合してしまおう。どちらを残す形でも良いが、URLが短くなる方が嬉しいので、`lists#show`を残して`achievements#index`を削除してしまおう。

#### lists#showで目標の一覧を表示する
`achievements#index`で表示している目標一覧の実装を`lists#show`に持ってくる。同時に、それっぽくなるように一旦ページのレイアウトを少し調整しておく。

```lists/show.html.haml
%h1=@list.name
= link_to '編集', edit_list_path(@list)

%h2 目標一覧
%table.table
  %thead
    %tr
      %th Name
      %th

  %tbody
    - @list.achievements.each do |achievement|
      %tr
        %td
          = achievement.name
          - if achievement.achieved
            (達成済み)
        %td= link_to '削除する', list_achievement_path(@list, achievement), :method => :delete, :data => { :confirm => 'Are you sure?' }, :class => "btn btn-danger"

%br

= link_to '新しい目標', new_list_achievement_path(@list), class: "btn btn-primary"
= link_to '戻る', lists_path, class: "btn btn-default"
```

![スクリーンショット 2017-01-02 18.36.59.png](https://qiita-image-store.s3.amazonaws.com/0/14097/33648a95-3542-5884-c851-1f73ce31719d.png)

これで不要になるachievementsのindexに加えて、Nameしか項目を持たない個別の目標(Achievement)のshowやedit、updateもあまり意味がないので、消してしまうことにした。

```config/routes.rb
Rails.application.routes.draw do
  root "lists#index"
  resources :lists do
    # index, show, edit, update の機能をルーティングから外す
    resources :achievements, except: [:index, :show, :edit, :update]
  end
end
```

show, edit, updateの各アクションに関する`achievements_controller`内の実装も消してしまおう。`before_action :set_achievement, only: [:destroy]`の`only:`対象から削除するのも忘れずに。

下記のビューファイルも不要なので消しちゃう。(`new`と`_form`だけが残る)

* views/achievements/show.html.haml
* views/achievements/show.json.jbuilder
* views/achievements/edit.html.haml
* views/achievements/index.html.haml
* views/achievements/index.json.jbuilder
* views/achievements/_achievements.json.jbuilder

そして、achievements_controller内、目標の作成後など`list_achievements_path`にリダイレクトしていた箇所は、全て`@list`にリダイレクトするように書き換える。

```
format.html { redirect_to @list, notice: 'Achievement was successfully created.' }
```

ここまで作って、ようやくリストの作成〜目標の作成までが画面として繋がった。

## 達成機能
目標を達成したら達成済み(achieved=true)にできるようにする。

![スクリーンショット 2017-01-02 19.59.00.png](https://qiita-image-store.s3.amazonaws.com/0/14097/5e491f3b-7aab-74c5-7e7d-ad8936a77d84.png)

### アクションの追加
`lists/:list_id/achievements/:id/achieve`で、目標を達成できるようにする。

```routes.rb
Rails.application.routes.draw do
  root "lists#index"
  resources :lists do
    resources :achievements, except: [:index, :show, :edit, :update] do
      member do
        post "achieve"
      end
    end
  end
end
```

`achievements_controller`に、`achieve`アクションを定義していく。アクションを定義する前に、新しく追加する`achieve`アクション内でも@achievementを参照できるように、`set_achievement`の対象に含めておく。

```achievements_controller.rb
before_action :set_achievement, only: [:destroy, :achieve]
```

`achieve`アクションでは、`@achievement`の`achieved`フラグを`true`にして、リスト画面にリダイレクトする。

```achievements_controller.rb
  def achieve
    @achievement.update_attributes achieved: true
    redirect_to @list, notice: "#{@achievement.name} を達成済みにしました"
  end
```

### 「達成済みにする」リンクの追加

目標一覧画面で、未達成の目標には「達成済みにする」リンクを表示するようにする。

```lists/show.html.haml
  %tbody
    - @list.achievements.each do |achievement|
      %tr
        %td
          = achievement.name
          - if achievement.achieved
            (達成済み)
          - else
            = link_to '達成済みにする', achieve_list_achievement_path(@list, achievement), method: 'POST', data: { confirm: '達成済みにしますか?一度達成済みにすると、元に戻せません。' }, class: "btn btn-xs btn-default"
        %td= link_to '削除する', list_achievement_path(@list, achievement), :method => :delete, :data => { :confirm => 'Are you sure?' }, :class => "btn btn-danger"
```

### 目標登録時に「達成済み」にならないようにする
最後に、新しい目標が作成されるタイミングでは「達成済み」にならないように対策をします。

#### フォームから項目を消す
フォームの「達成済み」チェックボックスは不要なので、消す。

```achievements/_form.html.haml
  .field
    = f.label :achieved
    = f.check_box :achieved
```

あわせて、コントローラ側でも`achieved`パラメータを受け付けないように対策する。

```achievements_controller.rb
def achievement_params
  params.require(:achievement).permit(:name) # :achievedを消した
end
```

以上で、目標の達成管理まで動くようになった。

## 残りの機能

実際に公開している[MyAchievements 2017年の目標宣言](http://achievements.nantekottai.com)では、レイアウトの調整に加えて、下記のような機能を実装しています。

* ListのIDをハッシュ文字列に
* パスワードによる編集制限
* 登録・達成のイベント管理
* Twitterへのシェア機能
* etc.

が、篦棒に長くなってしまうわりに内容が細かいので説明は省略します。いつか気が向いたら、完全版を書き記すかも…

では良い冬休みを。

# 参考
* [RailsのScaffoldでネストしたResourceを作る](http://sil.hatenablog.com/entry/rails-nested-resource-by-scaffold)
* [	
ruby 2.4.0 on rails 5.0.1にした際につまづいた点](http://www.lanches.co.jp/blog/6330)