チュートリアル全体の説明
🐱 今回はチュートリアルとしてSPA風の管理画面を作っていくよ。Rails AdminやActive Adminのような管理画面を想定しているよ。
🐱 具体的にはCRUD(一覧・編集・登録・削除)ができて、そこにページネーション・検索・ソートなどのプラスαな機能があるイメージだよ。これをSPA風にしたものを作っていくよ。
🐱 ベースはただのCRUDなので管理対象はなんでもいいんだけど、ねこが好きなのでcatsテーブルを管理対象にするよ。後はほかにもいくつか動物のテーブルを用意して、AMS(Animal Management System: 動物管理システム)というシステムとして作っていく。
🐱 まずチュートリアル1では、Hotwireを使わずに、素のRailsで管理画面を開発するよ。
チュートリアル1
🐱 チュートリアル1のデモはこちらから触れるよ。
🐱 チュートリアル2ではTurboを使って、JavaScriptを書かずに管理画面をSPA風にしていくよ。
チュートリアル2
🐱 チュートリアル2のデモはこちらから触れるよ。
🐱 チュートリアル3ではStimulusを使って、管理画面をもっとSPA風にしていくよ。
チュートリアル3
🐱 チュートリアル3のデモはこちらから触れるよ。
チュートリアル1の説明
🐱 まずチュートリアル1では素のRailsで管理画面を作っていくよ。
🐱 Hotwireの素晴らしい点の1つとして、既存のRailsアプリに対して後付けで段階的にSPA風の挙動を追加できる点があるよ。既存のRailsアプリのコードを少し修正するだけで、SPAのメリットを享受できるようになるんだ。初めからHotwireを使って開発してもいいんだけど、今回はまずは慣れ親しんだ素のRailsで管理画面を作って、その後でHotwireを使ってSPA風にしていくよ。
🐱 このチャプターではHotwireは使わないので、不要だと思ったら次のチャプターまで読み飛ばしちゃってもOKだよ。チュートリアル1終了時点のコードは https://github.com/shita1112/cat-hotwire-demo にあるからそこからgit clone
できるよ。
$ git clone https://github.com/shita1112/cat-hotwire-demo
$ cd cat-hotwire-demo
$ git checkout tutorial-1
🐱 チュートリアル1で作る機能は以下の通りだよ。
- CRUD(一覧・編集・登録・削除)
- ページネーション
- 検索
- ソート
- 編集・登録時のインラインバリデーション
rails new
🐱 それじゃあさっそく開発していこう!
🐱 まずは$ rails new
でRailsアプリを作るよ。
$ rails new cat-hotwire --css=bootstrap --skip-jbuilder --skip-action-mailbox --skip-action-mailer --skip-test --skip-active-storage --skip-action-text
🐱 オプションの意味は以下の通りだよ。
-
--css=bootstrap
: CSSフレームワークにBootstrapを利用 -
--skip-jbuilder
: jbuilderの導入をskip -
--skip-action-mailbox
: action-mailboxの導入をskip -
--skip-action-mailer
: action-mailerの導入をskip -
--skip-test
: testの導入をskip -
--skip-active-storage
: active-storageの導入をskip -
--skip-action-text
: action-textの導入をskip
🐱 CSSフレームワークにBootstrapを使うので、オプションに--css bootstrap
を指定してね。これはRails7で追加されたオプションで、これを使うとBootstrapとBootstrap Iconsの設定をいい感じにやってくれるよ。
🐱 あとjbuilder
等の不要な機能はスキップしてるよ。
コミット
日本向けに設定を変更
🐱 日本語と日本時間を使うように設定を修正するよ。
+ # タイムゾーンをTokyo(日本)にする
+ config.time_zone = "Tokyo"
+ # デフォルトのロケールを日本にする
+ config.i18n.default_locale = :ja
🐱 日本語のロケールファイルをja.yml
に配置するよ。rails-i18nのこちらの翻訳を利用させていただくよ。
ja.yml(長いので折りたたみ)
---
ja:
activerecord:
errors:
messages:
record_invalid: 'バリデーションに失敗しました: %{errors}'
restrict_dependent_destroy:
has_one: "%{record}が存在しているので削除できません"
has_many: "%{record}が存在しているので削除できません"
date:
abbr_day_names:
- 日
- 月
- 火
- 水
- 木
- 金
- 土
abbr_month_names:
-
- 1月
- 2月
- 3月
- 4月
- 5月
- 6月
- 7月
- 8月
- 9月
- 10月
- 11月
- 12月
day_names:
- 日曜日
- 月曜日
- 火曜日
- 水曜日
- 木曜日
- 金曜日
- 土曜日
formats:
default: "%Y/%m/%d"
long: "%Y年%m月%d日(%a)"
short: "%m/%d"
month_names:
-
- 1月
- 2月
- 3月
- 4月
- 5月
- 6月
- 7月
- 8月
- 9月
- 10月
- 11月
- 12月
order:
- :year
- :month
- :day
datetime:
distance_in_words:
about_x_hours:
one: 約1時間
other: 約%{count}時間
about_x_months:
one: 約1ヶ月
other: 約%{count}ヶ月
about_x_years:
one: 約1年
other: 約%{count}年
almost_x_years:
one: 1年弱
other: "%{count}年弱"
half_a_minute: 30秒前後
less_than_x_seconds:
one: 1秒以内
other: "%{count}秒未満"
less_than_x_minutes:
one: 1分以内
other: "%{count}分未満"
over_x_years:
one: 1年以上
other: "%{count}年以上"
x_seconds:
one: 1秒
other: "%{count}秒"
x_minutes:
one: 1分
other: "%{count}分"
x_days:
one: 1日
other: "%{count}日"
x_months:
one: 1ヶ月
other: "%{count}ヶ月"
x_years:
one: 1年
other: "%{count}年"
prompts:
second: 秒
minute: 分
hour: 時
day: 日
month: 月
year: 年
errors:
format: "%{attribute}%{message}"
messages:
accepted: を受諾してください
blank: を入力してください
confirmation: と%{attribute}の入力が一致しません
empty: を入力してください
equal_to: は%{count}にしてください
even: は偶数にしてください
exclusion: は予約されています
greater_than: は%{count}より大きい値にしてください
greater_than_or_equal_to: は%{count}以上の値にしてください
inclusion: は一覧にありません
invalid: は不正な値です
less_than: は%{count}より小さい値にしてください
less_than_or_equal_to: は%{count}以下の値にしてください
model_invalid: 'バリデーションに失敗しました: %{errors}'
not_a_number: は数値で入力してください
not_an_integer: は整数で入力してください
odd: は奇数にしてください
other_than: は%{count}以外の値にしてください
present: は入力しないでください
required: を入力してください
taken: はすでに存在します
too_long: は%{count}文字以内で入力してください
too_short: は%{count}文字以上で入力してください
wrong_length: は%{count}文字で入力してください
template:
body: 次の項目を確認してください
header:
one: "%{model}にエラーが発生しました"
other: "%{model}に%{count}個のエラーが発生しました"
helpers:
select:
prompt: 選択してください
submit:
create: 登録する
submit: 保存する
update: 更新する
number:
currency:
format:
delimiter: ","
format: "%n%u"
precision: 0
separator: "."
significant: false
strip_insignificant_zeros: false
unit: 円
format:
delimiter: ","
precision: 3
separator: "."
significant: false
strip_insignificant_zeros: false
human:
decimal_units:
format: "%n %u"
units:
billion: 十億
million: 百万
quadrillion: 千兆
thousand: 千
trillion: 兆
unit: ''
format:
delimiter: ''
precision: 3
significant: true
strip_insignificant_zeros: true
storage_units:
format: "%n%u"
units:
byte: バイト
eb: EB
gb: GB
kb: KB
mb: MB
pb: PB
tb: TB
percentage:
format:
delimiter: ''
format: "%n%"
precision:
format:
delimiter: ''
support:
array:
last_word_connector: "、"
two_words_connector: "、"
words_connector: "、"
time:
am: 午前
formats:
default: "%Y年%m月%d日(%a) %H時%M分%S秒 %z"
long: "%Y/%m/%d %H:%M"
short: "%m/%d %H:%M"
pm: 午後
コミット
Turbo Driveを無効化
🐱 Rails7ではデフォルトでTurbo Driveが有効になっている。チュートリアル1ではHotwireを使わずにアプリを開発したいので、無効にしておくよ(チュートリアル2で有効にするよ)。
Turbo.session.drive = false
コミット
動物5種のScaffoldを追加
🐱 Cat(ねこ)のScaffoldを作成するよ。
# DBを作成
$ rails db:create
# ねこのScaffoldを作成
$ rails g scaffold Cat name:string age:integer
# Catテーブルを作成
$ rails db:migrate
🐱 これでCatのCRUDができたよ。サーバーを起動して http://localhost:3000/cats にアクセスして確認してみてね。
$ bin/dev
🐱 ($ rails server
ではなく$ bin/dev
な理由については次節で説明するよ。)
🐱 Catの日本語訳を追加しておくよ。
ja:
activerecord:
models:
cat: ねこ
attributes:
cat:
name: 名前
age: 年齢
🐱 実際に作り込んでいくのはCatの画面だけだけど、管理画面にテーブルが1つだと寂しいので、他にもいくつかScaffoldを作っておくよ。
# 上からいぬ、ひよこ、ハリネズミ、フクロウ
$ rails g scaffold Dog name:string age:integer
$ rails g scaffold Chick name:string age:integer
$ rails g scaffold Hedgehog name:string age:integer
$ rails g scaffold Owl name:string age:integer
# テーブルを作成
$ rails db:migrate
コミット
importmap-railsとjsbundling-rails
🐱 今回、サーバーの起動に$ rails server
ではなく$ bin/dev
を使ったよ。その理由について説明するね。
🐱 Rails6ではnpmパッケージ(JavaScriptのライブラリ)の管理にはwebpackerというgemを利用していたよ。これがRails7では廃止になって、代わりに2つの方法が提供されるようになったよ。
🐱 1つ目はimportmap-railsというgemを使う方法で、これがRailsのデフォルトのやり方だよ。これはwebpackやesbuild等のバンドラーを利用しない方法だよ。自前のJavaScriptはES6で書いてバンドルせずにHTTP/2で配信して、サードパーティーのJavaScriptライブラリはCDNから取得する方法になる。今回は使わないので、詳しく知りたい場合は以下の記事を参考にしてね。
参考
🐱 2つ目がjsbundling-rails(とcssbundling-rails)を使う方法で、今回はこちらを使っているよ。$ rails new
のオプションで--css bootstrap
を指定したけれども、その場合は自動的にjsbundling-railsを使うことになるよ。
🐱 jsbundling-railsはバンドラーを使うよ(デフォルトではwebpackではなく、ビルドが高速なesbuildを使う)。ただし$ rails server
を起動するだけでは自動でJavaScript・CSSをビルドしてくれないんだ。JavaScript・CSSのビルドをするためにはforemanというプロセス管理のツールを使って、サーバーのプロセスと、JavaScript・CSSの自動ビルドのプロセスを同時に立ち上げる必要があるよ。--css bootstrap
を指定した場合、↓のようなファイルが用意されていい感じにやってくれるので、開発する側は$ bin/dev
を叩くだけでOKだよ。
`$ bin/dev`の詳細
#!/usr/bin/env bash
# foremanがインストールされていなければインストール
if ! command -v foreman &> /dev/null
then
echo "Installing foreman..."
gem install foreman
fi
# Procfile.devを元にforemanを起動
foreman start -f Procfile.dev "$@"
# foremanが立ち上げるプロセス
# サーバー起動
web: bin/rails server -p 3000
# ファイルの変更を監視して、esbuildでJavaScriptを自動ビルド(package.jsonのbuildに対応)
js: yarn build --watch
# ファイルの変更を監視して、sassでapplication.bootstrap.scssを自動ビルド(package.jsonのbuild:cssに対応)
css: yarn build:css --watch
"scripts": {
// yarn buildに対応
"build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds",
// yarn build:cssに対応
"build:css": "sass ./app/assets/stylesheets/application.bootstrap.scss ./app/assets/builds/application.css --no-source-map --load-path=node_modules"
}
🐱 JSのビルドに関してはjsbundling-railsが、CSSのビルドに関してはcssbundling-railsが担当するよ。
🐱 ということで、このチュートリアルではサーバー起動には$ bin/dev
を使っていくよ。
参考
seedsの作成
🐱 ねこのレコードを100件作るよ。
🐱 まずはseedsファイルを作るよ。
seeds.rb(長いので折りたたみ)
# 参考: https://nekorich.com/cat-names
Cat.create(
[
{name: "アーチー", age: 0 },
{name: "アーロ", age: 1 },
{name: "アイヴァン", age: 2 },
{name: "アイリス", age: 3 },
{name: "アヴァロン", age: 4 },
{name: "アクセル", age: 5 },
{name: "あずき", age: 6 },
{name: "アディ", age: 7 },
{name: "アディソン", age: 8 },
{name: "アニー", age: 9 },
{name: "アビー", age: 10 },
{name: "アポロ", age: 11 },
{name: "アリエル", age: 12 },
{name: "アリス", age: 13 },
{name: "アルド", age: 14 },
{name: "アルフィ", age: 15 },
{name: "アレックス", age: 16 },
{name: "アンバー", age: 17 },
{name: "イザベラ", age: 18 },
{name: "イジィ", age: 19 },
{name: "ウィスカー", age: 0 },
{name: "ウィリアム", age: 1 },
{name: "ウィロー", age: 2 },
{name: "うに", age: 3 },
{name: "エイヴァ", age: 4 },
{name: "エヴァ", age: 5 },
{name: "エコー", age: 6 },
{name: "エディ", age: 7 },
{name: "エマ", age: 8 },
{name: "エラ", age: 9 },
{name: "エリー", age: 10 },
{name: "エルサ", age: 11 },
{name: "エルビス", age: 12 },
{name: "エンジェル", age: 13 },
{name: "オータム", age: 14 },
{name: "オーティス", age: 15 },
{name: "オーディン", age: 16 },
{name: "オードリー", age: 17 },
{name: "おくら", age: 18 },
{name: "オジー", age: 19 },
{name: "オスカー", age: 0 },
{name: "おもち", age: 1 },
{name: "オリィ", age: 2 },
{name: "オリオン", age: 3 },
{name: "オリバー", age: 4 },
{name: "オリビア", age: 5 },
{name: "オレオ", age: 6 },
{name: "ガーフィールド", age: 7 },
{name: "かい", age: 8 },
{name: "カエサル", age: 9 },
{name: "ガナー", age: 10 },
{name: "カリー", age: 11 },
{name: "キキ", age: 12 },
{name: "ギズモ", age: 13 },
{name: "キティ", age: 14 },
{name: "きなこ", age: 15 },
{name: "キャスパー", age: 16 },
{name: "キャンディ", age: 17 },
{name: "ぎんじ", age: 18 },
{name: "クイナ", age: 19 },
{name: "くう", age: 0 },
{name: "グース", age: 1 },
{name: "クーパー", age: 2 },
{name: "クッキー", age: 3 },
{name: "クラッシュ", age: 4 },
{name: "グリフィン", age: 5 },
{name: "グレイシー", age: 6 },
{name: "グレース", age: 7 },
{name: "クレオ", age: 8 },
{name: "くろ", age: 9 },
{name: "クロエ", age: 10 },
{name: "ケイシー", age: 11 },
{name: "ケイティ", age: 12 },
{name: "ケティ", age: 13 },
{name: "ここ", age: 14 },
{name: "コスモ", age: 15 },
{name: "こたろう", age: 16 },
{name: "こてつ", age: 17 },
{name: "コナ", age: 18 },
{name: "ゴンゾ", age: 19 },
{name: "サーシャ", age: 0 },
{name: "さくら", age: 1 },
{name: "サシー", age: 2 },
{name: "サディ", age: 3 },
{name: "サミー", age: 4 },
{name: "サム", age: 5 },
{name: "サレム", age: 6 },
{name: "シーダ", age: 7 },
{name: "ジェイク", age: 8 },
{name: "シェビー", age: 9 },
{name: "ジギー", age: 10 },
{name: "じじ", age: 11 },
{name: "ししまる", age: 12 },
{name: "シナモン", age: 13 },
{name: "シバ", age: 14 },
{name: "ジプシー", age: 15 },
{name: "ジャスティス", age: 16 },
{name: "ジャスパー", age: 17 },
{name: "ジャスミン", age: 18 },
{name: "ジャック", age: 19 },
]
)
🐱 このデータをDBに投入するよ。
$ rails db:seed
コミット
ページネーションの実装
🐱 次にページネーション機能を実装するよ。
🐱 ページネーションの実装にはkaminariというgemを使うよ。
🐱 Gemfileにkaminariを追加して、bundle installするよ。
gem "kaminari"
$ bundle install
🐱 コードを以下のように修正するよ。
def index
- @cats = Cat.all
+ # @catsに対してページネートできるようにする
+ @cats = Cat.page(params[:page])
end
<% end %>
</div>
+ <%# ページネーションを表示する %>
+ <%= paginate @cats %>
+
<%= link_to "New cat", new_cat_path %>
🐱 これでページネーションできるようになったよ。
🐱 ただ、これだとページネーションの表記が英語なので日本語化しておくよ。
# 参考: https://github.com/tigrish/kaminari-i18n/blob/master/config/locales/ja.yml
ja:
helpers:
page_entries_info:
more_pages:
display_entries: "<b>%{total}</b>中の%{entry_name}を表示しています <b>%{first} - %{last}</b>"
one_page:
display_entries:
one: "<b>%{count}</b>レコード表示中です %{entry_name}"
other: "<b>%{count}</b>レコード表示中です %{entry_name}"
zero: "レコードが見つかりませんでした %{entry_name}"
views:
pagination:
first: "« 最初"
last: "最後 »"
next: "次 ›"
previous: "‹ 前"
truncate: "…"
🐱 あとkaminariはデフォルトだと1ページにつきレコードを25件を表示するのだけど、このアプリでは1ページにつき10件の表示にしたいので、その設定をしておくよ。
# kaminariの設定ファイルを生成する
$ rails g kaminari:config
create config/initializers/kaminari_config.rb
# frozen_string_literal: true
Kaminari.configure do |config|
# 1ページ10件に変更する
config.default_per_page = 10
# config.max_per_page = nil
# config.window = 4
# config.outer_window = 0
# config.left = 0
# config.right = 0
# config.page_method_name = :page
# config.param_name = :page
# config.params_on_first_page = false
end
🐱 これでページネーション機能は完成だよ。
コミット
検索の実装
🐱 次に検索機能を実装するよ。
🐱 検索の実装にはransackというgemを使うよ。
🐱 まずransackをinstallするよ。
gem "ransack"
$ bundle install
🐱 コードはこんな感じになるよ。
def index
- @cats = Cat.page(params[:page])
+ # `Cat.ransack`でCatに対してransackを使う
+ # params[:q]には検索フォームで指定した検索条件が入る
+ @search = Cat.ransack(params[:q])
+
+ # デフォルトのソートをid降順にする
+ @search.sorts = 'id desc' if @search.sorts.empty?
+
+ # `@search.result`で検索結果となる@catsを取得する
+ # 検索結果に対してはkaminariのpageメソッドをチェーンできる
+ @cats = @search.result.page(params[:page])
end
<h1>Cats</h1>
+ <%# ransackを利用した検索フォームでは、form_withの代わりにsearch_form_forを使う %>
+ <%= search_form_for @search do |f| %>
+
+ <%# `カラム名_cont`とすることで、カラムに対してLIKEを使った曖昧一致検索ができる %>
+ <%= f.label :name_cont, "名前" %>
+ <%= f.search_field :name_cont %>
+
+ <%# `カラム名_eq`とすることで、カラムに対して完全一致検索ができる %>
+ <%= f.label :age_eq, "年齢" %>
+ <%= f.search_field :age_eq %>
+ <%= f.submit %>
+
+ <%# 検索結果と検索フォームをクリアする %>
+ <%= link_to "クリア", cats_path %>
+ <% end %>
<div id="cats">
<% @cats.each do |cat| %>
<%= render cat %>
🐱 これで検索フォームは完成だよ。
コミット
ソートの実装
🐱 次にソート機能を実装するよ。
🐱 ソートの実装もransackがやってくれるよ。
+ <%# ソートのリンク。このリンクをクリックするとname属性でソートされる %>
+ <%= sort_link(@search, :name) %>
+ <%# ソートのリンク。このリンクをクリックするとage属性でソートされる %>
+ <%= sort_link(@search, :age) %>
<div id="cats">
<% @cats.each do |cat| %>
<%= render cat %>
🐱 これでソート機能は完成だよ。
コミット
Bootstrap5で見た目を整える
🐱 一通りの機能はできたので、Bootstrap5を使って見た目を整えるよ。
🐱 変更量が多いので、こちらのコミットを参考にしてね。
コミット
インラインバリデーションの実装
🐱 最後にインラインバリデーションを実装するよ。
🐱 実装にはbootstrap_formというgemを使うよ。bootstrap_formを使うと、インラインバリデーションに対応したBootstrap用のフォームを簡単に実装できるよ。
🐱 まずモデルにバリデーションを実装するよ。
class Cat < ApplicationRecord
# 名前: 必須
validates :name, presence: true
# 年齢: 必須 + integer + 0以上
validates :age, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
end
🐱 bootstrap_formをインストールするよ。
gem "bootstrap_form"
$ bundle install
🐱 次にbootstrap_formを使ってフォームを書くよ。
-<%= form_with(model: cat) do |form| %>
- <% if cat.errors.any? %>
- <div style="color: red">
- <h2><%= pluralize(cat.errors.count, "error") %> prohibited this cat from being saved:</h2>
-
- <ul>
- <% cat.errors.each do |error| %>
- <li><%= error.full_message %></li>
- <% end %>
- </ul>
- </div>
- <% end %>
-
- <div>
- <%= form.label :name, style: "display: block" %>
- <%= form.text_field :name %>
- </div>
-
- <div>
- <%= form.label :age, style: "display: block" %>
- <%= form.number_field :age %>
- </div>
-
- <div>
- <%= form.submit %>
- </div>
-<% end %>
+<%# bootstrap_formを使ったフォームでは、form_withの代わりにbootstrap_form_withを使う %>
+<%# バリデーションエラーの表示はbootstrap_formが用意してくれるので、自前で用意する必要がなくなる %>
+<%= bootstrap_form_with(model: cat) do |form| %>
+
+ <%# ラベルはbootstrap_formが用意してくれるので、自前で用意する必要がなくなる %>
+ <%# Bootstrapのclass属性もbootstrap_formが設定してくれるので、自前で用意する必要はない %>
+ <%= form.text_field :name %>
+ <%= form.number_field :age %>
+
+ <%# Bootstrapのprimary色のsubmitボタン %>
+ <%= form.primary %>
+<% end %>
🐱 だいぶコードがすっきりしたね。
🐱 bootstrap_formはバリデーションでpresence: true
に設定した属性のラベルに、自動でrequired
クラス属性を付与してくれる。それを利用して必須属性は赤*
でマークされるようにCSSを設定しておくよ。
@import 'bootstrap/scss/bootstrap';
@import 'bootstrap-icons/font/bootstrap-icons';
+ label.required:after {
+ content:" *";
+ color: red;
+ }
コミット
まとめ
🐱 これでチュートリアル1は終わりだよ!完成したアプリケーションがこちら。
チュートリアル1
🐱 チュートリアル1のデモはこちらから触れるよ。
🐱 お疲れさまでした〜。
Hotwireのお仕事を探しています🙇♂️
現在、業務委託で入れるHotwireのお仕事を探しています。
Hotwireの経験があるRailsエンジニアをお探しの方は、ぜひ https://twitter.com/shita1112 にDMください🙇♂️
(Hotwire便利で楽しいのでもっと使っていきたいのですが、現状だとHotwireの案件はなかなか見つからない感じなのでした......この本が参考になったと感じて、これからHotwireやっていくぞ〜となった方は、ぜひぜひお気軽にDMください〜。とても喜びます!)