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

  • 20
    いいね
  • 0
    コメント

概要

  • 最近アプリばかり作っているので、冬休みの久しぶりにウェブサービスを作ることにした。
  • Ruby on Rails 5を使って目標管理ツールを作った。
  • Herokuにデプロイした。

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

前提

  • 1度くらいRuby on Railsに触れたことがある人

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

作るもの

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

スクリーンショット 2017-01-02 15.18.23.png

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

使うもの

エディタ

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

atom-rails.png

特に、この記事 AtomでRailsを爆速開発する環境を作ってみた に従ってパッケージを入れると、大分快適な開発環境が整った。browser-syncすら不要。

2016年12月31日現在、browser-plusはうまく動作しなかったので、代わりにplatformio-ide-terminalを使うようにした。

プロジェクトの作成

rails new my-achievements -d postgresql

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

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

cd my-achievements
atom .

DBの作成

初回なので、developmenttest用のPostgresのDBを作成する。

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

rails db:create

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

動作確認

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

atom-browser.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", "~> 0.9"

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

rails haml:erb2haml

Bootstrapの導入

gem 'therubyracer', platforms: :ruby, github: 'cowboyd/therubyracer'
gem "less-rails" #Sprockets (what Rails 3.1 uses for its asset pipeline) supports LESS
gem "twitter-bootstrap-rails"

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

bundle installした後、generate bootstrap:install lessでプロジェクトにBootstrap関連のアセットを追加する。

rails generate bootstrap:install less

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

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

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

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 © Company 2017

Herokuにデプロイする

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

gitコミットの作成

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

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

Herokuへのデプロイ

heroku create

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

git push heroku master

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

heroku run rails db:migrate

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

タイムゾーンの設定

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

heroku config:add TZ=Asia/Tokyo

機能実装

ルートパスの設定

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

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

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

URL構造を変える

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

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_controllerlistsの下にネストされた状態になった。今後、achievements_controller:list_idで指定されたListに紐づいて動くようにする必要がある。

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

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を手動で指定させることは無いし、変な値を入力されても困るので、この項目は削除してしまおう。

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)とすることで動作するようになる。

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

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

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

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

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

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

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

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

一覧画面の修正

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#showachievements#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

Nameしか項目を持たない個別の目標(Achievement)のshowやedit、updateはあまり意味がないので、消してしまうことにした。

config/routes.rb
Rails.application.routes.draw do
  root "lists#index"
  resources :lists do
    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

アクションの追加

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アクションでは、@achievementachievedフラグを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年の目標宣言では、レイアウトの調整に加えて、下記のような機能を実装しています。

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

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

では良い冬休みを。

参考