
Implementing Hotwire Admin
- 3 installs
- 3 repo stars
- Updated June 5, 2026
- xtone/ai_development_tools
Implements a custom Rails 8 admin panel with Hotwire (Turbo + Stimulus) and Tailwind, including Playwright/Capybara E2E tests.
About
A skill for building a custom Rails admin panel using Hotwire, Tailwind, and Devise, with full E2E test coverage. A developer uses it to add an admin screen to a Rails app without ActiveAdmin or Administrate.
- Rails 8 admin with Hotwire (Turbo + Stimulus) and Tailwind
- Playwright + Capybara E2E test design and implementation
Implementing Hotwire Admin by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,846 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/xtone/ai_development_tools --skill implementing-hotwire-adminAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 3 |
| Last updated | June 5, 2026 |
| Repository | xtone/ai_development_tools ↗ |
What it does
Implements a custom Rails 8 admin panel with Hotwire (Turbo + Stimulus) and Tailwind, including Playwright/Capybara E2E tests.
Files
概要
このスキルは、Hotwire(Turbo + Stimulus)を使用して、Ruby on Railsで完全にカスタマイズ可能な管理画面を実装します。E2Eテスト設計・実装も含め、本番運用可能な管理画面を構築します。
このスキルを使用するタイミング
Claudeは以下の状況でこのスキルを使用します:
- ユーザーがHotwireを使用した管理画面の実装を依頼した場合
- 既存のRailsアプリケーションに管理画面を追加する場合
- 管理画面のE2Eテストを実装する場合
- ActiveAdminやAdministrateを使わずに自作の管理画面を構築する場合
技術スタック
| 項目 | 技術 |
|---|---|
| フレームワーク | Ruby on Rails 8.x |
| フロントエンド | Hotwire (Turbo + Stimulus) |
| スタイリング | Tailwind CSS |
| E2Eテスト | Playwright + Capybara |
| 認証 | Devise / 自作認証 |
ステップ
各ステップの詳細は steps/ ディレクトリ内のファイルを参照してください。
1. セットアップとルーティング
詳細: @steps/01_setup_and_routing.md
Claudeは、管理画面用の名前空間、ルーティング、ベースコントローラを設定します。
2. レイアウトとナビゲーション
詳細: @steps/02_layout_and_navigation.md
Claudeは、管理画面専用のレイアウトファイルとサイドバーナビゲーションを作成します。
3. CRUD画面の実装
詳細: @steps/03_crud_views.md
Claudeは、一覧・詳細・新規作成・編集画面を実装します。各種データ型に対応したフォームヘルパーも含みます。
4. Turbo対応アクション
詳細: @steps/04_turbo_actions.md
Claudeは、削除・公開・非公開などのアクションをTurbo対応で実装します。button_toとdata-turbo-confirmを使用した安全なアクション実装を行います。
5. Stimulusコントローラ
詳細: @steps/05_stimulus_controllers.md
Claudeは、確認ダイアログ、フラッシュメッセージ、動的フォームなどのStimulusコントローラを実装します。
6. E2Eテスト設計
詳細: @steps/06_e2e_test_design.md
Claudeは、管理画面のテストケースを体系的に洗い出し、テスト計画を作成します。
7. E2Eテスト実装
詳細: @steps/07_e2e_test_implementation.md
Claudeは、Playwright + Capybaraを使用してE2Eテストを実装します。TDDアプローチでテストと実装を進めます。
8. トラブルシューティング
詳細: @steps/08_troubleshooting.md
Claudeは、よくある問題と解決策を参照し、実装中の問題を解決します。
ファイル構成
implementing-hotwire-admin/
├── SKILL.md # このファイル
├── references/
│ └── turbo_patterns.md # Turboパターンのリファレンス
└── steps/
├── 01_setup_and_routing.md # セットアップとルーティング
├── 02_layout_and_navigation.md # レイアウトとナビゲーション
├── 03_crud_views.md # CRUD画面
├── 04_turbo_actions.md # Turbo対応アクション
├── 05_stimulus_controllers.md # Stimulusコントローラ
├── 06_e2e_test_design.md # E2Eテスト設計
├── 07_e2e_test_implementation.md # E2Eテスト実装
└── 08_troubleshooting.md # トラブルシューティング重要な注意点
Turbo対応の削除リンク
Rails 8のTurbo環境では、link_toのmethod:オプションが機能しません。必ずbutton_toを使用してください:
<%# NG: Turbo環境では機能しない %>
<%= link_to '削除', path, method: :delete, data: { confirm: '削除しますか?' } %>
<%# OK: Turbo対応 %>
<%= button_to '削除', path, method: :delete, data: { turbo_confirm: '削除しますか?' } %>E2Eテストでの注意点
- Turbo確認ダイアログのテストでは、
click_linkではなくclick_buttonを使用 - レスポンシブテストは
page.driver.with_playwright_pageでビューポートサイズを変更 - CI環境ではPlaywrightのヘッドレスモードを使用
Turboパターンリファレンス
概要
Rails 8 + Turbo環境で管理画面を実装する際の主要なパターンをまとめたリファレンスです。
基本パターン
1. 削除アクション
<%# 推奨: button_to + turbo_confirm %>
<%= button_to '削除', admin_user_path(@user),
method: :delete,
class: 'btn btn-danger',
form: { data: { turbo_confirm: '本当に削除しますか?' } },
data: { testid: 'delete-button' } %>2. インライン削除ボタン(テーブル内)
<%= button_to '削除', admin_user_path(user),
method: :delete,
class: 'text-red-600 hover:text-red-800 bg-transparent border-0 p-0 cursor-pointer',
form: { class: 'inline', data: { turbo_confirm: "#{user.name}を削除しますか?" } },
data: { testid: "delete-user-#{user.id}" } %>3. カスタムPOSTアクション
<%# 公開/非公開トグル %>
<%= button_to(@article.published? ? '非公開にする' : '公開する'),
toggle_publish_admin_article_path(@article),
method: :post,
class: 'btn btn-secondary',
form: { data: { turbo_confirm: '状態を変更しますか?' } } %>4. リンク + turbo_method(非推奨だが可能)
<%# button_toが使えない場合の代替 %>
<%= link_to '削除', admin_user_path(@user),
data: { turbo_method: :delete, turbo_confirm: '削除しますか?' },
class: 'text-red-600' %>Turbo Stream パターン
5. 削除後に行を削除
# app/controllers/admin/users_controller.rb
def destroy
@user.destroy
respond_to do |format|
format.html { redirect_to admin_users_path, notice: '削除しました' }
format.turbo_stream { flash.now[:notice] = '削除しました' }
end
end<%# app/views/admin/users/destroy.turbo_stream.erb %>
<%= turbo_stream.remove dom_id(@user) %>
<%= turbo_stream.update 'flash-messages' do %>
<%= render 'layouts/admin/flash' %>
<% end %>6. 更新後に行を差し替え
<%# app/views/admin/users/update.turbo_stream.erb %>
<%= turbo_stream.replace dom_id(@user) do %>
<%= render 'admin/users/user_row', user: @user %>
<% end %>7. 新規作成後にテーブルに追加
<%# app/views/admin/users/create.turbo_stream.erb %>
<%= turbo_stream.prepend 'users-list' do %>
<%= render 'admin/users/user_row', user: @user %>
<% end %>
<%= turbo_stream.update 'flash-messages' do %>
<%= render 'layouts/admin/flash' %>
<% end %>Turbo Frame パターン
8. 検索結果の部分更新
<%# app/views/admin/users/index.html.erb %>
<%= form_with url: admin_users_path, method: :get, data: { turbo_frame: 'users-table' } do |f| %>
<%= f.text_field :q, placeholder: '検索...' %>
<%= f.submit '検索' %>
<% end %>
<%= turbo_frame_tag 'users-table' do %>
<table>
<%# テーブル内容 %>
</table>
<%== pagy_nav(@pagy) %>
<% end %>9. モーダル内フォーム
<%# 一覧画面 %>
<%= turbo_frame_tag 'modal' %>
<%= link_to '新規作成', new_admin_user_path, data: { turbo_frame: 'modal' } %>
<%# new.html.erb %>
<%= turbo_frame_tag 'modal' do %>
<div class="modal">
<%= render 'form', user: @user %>
</div>
<% end %>フラッシュメッセージ
10. Turbo対応フラッシュ
<%# app/views/layouts/admin.html.erb %>
<%= turbo_frame_tag 'flash-messages' do %>
<%= render 'layouts/admin/flash' %>
<% end %># コントローラ
respond_to do |format|
format.html { redirect_to path, notice: 'メッセージ' }
format.turbo_stream { flash.now[:notice] = 'メッセージ' }
enddata属性一覧
| 属性 | 用途 | 例 |
|---|---|---|
data-turbo-method | HTTPメソッド指定 | data: { turbo_method: :delete } |
data-turbo-confirm | 確認ダイアログ | data: { turbo_confirm: '確認' } |
data-turbo-frame | Turbo Frame指定 | data: { turbo_frame: 'modal' } |
data-turbo-stream | Turbo Stream有効化 | data: { turbo_stream: true } |
data-turbo-permanent | 永続化(Morphing) | data: { turbo_permanent: true } |
data-turbo | Turbo無効化 | data: { turbo: false } |
コントローラのレスポンス
基本パターン
def create
@user = User.new(user_params)
if @user.save
respond_to do |format|
format.html { redirect_to admin_user_path(@user), notice: '作成しました' }
format.turbo_stream { flash.now[:notice] = '作成しました' }
end
else
render :new, status: :unprocessable_entity
end
end
def update
if @user.update(user_params)
respond_to do |format|
format.html { redirect_to admin_user_path(@user), notice: '更新しました' }
format.turbo_stream { flash.now[:notice] = '更新しました' }
end
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@user.destroy
respond_to do |format|
format.html { redirect_to admin_users_path, notice: '削除しました' }
format.turbo_stream { flash.now[:notice] = '削除しました' }
end
endE2Eテストでの対応
button_toのテスト
# link_toではなくbutton_toを使用している場合
click_button '削除' # click_link ではない
# 確認ダイアログの処理
accept_confirm do
click_button '削除'
endTurbo Frame内の要素
within_frame 'users-table' do
expect(page).to have_content(user.name)
endステップ1: セットアップとルーティング
目次
---
概要
管理画面の基盤となる名前空間、ベースコントローラ、ルーティングを設定します。
名前空間の決定
管理画面のパスを決定します。一般的な選択肢:
| パス | 用途 |
|---|---|
/admin | 一般的な管理画面 |
/system_admin | システム管理者向け |
/staff | スタッフ向け |
/dashboard | ダッシュボード |
# コントローラ生成コマンド
rails generate controller Admin::Dashboard index
# または
rails generate controller SystemAdmin::Dashboard indexベースコントローラの作成
app/controllers/admin/base_controller.rb:
class Admin::BaseController < ApplicationController
# 管理画面専用レイアウトを使用
layout 'admin'
# 認証を必須に
before_action :authenticate_user!
before_action :authorize_admin!
private
def authorize_admin!
unless current_user&.admin?
flash[:alert] = '管理者権限が必要です'
redirect_to root_path
end
end
end各リソースコントローラの基本構造
app/controllers/admin/users_controller.rb:
class Admin::UsersController < Admin::BaseController
before_action :set_user, only: [:show, :edit, :update, :destroy]
def index
@users = User.order(created_at: :desc).page(params[:page])
end
def show
end
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
redirect_to admin_user_path(@user), notice: 'ユーザーを作成しました'
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @user.update(user_params)
redirect_to admin_user_path(@user), notice: 'ユーザーを更新しました'
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@user.destroy
redirect_to admin_users_path, notice: 'ユーザーを削除しました'
end
private
def set_user
@user = User.find(params[:id])
end
def user_params
params.require(:user).permit(:name, :email, :role)
end
endルーティングの設定
config/routes.rb:
Rails.application.routes.draw do
# 管理画面
namespace :admin do
root to: 'dashboard#index'
# リソース
resources :users do
member do
post :toggle_role # 権限切替
post :soft_delete # 論理削除
post :revive # 復活
end
end
resources :projects do
resources :members, controller: 'projects/members', only: [:index, :create, :destroy]
end
resources :reports, only: [:index, :show, :edit, :update, :destroy] do
collection do
get :summary # 集計
get :unsubmitted # 未提出一覧
end
end
resources :estimates
resources :bills
resources :user_roles, only: [:index, :show]
# CSV出力
resources :csvs, only: [:index] do
collection do
get :users
get :projects
get :reports
end
end
end
end認証・認可の設定
Deviseを使用する場合
# config/routes.rb
namespace :admin do
# 管理者専用のscopeを追加
end
# app/controllers/admin/base_controller.rb
class Admin::BaseController < ApplicationController
before_action :authenticate_user!
before_action :require_admin!
private
def require_admin!
redirect_to root_path, alert: '権限がありません' unless current_user.admin?
end
end自作認証を使用する場合
# app/controllers/admin/base_controller.rb
class Admin::BaseController < ApplicationController
before_action :require_admin_login!
private
def require_admin_login!
unless session[:admin_user_id]
redirect_to admin_login_path, alert: 'ログインが必要です'
end
end
def current_admin_user
@current_admin_user ||= AdminUser.find_by(id: session[:admin_user_id])
end
helper_method :current_admin_user
endロールベースの認可
# app/models/user.rb
class User < ApplicationRecord
has_many :user_roles
has_many :roles, through: :user_roles
def admin?
roles.exists?(name: 'admin')
end
def has_role?(role_name)
roles.exists?(name: role_name)
end
end
# app/controllers/admin/base_controller.rb
class Admin::BaseController < ApplicationController
before_action :authorize_admin!
private
def authorize_admin!
unless current_user&.admin?
respond_to do |format|
format.html { redirect_to root_path, alert: '管理者権限が必要です' }
format.turbo_stream { head :forbidden }
end
end
end
end次のステップ
レイアウトとナビゲーションの作成に進みます → @steps/02_layout_and_navigation.md
ステップ2: レイアウトとナビゲーション
目次
---
概要
管理画面専用のレイアウトファイルとナビゲーションを作成します。Tailwind CSSを使用したモダンなUIを構築します。
レイアウトファイルの作成
app/views/layouts/admin.html.erb:
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理画面 | <%= content_for(:title) || 'ダッシュボード' %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag 'tailwind', 'inter-font', 'data-turbo-track': 'reload' %>
<%= javascript_importmap_tags %>
</head>
<body class="bg-gray-100">
<div class="flex h-screen overflow-hidden">
<!-- サイドバー -->
<%= render 'layouts/admin/sidebar' %>
<!-- メインコンテンツ -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- ヘッダー -->
<%= render 'layouts/admin/header' %>
<!-- コンテンツエリア -->
<main class="flex-1 overflow-y-auto p-6">
<!-- フラッシュメッセージ -->
<%= render 'layouts/admin/flash' %>
<!-- パンくずリスト -->
<% if content_for?(:breadcrumbs) %>
<nav class="mb-4" aria-label="Breadcrumb">
<%= yield :breadcrumbs %>
</nav>
<% end %>
<!-- ページコンテンツ -->
<%= yield %>
</main>
</div>
</div>
</body>
</html>サイドバーナビゲーション
app/views/layouts/admin/_sidebar.html.erb:
<aside class="w-64 bg-gray-800 text-white flex-shrink-0" data-testid="admin-sidebar">
<!-- ロゴ -->
<div class="h-16 flex items-center justify-center border-b border-gray-700">
<%= link_to admin_root_path, class: 'text-xl font-bold text-white hover:text-gray-300' do %>
管理画面
<% end %>
</div>
<!-- ナビゲーション -->
<nav class="p-4">
<ul class="space-y-2">
<%= render 'layouts/admin/nav_item', path: admin_root_path, icon: 'home', label: 'ダッシュボード', testid: 'nav-dashboard' %>
<%= render 'layouts/admin/nav_item', path: admin_users_path, icon: 'users', label: 'ユーザー', testid: 'nav-users' %>
<%= render 'layouts/admin/nav_item', path: admin_projects_path, icon: 'folder', label: 'プロジェクト', testid: 'nav-projects' %>
<%= render 'layouts/admin/nav_item', path: admin_reports_path, icon: 'document', label: '日報', testid: 'nav-reports' %>
<%= render 'layouts/admin/nav_item', path: admin_estimates_path, icon: 'calculator', label: '見積書', testid: 'nav-estimates' %>
<%= render 'layouts/admin/nav_item', path: admin_bills_path, icon: 'receipt', label: '請求書', testid: 'nav-bills' %>
<%= render 'layouts/admin/nav_item', path: admin_user_roles_path, icon: 'shield', label: '権限管理', testid: 'nav-roles' %>
<%= render 'layouts/admin/nav_item', path: admin_csvs_path, icon: 'download', label: 'CSV出力', testid: 'nav-csvs' %>
</ul>
</nav>
<!-- フッター -->
<div class="absolute bottom-0 w-64 p-4 border-t border-gray-700">
<%= link_to root_path, class: 'flex items-center text-gray-300 hover:text-white', data: { testid: 'back-to-app' } do %>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
アプリに戻る
<% end %>
</div>
</aside>app/views/layouts/admin/_nav_item.html.erb:
<%# locals: (path:, icon:, label:, testid:) %>
<li>
<%= link_to path,
class: "flex items-center px-4 py-2 rounded-md transition-colors #{current_page?(path) ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'}",
data: { testid: testid } do %>
<%= render "layouts/admin/icons/#{icon}" %>
<span class="ml-3"><%= label %></span>
<% end %>
</li>ヘッダー
app/views/layouts/admin/_header.html.erb:
<header class="h-16 bg-white shadow-sm flex items-center justify-between px-6">
<div>
<h1 class="text-xl font-semibold text-gray-800">
<%= content_for?(:page_title) ? yield(:page_title) : 'ダッシュボード' %>
</h1>
</div>
<div class="flex items-center space-x-4">
<!-- ユーザー情報 -->
<span class="text-gray-600"><%= current_user.name %></span>
<!-- ログアウト -->
<%= button_to 'ログアウト',
destroy_user_session_path,
method: :delete,
class: 'text-gray-600 hover:text-gray-800',
data: { testid: 'logout-button' } %>
</div>
</header>フラッシュメッセージ
app/views/layouts/admin/_flash.html.erb:
<% flash.each do |type, message| %>
<%
base_classes = 'mb-4 px-4 py-3 rounded-md flex items-center justify-between'
type_classes = case type.to_sym
when :notice, :success
'bg-green-50 text-green-800 border border-green-200'
when :alert, :error
'bg-red-50 text-red-800 border border-red-200'
when :warning
'bg-yellow-50 text-yellow-800 border border-yellow-200'
else
'bg-blue-50 text-blue-800 border border-blue-200'
end
%>
<div class="<%= base_classes %> <%= type_classes %>"
data-controller="flash"
data-flash-auto-dismiss-value="true"
data-testid="flash-<%= type %>">
<span><%= message %></span>
<button type="button"
class="text-current opacity-50 hover:opacity-100"
data-action="flash#dismiss">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</button>
</div>
<% end %>レスポンシブ対応
モバイルメニュー用Stimulusコントローラ
app/javascript/controllers/mobile_menu_controller.js:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["sidebar", "overlay"]
static values = { open: Boolean }
toggle() {
this.openValue = !this.openValue
}
close() {
this.openValue = false
}
openValueChanged() {
if (this.openValue) {
this.sidebarTarget.classList.remove('-translate-x-full')
this.overlayTarget.classList.remove('hidden')
} else {
this.sidebarTarget.classList.add('-translate-x-full')
this.overlayTarget.classList.add('hidden')
}
}
}レスポンシブレイアウト
<body class="bg-gray-100" data-controller="mobile-menu">
<!-- モバイルオーバーレイ -->
<div class="fixed inset-0 bg-black bg-opacity-50 z-20 lg:hidden hidden"
data-mobile-menu-target="overlay"
data-action="click->mobile-menu#close"></div>
<div class="flex h-screen overflow-hidden">
<!-- サイドバー(モバイルではスライドイン) -->
<aside class="fixed lg:static inset-y-0 left-0 z-30 w-64 bg-gray-800 text-white transform -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out"
data-mobile-menu-target="sidebar">
<!-- サイドバー内容 -->
</aside>
<div class="flex-1 flex flex-col overflow-hidden">
<!-- モバイルヘッダー -->
<header class="lg:hidden h-16 bg-white shadow-sm flex items-center px-4">
<button type="button"
class="p-2 rounded-md text-gray-600 hover:text-gray-800 hover:bg-gray-100"
data-action="mobile-menu#toggle">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
<span class="ml-4 text-lg font-semibold">管理画面</span>
</header>
<!-- メインコンテンツ -->
<main class="flex-1 overflow-y-auto p-4 lg:p-6">
<%= yield %>
</main>
</div>
</div>
</body>アイコンSVG
app/views/layouts/admin/icons/_home.html.erb:
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>app/views/layouts/admin/icons/_users.html.erb:
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>次のステップ
CRUD画面の実装に進みます → @steps/03_crud_views.md
ステップ3: CRUD画面の実装
目次
---
概要
一覧・詳細・新規作成・編集の各CRUD画面を実装します。data-testid属性を付与してE2Eテストに対応します。
一覧画面
app/views/admin/users/index.html.erb:
<% content_for :page_title, 'ユーザー管理' %>
<div class="bg-white shadow rounded-lg" data-testid="users-list">
<!-- ヘッダー -->
<div class="px-6 py-4 border-b flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 class="text-xl font-semibold text-gray-800">ユーザー一覧</h2>
<%= link_to '新規作成', new_admin_user_path,
class: 'inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors',
data: { testid: 'new-user-button' } %>
</div>
<!-- 検索・フィルタ -->
<div class="px-6 py-4 border-b bg-gray-50">
<%= form_with url: admin_users_path, method: :get, class: 'flex flex-wrap gap-4', data: { turbo_frame: 'users-table' } do |f| %>
<div class="flex-1 min-w-[200px]">
<%= f.text_field :q, value: params[:q], placeholder: '名前・メールで検索',
class: 'w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500',
data: { testid: 'search-input' } %>
</div>
<div>
<%= f.select :role, [['すべて', ''], ['管理者', 'admin'], ['一般', 'user']],
{ selected: params[:role] },
class: 'rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500',
data: { testid: 'role-filter' } %>
</div>
<%= f.submit '検索', class: 'px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 cursor-pointer',
data: { testid: 'search-button' } %>
<% end %>
</div>
<!-- テーブル -->
<%= turbo_frame_tag 'users-table' do %>
<div class="overflow-x-auto">
<table class="w-full" data-testid="users-table">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">名前</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">メール</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">権限</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">作成日</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<% @users.each do |user| %>
<tr data-testid="user-row-<%= user.id %>">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= user.id %></td>
<td class="px-6 py-4 whitespace-nowrap">
<%= link_to user.name, admin_user_path(user),
class: 'text-blue-600 hover:text-blue-800',
data: { testid: "user-name-#{user.id}" } %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= user.email %></td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs rounded-full <%= user.admin? ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-800' %>">
<%= user.admin? ? '管理者' : '一般' %>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= l user.created_at, format: :short %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<%= link_to '編集', edit_admin_user_path(user),
class: 'text-blue-600 hover:text-blue-800 mr-3',
data: { testid: "edit-user-#{user.id}" } %>
<%= button_to '削除', admin_user_path(user),
method: :delete,
class: 'text-red-600 hover:text-red-800',
form: { data: { turbo_confirm: "#{user.name}を削除しますか?" } },
data: { testid: "delete-user-#{user.id}" } %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<!-- ページネーション -->
<div class="px-6 py-4 border-t">
<%== pagy_nav(@pagy) if @pagy.pages > 1 %>
</div>
<% end %>
</div>詳細画面
app/views/admin/users/show.html.erb:
<% content_for :page_title, @user.name %>
<div class="max-w-4xl" data-testid="user-detail">
<!-- ヘッダー -->
<div class="bg-white shadow rounded-lg mb-6">
<div class="px-6 py-4 border-b flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-800"><%= @user.name %></h2>
<div class="flex items-center space-x-3">
<%= link_to '編集', edit_admin_user_path(@user),
class: 'px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700',
data: { testid: 'edit-button' } %>
<%= button_to '削除', admin_user_path(@user),
method: :delete,
class: 'px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700',
form: { data: { turbo_confirm: '本当に削除しますか?' } },
data: { testid: 'delete-button' } %>
</div>
</div>
<!-- 詳細情報 -->
<dl class="divide-y divide-gray-200">
<div class="px-6 py-4 grid grid-cols-3 gap-4">
<dt class="text-sm font-medium text-gray-500">ID</dt>
<dd class="text-sm text-gray-900 col-span-2" data-testid="user-id"><%= @user.id %></dd>
</div>
<div class="px-6 py-4 grid grid-cols-3 gap-4">
<dt class="text-sm font-medium text-gray-500">名前</dt>
<dd class="text-sm text-gray-900 col-span-2" data-testid="user-name"><%= @user.name %></dd>
</div>
<div class="px-6 py-4 grid grid-cols-3 gap-4">
<dt class="text-sm font-medium text-gray-500">メールアドレス</dt>
<dd class="text-sm text-gray-900 col-span-2" data-testid="user-email"><%= @user.email %></dd>
</div>
<div class="px-6 py-4 grid grid-cols-3 gap-4">
<dt class="text-sm font-medium text-gray-500">権限</dt>
<dd class="text-sm text-gray-900 col-span-2">
<span class="px-2 py-1 text-xs rounded-full <%= @user.admin? ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-800' %>"
data-testid="user-role">
<%= @user.admin? ? '管理者' : '一般' %>
</span>
</dd>
</div>
<div class="px-6 py-4 grid grid-cols-3 gap-4">
<dt class="text-sm font-medium text-gray-500">作成日時</dt>
<dd class="text-sm text-gray-900 col-span-2"><%= l @user.created_at, format: :long %></dd>
</div>
<div class="px-6 py-4 grid grid-cols-3 gap-4">
<dt class="text-sm font-medium text-gray-500">更新日時</dt>
<dd class="text-sm text-gray-900 col-span-2"><%= l @user.updated_at, format: :long %></dd>
</div>
</dl>
</div>
<!-- 戻るリンク -->
<div>
<%= link_to '← 一覧に戻る', admin_users_path,
class: 'text-gray-600 hover:text-gray-800',
data: { testid: 'back-to-list' } %>
</div>
</div>新規作成・編集画面
app/views/admin/users/new.html.erb:
<% content_for :page_title, 'ユーザー新規作成' %>
<div class="max-w-2xl">
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b">
<h2 class="text-xl font-semibold text-gray-800">ユーザー新規作成</h2>
</div>
<div class="p-6">
<%= render 'form', user: @user %>
</div>
</div>
</div>app/views/admin/users/edit.html.erb:
<% content_for :page_title, "#{@user.name}の編集" %>
<div class="max-w-2xl">
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b">
<h2 class="text-xl font-semibold text-gray-800">ユーザー編集</h2>
</div>
<div class="p-6">
<%= render 'form', user: @user %>
</div>
</div>
</div>app/views/admin/users/_form.html.erb:
<%= form_with model: [:admin, user], class: 'space-y-6', data: { testid: 'user-form' } do |f| %>
<% if user.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-md p-4" data-testid="error-messages">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(user.errors.count, 'エラー') %>があります
</h3>
<ul class="mt-2 list-disc list-inside text-sm text-red-700">
<% user.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<!-- 名前 -->
<div>
<%= f.label :name, '名前', class: 'block text-sm font-medium text-gray-700' %>
<%= f.text_field :name,
class: 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500',
data: { testid: 'input-name' } %>
</div>
<!-- メールアドレス -->
<div>
<%= f.label :email, 'メールアドレス', class: 'block text-sm font-medium text-gray-700' %>
<%= f.email_field :email,
class: 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500',
data: { testid: 'input-email' } %>
</div>
<!-- パスワード -->
<% if user.new_record? %>
<div>
<%= f.label :password, 'パスワード', class: 'block text-sm font-medium text-gray-700' %>
<%= f.password_field :password,
class: 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500',
data: { testid: 'input-password' } %>
</div>
<% end %>
<!-- 職種(enum) -->
<div>
<%= f.label :division, '職種', class: 'block text-sm font-medium text-gray-700' %>
<%= f.select :division,
User.divisions.keys.map { |k| [I18n.t("enums.user.division.#{k}"), k] },
{ include_blank: '選択してください' },
class: 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500',
data: { testid: 'input-division' } %>
</div>
<!-- 権限(チェックボックス) -->
<div>
<span class="block text-sm font-medium text-gray-700 mb-2">権限</span>
<div class="space-y-2">
<% UserRole.select("MIN(id) as id, role").group(:role).each do |role| %>
<label class="flex items-center">
<%= check_box_tag 'user[role_ids][]', role.id,
user.roles.pluck(:role).include?(role.role),
class: 'h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500',
data: { testid: "input-role-#{role.role}" } %>
<span class="ml-2 text-sm text-gray-700">
<%= I18n.t("enums.user_role.role.#{role.role}", default: role.role) %>
</span>
</label>
<% end %>
</div>
</div>
<!-- 送信ボタン -->
<div class="flex items-center justify-between pt-4">
<%= link_to 'キャンセル', admin_users_path,
class: 'text-gray-600 hover:text-gray-800',
data: { testid: 'cancel-button' } %>
<%= f.submit user.new_record? ? '作成' : '更新',
class: 'px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 cursor-pointer',
data: { testid: 'submit-button' } %>
</div>
<% end %>フォームヘルパー
app/helpers/admin/form_helper.rb:
module Admin::FormHelper
# データ型に応じた入力フィールドを生成
def admin_field_for(form, field_name, field_type, options = {})
case field_type.to_sym
when :string
form.text_field field_name, class: admin_input_class, **options
when :text
form.text_area field_name, rows: options[:rows] || 5, class: admin_input_class, **options.except(:rows)
when :integer
form.number_field field_name, step: 1, class: admin_input_class, **options
when :decimal, :float
form.number_field field_name, step: 0.01, class: admin_input_class, **options
when :boolean
form.check_box field_name, class: 'h-4 w-4 text-blue-600 border-gray-300 rounded', **options
when :date
form.date_field field_name, class: admin_input_class, **options
when :datetime
form.datetime_local_field field_name, class: admin_input_class, **options
when :email
form.email_field field_name, class: admin_input_class, **options
when :password
form.password_field field_name, class: admin_input_class, **options
when :enum
enum_options = options.delete(:collection) || []
form.select field_name, enum_options, { include_blank: '選択してください' }, class: admin_input_class
when :belongs_to
collection = options.delete(:collection) || []
label_method = options.delete(:label_method) || :name
form.collection_select field_name, collection, :id, label_method, { include_blank: '選択してください' }, class: admin_input_class
when :file
form.file_field field_name, class: admin_file_input_class, **options
else
form.text_field field_name, class: admin_input_class, **options
end
end
private
def admin_input_class
'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500'
end
def admin_file_input_class
'mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100'
end
endページネーション
Pagyを使用したページネーション:
# Gemfile
gem 'pagy'
# config/initializers/pagy.rb
require 'pagy/extras/overflow'
Pagy::DEFAULT[:items] = 20
Pagy::DEFAULT[:overflow] = :last_page
# app/controllers/admin/base_controller.rb
class Admin::BaseController < ApplicationController
include Pagy::Backend
end
# app/helpers/application_helper.rb
module ApplicationHelper
include Pagy::Frontend
end検索・フィルタリング
# app/controllers/admin/users_controller.rb
def index
@users = User.all
@users = @users.where('name LIKE ? OR email LIKE ?', "%#{params[:q]}%", "%#{params[:q]}%") if params[:q].present?
@users = @users.where(role: params[:role]) if params[:role].present?
@pagy, @users = pagy(@users.order(created_at: :desc))
end次のステップ
Turbo対応アクションの実装に進みます → @steps/04_turbo_actions.md
ステップ4: Turbo対応アクション
目次
---
概要
Rails 8 + Turbo環境では、従来のlink_toのmethod:オプションが機能しません。この章では、Turbo対応の正しいアクション実装方法を解説します。
削除アクションの実装
重要: link_to の method: は使用しない
<%# NG: Rails 8 Turbo環境では動作しない %>
<%= link_to '削除', admin_user_path(@user), method: :delete, data: { confirm: '削除しますか?' } %>
<%# OK: button_to を使用 %>
<%= button_to '削除', admin_user_path(@user),
method: :delete,
class: 'text-red-600 hover:text-red-800',
form: { data: { turbo_confirm: '削除しますか?' } },
data: { testid: 'delete-button' } %>ボタンのスタイリング
button_toはデフォルトでフォーム要素を生成するため、リンクのようにインラインで表示するにはスタイリングが必要です:
<%# 一覧画面のアクションボタン(インライン表示) %>
<%= button_to '削除', admin_user_path(user),
method: :delete,
class: 'text-red-600 hover:text-red-800 bg-transparent border-0 p-0 cursor-pointer',
form: { class: 'inline', data: { turbo_confirm: "#{user.name}を削除しますか?" } },
data: { testid: "delete-user-#{user.id}" } %>
<%# 詳細画面のボタン %>
<%= button_to '削除', admin_user_path(@user),
method: :delete,
class: 'px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700',
form: { data: { turbo_confirm: '本当に削除しますか?' } },
data: { testid: 'delete-button' } %>コントローラの実装
# app/controllers/admin/users_controller.rb
class Admin::UsersController < Admin::BaseController
def destroy
@user = User.find(params[:id])
@user.destroy
respond_to do |format|
format.html { redirect_to admin_users_path, notice: 'ユーザーを削除しました' }
format.turbo_stream {
flash.now[:notice] = 'ユーザーを削除しました'
}
end
end
endカスタムアクション
論理削除と復活
# config/routes.rb
namespace :admin do
resources :users do
member do
post :soft_delete
post :revive
post :toggle_role
end
end
end# app/controllers/admin/users_controller.rb
class Admin::UsersController < Admin::BaseController
# POST /admin/users/:id/soft_delete
def soft_delete
@user = User.find(params[:id])
@user.update!(deleted_at: Time.current)
respond_to do |format|
format.html { redirect_to admin_user_path(@user), notice: 'ユーザーを削除しました' }
format.turbo_stream {
flash.now[:notice] = 'ユーザーを削除しました'
}
end
end
# POST /admin/users/:id/revive
def revive
@user = User.find(params[:id])
@user.update!(deleted_at: nil)
respond_to do |format|
format.html { redirect_to admin_user_path(@user), notice: 'ユーザーを復活しました' }
format.turbo_stream {
flash.now[:notice] = 'ユーザーを復活しました'
}
end
end
# POST /admin/users/:id/toggle_role
def toggle_role
@user = User.find(params[:id])
role = UserRole.find(params[:role_id])
if @user.roles.include?(role)
@user.roles.delete(role)
message = "#{role.name}権限を削除しました"
else
@user.roles << role
message = "#{role.name}権限を付与しました"
end
respond_to do |format|
format.html { redirect_to admin_user_path(@user), notice: message }
format.turbo_stream {
flash.now[:notice] = message
}
end
end
endビューでのカスタムアクションボタン
<%# app/views/admin/users/show.html.erb %>
<div class="flex items-center space-x-3">
<% if @user.deleted? %>
<%= button_to '復活させる', revive_admin_user_path(@user),
method: :post,
class: 'px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700',
form: { data: { turbo_confirm: 'ユーザーを復活させますか?' } },
data: { testid: 'revive-button' } %>
<% else %>
<%= button_to '無効化', soft_delete_admin_user_path(@user),
method: :post,
class: 'px-4 py-2 bg-yellow-600 text-white rounded-md hover:bg-yellow-700',
form: { data: { turbo_confirm: 'ユーザーを無効化しますか?' } },
data: { testid: 'soft-delete-button' } %>
<% end %>
<%= link_to '編集', edit_admin_user_path(@user),
class: 'px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700',
data: { testid: 'edit-button' } %>
<%= button_to '完全削除', admin_user_path(@user),
method: :delete,
class: 'px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700',
form: { data: { turbo_confirm: '本当に削除しますか?この操作は取り消せません。' } },
data: { testid: 'delete-button' } %>
</div>Turbo Stream応答
削除後にテーブルから行を削除
app/views/admin/users/destroy.turbo_stream.erb:
<%= turbo_stream.remove "user-row-#{@user.id}" %>
<%= turbo_stream.update "flash-messages" do %>
<%= render 'layouts/admin/flash' %>
<% end %>更新後に行を差し替え
app/views/admin/users/update.turbo_stream.erb:
<%= turbo_stream.replace "user-row-#{@user.id}" do %>
<%= render 'admin/users/user_row', user: @user %>
<% end %>
<%= turbo_stream.update "flash-messages" do %>
<%= render 'layouts/admin/flash' %>
<% end %>フラッシュメッセージの更新
レイアウトにTurbo Frame追加
<%# app/views/layouts/admin.html.erb %>
<main class="flex-1 overflow-y-auto p-6">
<%= turbo_frame_tag 'flash-messages' do %>
<%= render 'layouts/admin/flash' %>
<% end %>
<%= yield %>
</main>コントローラでの設定
def destroy
@user.destroy
respond_to do |format|
format.html { redirect_to admin_users_path, notice: '削除しました' }
format.turbo_stream {
flash.now[:notice] = '削除しました'
# Turbo Streamテンプレートが自動でレンダリングされる
}
end
endよくある間違いと対処法
1. data: { confirm: ... } が効かない
<%# NG %>
<%= button_to '削除', path, data: { confirm: '削除しますか?' } %>
<%# OK: turbo_confirm を使用 %>
<%= button_to '削除', path, form: { data: { turbo_confirm: '削除しますか?' } } %>2. method: :delete が効かない
<%# NG: link_to + method %>
<%= link_to '削除', path, method: :delete %>
<%# OK: button_to を使用 %>
<%= button_to '削除', path, method: :delete %>
<%# OK: link_to + data-turbo-method %>
<%= link_to '削除', path, data: { turbo_method: :delete } %>3. フォームの外観がおかしい
<%# button_to はデフォルトでブロック要素のフォームを生成 %>
<%# インラインにするには form: { class: 'inline' } を指定 %>
<%= button_to '削除', path,
method: :delete,
form: { class: 'inline', data: { turbo_confirm: '確認' } },
class: 'text-red-600' %>4. 確認ダイアログをスキップしたい(テスト用)
# spec/support/turbo_helpers.rb
module TurboHelpers
def accept_turbo_confirm
page.driver.with_playwright_page do |playwright_page|
playwright_page.on('dialog', ->(dialog) { dialog.accept })
end
end
end
RSpec.configure do |config|
config.include TurboHelpers, type: :feature
end次のステップ
Stimulusコントローラの実装に進みます → @steps/05_stimulus_controllers.md
ステップ5: Stimulusコントローラ
目次
---
概要
Stimulusを使用して、管理画面で必要なインタラクティブな機能を実装します。
フラッシュメッセージ
自動消去とクローズボタン付きのフラッシュメッセージ:
app/javascript/controllers/flash_controller.js:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
autoDismiss: { type: Boolean, default: true },
dismissAfter: { type: Number, default: 5000 }
}
connect() {
if (this.autoDismissValue) {
this.timeout = setTimeout(() => {
this.dismiss()
}, this.dismissAfterValue)
}
}
disconnect() {
if (this.timeout) {
clearTimeout(this.timeout)
}
}
dismiss() {
this.element.classList.add('opacity-0', 'transition-opacity', 'duration-300')
setTimeout(() => {
this.element.remove()
}, 300)
}
}使用例:
<div class="bg-green-50 border border-green-200 rounded-md p-4"
data-controller="flash"
data-flash-auto-dismiss-value="true"
data-flash-dismiss-after-value="5000">
<div class="flex items-center justify-between">
<span>保存しました</span>
<button type="button" data-action="flash#dismiss" class="text-green-600 hover:text-green-800">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</button>
</div>
</div>確認ダイアログ
カスタムスタイルの確認ダイアログ:
app/javascript/controllers/confirm_dialog_controller.js:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
title: { type: String, default: '確認' },
message: String,
confirmText: { type: String, default: '実行' },
cancelText: { type: String, default: 'キャンセル' },
confirmClass: { type: String, default: 'bg-blue-600 hover:bg-blue-700' }
}
confirm(event) {
event.preventDefault()
const dialog = document.createElement('div')
dialog.className = 'fixed inset-0 z-50 flex items-center justify-center'
dialog.innerHTML = `
<div class="fixed inset-0 bg-black bg-opacity-50" data-action="click->confirm-dialog#cancel"></div>
<div class="relative bg-white rounded-lg shadow-xl max-w-md w-full mx-4 z-10">
<div class="px-6 py-4 border-b">
<h3 class="text-lg font-medium text-gray-900">${this.titleValue}</h3>
</div>
<div class="px-6 py-4">
<p class="text-gray-600">${this.messageValue}</p>
</div>
<div class="px-6 py-4 bg-gray-50 flex justify-end space-x-3 rounded-b-lg">
<button type="button" class="px-4 py-2 text-gray-700 hover:text-gray-900 cancel-btn">
${this.cancelTextValue}
</button>
<button type="button" class="px-4 py-2 text-white rounded-md confirm-btn ${this.confirmClassValue}">
${this.confirmTextValue}
</button>
</div>
</div>
`
document.body.appendChild(dialog)
dialog.querySelector('.cancel-btn').addEventListener('click', () => {
dialog.remove()
})
dialog.querySelector('.confirm-btn').addEventListener('click', () => {
dialog.remove()
// フォームの場合はsubmit、リンクの場合はナビゲート
if (this.element.tagName === 'FORM') {
this.element.requestSubmit()
} else if (this.element.href) {
window.location.href = this.element.href
}
})
// ESCキーでキャンセル
const handleEscape = (e) => {
if (e.key === 'Escape') {
dialog.remove()
document.removeEventListener('keydown', handleEscape)
}
}
document.addEventListener('keydown', handleEscape)
}
cancel() {
this.element.closest('[data-controller="confirm-dialog"]')?.remove()
}
}動的フォーム
条件付きフィールド表示
app/javascript/controllers/conditional_fields_controller.js:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["trigger", "conditional"]
static values = {
showWhen: String
}
connect() {
this.toggle()
}
toggle() {
const triggerValue = this.triggerTarget.value
const shouldShow = triggerValue === this.showWhenValue
this.conditionalTargets.forEach(el => {
if (shouldShow) {
el.classList.remove('hidden')
el.querySelectorAll('input, select, textarea').forEach(input => {
input.disabled = false
})
} else {
el.classList.add('hidden')
el.querySelectorAll('input, select, textarea').forEach(input => {
input.disabled = true
})
}
})
}
}使用例:
<div data-controller="conditional-fields" data-conditional-fields-show-when-value="other">
<div>
<%= f.label :category %>
<%= f.select :category, [['通常', 'normal'], ['特別', 'special'], ['その他', 'other']],
{}, data: { conditional_fields_target: 'trigger', action: 'conditional-fields#toggle' } %>
</div>
<div data-conditional-fields-target="conditional" class="hidden">
<%= f.label :category_other, 'その他の詳細' %>
<%= f.text_field :category_other %>
</div>
</div>動的項目追加
app/javascript/controllers/nested_form_controller.js:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["template", "container"]
static values = {
index: Number
}
connect() {
this.indexValue = this.containerTarget.children.length
}
add(event) {
event.preventDefault()
const content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, this.indexValue)
this.containerTarget.insertAdjacentHTML('beforeend', content)
this.indexValue++
}
remove(event) {
event.preventDefault()
const item = event.target.closest('[data-nested-form-item]')
// 既存レコードの場合は_destroy=1をセット
const destroyInput = item.querySelector('[data-destroy-field]')
if (destroyInput) {
destroyInput.value = '1'
item.classList.add('hidden')
} else {
item.remove()
}
}
}ファイルアップロードプレビュー
app/javascript/controllers/file_preview_controller.js:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "preview", "placeholder"]
preview() {
const file = this.inputTarget.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
if (this.hasPlaceholderTarget) {
this.placeholderTarget.classList.add('hidden')
}
if (file.type.startsWith('image/')) {
this.previewTarget.innerHTML = `
<img src="${e.target.result}" class="max-h-48 rounded-lg" alt="Preview">
`
} else {
this.previewTarget.innerHTML = `
<div class="flex items-center space-x-2 text-gray-600">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<span>${file.name}</span>
</div>
`
}
this.previewTarget.classList.remove('hidden')
}
reader.readAsDataURL(file)
}
}使用例:
<div data-controller="file-preview">
<label class="block">
<span class="text-sm font-medium text-gray-700">画像</span>
<div class="mt-1">
<%= f.file_field :image,
accept: 'image/*',
class: 'hidden',
data: { file_preview_target: 'input', action: 'file-preview#preview' } %>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer hover:border-gray-400"
data-file-preview-target="placeholder"
onclick="this.previousElementSibling.click()">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<p class="mt-2 text-sm text-gray-600">クリックして画像を選択</p>
</div>
</div>
</label>
<div data-file-preview-target="preview" class="mt-4 hidden"></div>
</div>検索フォーム
デバウンス付きリアルタイム検索:
app/javascript/controllers/search_form_controller.js:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input"]
static values = {
debounce: { type: Number, default: 300 }
}
connect() {
this.timeout = null
}
search() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.element.requestSubmit()
}, this.debounceValue)
}
reset() {
this.inputTarget.value = ''
this.element.requestSubmit()
}
}使用例:
<%= form_with url: admin_users_path, method: :get,
data: { controller: 'search-form', turbo_frame: 'users-table' } do |f| %>
<div class="relative">
<%= f.text_field :q,
value: params[:q],
placeholder: '検索...',
class: 'w-full pl-10 pr-10 rounded-md border-gray-300',
data: { search_form_target: 'input', action: 'input->search-form#search' } %>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<% if params[:q].present? %>
<button type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center"
data-action="search-form#reset">
<svg class="h-5 w-5 text-gray-400 hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<% end %>
</div>
<% end %>次のステップ
E2Eテスト設計に進みます → @steps/06_e2e_test_design.md
ステップ6: E2Eテスト設計
目次
---
概要
管理画面のE2Eテストを体系的に設計します。テストケースを洗い出し、優先度を付けて計画的に実装します。
テストケース洗い出しの方針
1. 機能カテゴリで分類
| カテゴリ | 内容 |
|---|---|
| 認証・認可 | ログイン、ログアウト、権限チェック |
| ナビゲーション | サイドバー遷移、パンくず |
| CRUD操作 | 作成、読取、更新、削除 |
| 検索・フィルタ | キーワード検索、条件フィルタ |
| 一括操作 | 一括削除、一括更新 |
| カスタムアクション | 公開/非公開、有効化/無効化 |
2. 画面単位で洗い出し
各リソースに対して:
- 一覧画面(index)の表示確認
- 詳細画面(show)の表示確認
- 新規作成画面(new)の表示確認
- 編集画面(edit)の表示確認
- 作成処理(create)の動作確認
- 更新処理(update)の動作確認
- 削除処理(destroy)の動作確認
3. エッジケースの考慮
- データが0件の場合
- ページネーションが必要な場合
- バリデーションエラーの場合
- 権限がない場合
カテゴリ別テストケース一覧
認証・認可
## 認証テスト
- [ ] 未認証ユーザーがアクセス → ログイン画面にリダイレクト
- [ ] 一般ユーザーがアクセス → 権限エラー表示
- [ ] 管理者ユーザーがアクセス → ダッシュボード表示
- [ ] ログアウトボタンクリック → ログイン画面に遷移ダッシュボード
## ダッシュボードテスト
- [ ] 統計カードが表示される(ユーザー数、プロジェクト数など)
- [ ] 最近のアクティビティが表示される
- [ ] クイックリンクが機能するユーザー管理(例)
## ユーザー管理テスト
### 一覧画面
- [ ] ユーザー一覧が表示される
- [ ] ページネーションが機能する
- [ ] 名前で検索できる
- [ ] メールで検索できる
- [ ] 権限でフィルタできる
- [ ] 新規作成ボタンがある
### 詳細画面
- [ ] ユーザー情報が正しく表示される
- [ ] 編集ボタンがある
- [ ] 削除ボタンがある
- [ ] 一覧に戻るリンクがある
### 新規作成
- [ ] フォームが表示される
- [ ] 必須項目が入力できる
- [ ] 保存すると一覧に遷移する
- [ ] バリデーションエラーが表示される
### 編集
- [ ] 既存データがフォームに表示される
- [ ] 更新すると詳細画面に遷移する
- [ ] バリデーションエラーが表示される
### 削除
- [ ] 確認ダイアログが表示される
- [ ] 削除後は一覧に遷移する
- [ ] フラッシュメッセージが表示されるテストカバレッジマトリックス
リソース × アクション マトリックス
| リソース | index | show | new | edit | create | update | destroy |
|---|---|---|---|---|---|---|---|
| Users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Projects | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Reports | ✅ | ✅ | - | ✅ | - | ✅ | ✅ |
| Estimates | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Bills | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| UserRoles | ✅ | ✅ | - | - | - | - | - |
カスタムアクション マトリックス
| リソース | アクション | テスト |
|---|---|---|
| Users | soft_delete | ✅ |
| Users | revive | ✅ |
| Users | toggle_role | ✅ |
| Projects | archive | ✅ |
| Reports | summary | ✅ |
| Reports | unsubmitted | ✅ |
テスト優先度の決定
P0: 必須(リリースブロッカー)
- 認証・認可
- 基本的なCRUD操作
- データ破壊を伴う操作(削除)
P1: 重要
- 検索・フィルタリング
- ページネーション
- カスタムアクション
P2: あれば良い
- レスポンシブ対応
- エラーハンドリング
- エッジケース
テストデータの準備
FactoryBot定義
# spec/factories/users.rb
FactoryBot.define do
factory :user do
sequence(:name) { |n| "テストユーザー#{n}" }
sequence(:email) { |n| "user#{n}@example.com" }
password { 'password123' }
trait :admin do
after(:create) do |user|
user.roles << create(:user_role, :admin)
end
end
trait :deleted do
deleted_at { Time.current }
end
end
end
# spec/factories/user_roles.rb
FactoryBot.define do
factory :user_role do
trait :admin do
role { 'admin' }
name { '管理者' }
end
trait :member do
role { 'member' }
name { '一般メンバー' }
end
end
endテストヘルパー
# spec/support/admin_helpers.rb
module AdminHelpers
def sign_in_as_admin
@admin_user = create(:user, :admin)
visit admin_login_path
fill_in 'メールアドレス', with: @admin_user.email
fill_in 'パスワード', with: 'password123'
click_button 'ログイン'
expect(page).to have_content('ダッシュボード')
end
def sign_in_to_admin(user)
visit admin_login_path
fill_in 'メールアドレス', with: user.email
fill_in 'パスワード', with: 'password123'
click_button 'ログイン'
end
end
RSpec.configure do |config|
config.include AdminHelpers, type: :feature
endテストデータのセットアップ
# spec/features/admin/users_spec.rb
RSpec.describe '管理画面 - ユーザー管理', type: :feature do
let!(:admin) { create(:user, :admin) }
let!(:users) { create_list(:user, 25) } # ページネーション確認用
before do
sign_in_to_admin(admin)
end
describe '一覧画面' do
before { visit admin_users_path }
it 'ユーザー一覧が表示される' do
expect(page).to have_selector('[data-testid="users-table"]')
expect(page).to have_content(users.first.name)
end
it 'ページネーションが表示される' do
expect(page).to have_selector('.pagy-nav')
end
end
endチェックリストテンプレート
新しいリソースを追加する際のチェックリスト:
## [リソース名] E2Eテストチェックリスト
### 画面表示
- [ ] 一覧画面が表示される
- [ ] 詳細画面が表示される
- [ ] 新規作成画面が表示される
- [ ] 編集画面が表示される
### CRUD操作
- [ ] 新規作成が成功する
- [ ] 新規作成のバリデーションエラーが表示される
- [ ] 更新が成功する
- [ ] 更新のバリデーションエラーが表示される
- [ ] 削除が成功する
- [ ] 削除の確認ダイアログが表示される
### 検索・フィルタ
- [ ] キーワード検索ができる
- [ ] 条件フィルタができる
- [ ] 検索結果が正しく表示される
### ナビゲーション
- [ ] サイドバーからアクセスできる
- [ ] パンくずが正しく表示される
- [ ] 一覧↔詳細の遷移ができる次のステップ
E2Eテスト実装に進みます → @steps/07_e2e_test_implementation.md
ステップ7: E2Eテスト実装
目次
---
概要
Playwright + Capybaraを使用してE2Eテストを実装します。TDDアプローチで、テストを先に書いてから実装を進めます。
テスト環境のセットアップ
Gemfile
group :test do
gem 'capybara'
gem 'capybara-playwright-driver'
gem 'factory_bot_rails'
gem 'database_cleaner-active_record'
endpackage.json
{
"devDependencies": {
"@playwright/test": "^1.40.0"
}
}Playwrightのインストール
bundle install
yarn install
npx playwright install chromium --with-depsPlaywright + Capybaraの設定
spec/support/capybara.rb:
require 'capybara/rspec'
require 'capybara/playwright'
Capybara.register_driver(:playwright) do |app|
Capybara::Playwright::Driver.new(
app,
browser_type: :chromium,
headless: ENV['HEADLESS'] != 'false'
)
end
Capybara.default_driver = :playwright
Capybara.javascript_driver = :playwright
Capybara.configure do |config|
config.default_max_wait_time = 10
config.server = :puma, { Silent: true }
endspec/rails_helper.rb:
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
abort("The Rails environment is running in production mode!") if Rails.env.production?
require 'rspec/rails'
require 'capybara/rspec'
# ヘルパーファイル読み込み
Dir[Rails.root.join('spec/support/**/*.rb')].sort.each { |f| require f }
RSpec.configure do |config|
config.use_transactional_fixtures = false
config.before(:suite) do
DatabaseCleaner.strategy = :truncation
DatabaseCleaner.clean_with(:truncation)
end
config.around(:each) do |example|
DatabaseCleaner.cleaning do
example.run
end
end
config.include FactoryBot::Syntax::Methods
endテストファイルの構造
spec/
├── features/
│ └── admin/
│ ├── authentication_spec.rb # 認証テスト
│ ├── dashboard_spec.rb # ダッシュボード
│ ├── users_spec.rb # ユーザー管理
│ ├── projects_spec.rb # プロジェクト管理
│ └── reports_spec.rb # 日報管理
├── support/
│ ├── capybara.rb # Capybara設定
│ ├── admin_helpers.rb # 管理画面ヘルパー
│ └── playwright_helpers.rb # Playwrightヘルパー
└── factories/
├── users.rb
├── projects.rb
└── reports.rb基本的なテストパターン
認証テスト
spec/features/admin/authentication_spec.rb:
require 'rails_helper'
RSpec.describe '管理画面 - 認証', type: :feature do
let!(:admin) { create(:user, :admin) }
let!(:normal_user) { create(:user) }
describe '未認証ユーザー' do
it 'ログイン画面にリダイレクトされる' do
visit admin_root_path
expect(page).to have_current_path(admin_login_path)
end
end
describe '一般ユーザー' do
before do
sign_in_to_admin(normal_user)
end
it '権限エラーが表示される' do
expect(page).to have_content('管理者権限が必要です')
end
end
describe '管理者ユーザー' do
before do
sign_in_to_admin(admin)
end
it 'ダッシュボードが表示される' do
expect(page).to have_content('ダッシュボード')
expect(page).to have_selector('[data-testid="admin-sidebar"]')
end
end
describe 'ログアウト' do
before do
sign_in_to_admin(admin)
end
it 'ログアウトできる' do
click_button 'ログアウト'
expect(page).to have_current_path(admin_login_path)
end
end
endCRUD テスト
spec/features/admin/users_spec.rb:
require 'rails_helper'
RSpec.describe '管理画面 - ユーザー管理', type: :feature do
let!(:admin) { create(:user, :admin) }
let!(:users) { create_list(:user, 5) }
before do
sign_in_to_admin(admin)
end
describe '一覧画面' do
before { visit admin_users_path }
it 'ユーザー一覧が表示される' do
expect(page).to have_selector('[data-testid="users-table"]')
users.each do |user|
expect(page).to have_content(user.name)
end
end
it '新規作成ボタンがある' do
expect(page).to have_selector('[data-testid="new-user-button"]')
end
it '検索ができる' do
target_user = users.first
fill_in 'q', with: target_user.name
click_button '検索'
expect(page).to have_content(target_user.name)
users[1..].each do |user|
expect(page).not_to have_content(user.name)
end
end
end
describe '詳細画面' do
let(:user) { users.first }
before { visit admin_user_path(user) }
it 'ユーザー情報が表示される' do
expect(page).to have_selector('[data-testid="user-detail"]')
expect(page).to have_content(user.name)
expect(page).to have_content(user.email)
end
it '編集ボタンがある' do
expect(page).to have_selector('[data-testid="edit-button"]')
end
it '削除ボタンがある' do
expect(page).to have_selector('[data-testid="delete-button"]')
end
end
describe '新規作成' do
before { visit new_admin_user_path }
it 'フォームが表示される' do
expect(page).to have_selector('[data-testid="user-form"]')
end
context '正常な入力の場合' do
it 'ユーザーが作成される' do
fill_in 'user[name]', with: '新規ユーザー'
fill_in 'user[email]', with: 'new@example.com'
fill_in 'user[password]', with: 'password123'
click_button '作成'
expect(page).to have_content('ユーザーを作成しました')
expect(page).to have_content('新規ユーザー')
end
end
context 'バリデーションエラーの場合' do
it 'エラーメッセージが表示される' do
click_button '作成'
expect(page).to have_selector('[data-testid="error-messages"]')
end
end
end
describe '編集' do
let(:user) { users.first }
before { visit edit_admin_user_path(user) }
it '既存データがフォームに表示される' do
expect(page).to have_field('user[name]', with: user.name)
expect(page).to have_field('user[email]', with: user.email)
end
it '更新できる' do
fill_in 'user[name]', with: '更新後の名前'
click_button '更新'
expect(page).to have_content('ユーザーを更新しました')
expect(page).to have_content('更新後の名前')
end
end
describe '削除' do
let(:user) { users.first }
before { visit admin_user_path(user) }
it '削除できる' do
accept_confirm do
click_button '削除'
end
expect(page).to have_content('ユーザーを削除しました')
expect(page).to have_current_path(admin_users_path)
expect(page).not_to have_content(user.name)
end
end
endTurbo対応のテスト
確認ダイアログのテスト
# spec/support/playwright_helpers.rb
module PlaywrightHelpers
# Turbo confirmダイアログを自動承認
def accept_confirm(&block)
page.driver.with_playwright_page do |playwright_page|
playwright_page.once('dialog', ->(dialog) { dialog.accept })
end
yield if block_given?
end
# Turbo confirmダイアログをキャンセル
def dismiss_confirm(&block)
page.driver.with_playwright_page do |playwright_page|
playwright_page.once('dialog', ->(dialog) { dialog.dismiss })
end
yield if block_given?
end
end
RSpec.configure do |config|
config.include PlaywrightHelpers, type: :feature
endbutton_toのテスト
describe '削除' do
it '削除ボタンをクリックすると確認ダイアログが表示される' do
visit admin_user_path(user)
# button_toなのでclick_buttonを使用
accept_confirm do
click_button '削除'
end
expect(page).to have_content('削除しました')
end
endレスポンシブテスト
describe 'レスポンシブ対応', type: :feature do
before { sign_in_to_admin(admin) }
context 'モバイル表示' do
before do
page.driver.with_playwright_page do |playwright_page|
playwright_page.viewport_size = { width: 375, height: 667 }
end
visit admin_users_path
end
it 'モバイルメニューボタンが表示される' do
expect(page).to have_selector('[data-testid="mobile-menu-button"]')
end
it 'サイドバーが隠れている' do
expect(page).not_to have_selector('[data-testid="admin-sidebar"]:visible')
end
end
context 'タブレット表示' do
before do
page.driver.with_playwright_page do |playwright_page|
playwright_page.viewport_size = { width: 768, height: 1024 }
end
visit admin_users_path
end
it 'コンテンツが正しく表示される' do
expect(page).to have_selector('[data-testid="users-table"]')
end
end
context 'デスクトップ表示' do
before do
page.driver.with_playwright_page do |playwright_page|
playwright_page.viewport_size = { width: 1280, height: 800 }
end
visit admin_users_path
end
it 'サイドバーが表示される' do
expect(page).to have_selector('[data-testid="admin-sidebar"]:visible')
end
end
endCI/CD設定
GitHub Actions
.github/workflows/e2e-test.yml:
name: E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
e2e-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: |
bundle install
yarn install
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Setup database
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
run: |
bin/rails db:create
bin/rails db:schema:load
- name: Run E2E tests
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
HEADLESS: true
run: bundle exec rspec spec/features --format documentation
- name: Upload screenshots on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: screenshots
path: tmp/capybara/Makefile
.PHONY: e2e-test e2e-test-headed
e2e-test:
@echo "Installing Playwright browsers..."
npx playwright install chromium --with-deps
@echo "Running E2E tests..."
HEADLESS=true bundle exec rspec spec/features
e2e-test-headed:
@echo "Running E2E tests (headed mode)..."
HEADLESS=false bundle exec rspec spec/features次のステップ
トラブルシューティングに進みます → @steps/08_troubleshooting.md
ステップ8: トラブルシューティング
目次
---
概要
Hotwire管理画面とE2Eテスト実装でよく発生する問題と解決策を記載します。
Turbo関連の問題
問題1: 削除リンクが機能しない
症状: link_toでmethod: :deleteを指定しても、GETリクエストになる
原因: Rails 8 + Turbo環境ではlink_toのmethod:オプションが機能しない
解決策:
<%# NG %>
<%= link_to '削除', path, method: :delete %>
<%# OK: button_to を使用 %>
<%= button_to '削除', path, method: :delete %>
<%# OK: data-turbo-method を使用 %>
<%= link_to '削除', path, data: { turbo_method: :delete } %>問題2: 確認ダイアログが表示されない
症状: data: { confirm: '...' }が機能しない
原因: Turboではdata-turbo-confirmを使用する必要がある
解決策:
<%# NG %>
<%= button_to '削除', path, data: { confirm: '削除しますか?' } %>
<%# OK: form オプションで指定 %>
<%= button_to '削除', path, form: { data: { turbo_confirm: '削除しますか?' } } %>問題3: Turbo Streamが機能しない
症状: respond_toのturbo_streamフォーマットが呼ばれない
原因: Turboがロードされていない、またはレイアウトの設定問題
解決策:
<%# app/views/layouts/admin.html.erb %>
<head>
<%= javascript_importmap_tags %> <%# Turboがインポートされることを確認 %>
</head>// app/javascript/application.js
import "@hotwired/turbo-rails"問題4: フラッシュメッセージが表示されない(Turbo Stream時)
症状: HTMLリクエストではフラッシュが表示されるが、Turbo Streamでは表示されない
原因: Turbo Streamではflashではなくflash.nowを使用する必要がある
解決策:
def destroy
@user.destroy
respond_to do |format|
format.html { redirect_to admin_users_path, notice: '削除しました' }
format.turbo_stream {
flash.now[:notice] = '削除しました' # flash.now を使用
}
end
endフォーム関連の問題
問題5: enumのセレクトボックスが英語表示
症状: enumの値が日本語ではなく英語キーで表示される
解決策:
<%# NG %>
<%= f.select :division, User.divisions.keys %>
<%# OK: I18n翻訳を使用 %>
<%= f.select :division, User.divisions.keys.map { |k| [I18n.t("enums.user.division.#{k}"), k] } %># config/locales/ja.yml
ja:
enums:
user:
division:
engineer: エンジニア
designer: デザイナー
manager: マネージャー問題6: チェックボックスが重複表示される
症状: 同じ権限のチェックボックスが複数表示される
原因: レコードの重複がある
解決策:
<%# グループ化して重複を排除 %>
<% UserRole.select("MIN(id) as id, role").group(:role).each do |role| %>
<label>
<%= check_box_tag 'user[role_ids][]', role.id, user.roles.pluck(:role).include?(role.role) %>
<%= I18n.t("enums.user_role.role.#{role.role}") %>
</label>
<% end %>問題7: ネストされたパラメータが送信されない
症状: user[role_ids][]などの配列パラメータが空になる
解決策:
# Strong Parametersで配列を許可
def user_params
params.require(:user).permit(:name, :email, role_ids: [])
endE2Eテスト関連の問題
問題8: click_linkが失敗する(button_toの場合)
症状: click_link '削除'が要素を見つけられない
原因: button_toはボタンを生成するため、click_linkではなくclick_buttonを使用する必要がある
解決策:
# NG
click_link '削除'
# OK
click_button '削除'問題9: Turbo confirmダイアログのテストが失敗
症状: 確認ダイアログが処理されない
解決策:
# spec/support/playwright_helpers.rb
module PlaywrightHelpers
def accept_confirm(&block)
page.driver.with_playwright_page do |playwright_page|
playwright_page.once('dialog', ->(dialog) { dialog.accept })
end
yield if block_given?
end
end
# テストでの使用
accept_confirm do
click_button '削除'
end問題10: Stale Element Reference エラー
症状: 要素がDOMから削除された後にアクセスしようとしてエラーが発生
解決策:
# 要素が削除されるまで待機
expect(page).not_to have_selector("[data-testid='user-row-#{user.id}']")
# または再取得
page.refresh
expect(page).not_to have_content(user.name)問題11: レスポンシブテストでサイドバーが見えない
症状: ビューポートサイズを変更してもレイアウトが変わらない
解決策:
# ビューポートサイズ変更後にページをリロード
page.driver.with_playwright_page do |playwright_page|
playwright_page.viewport_size = { width: 375, height: 667 }
end
visit current_path # ページをリロード問題12: CI環境でテストが失敗する
症状: ローカルでは成功するがCI環境で失敗
原因: Playwrightブラウザがインストールされていない
解決策:
# .github/workflows/e2e-test.yml
- name: Install Playwright browsers
run: npx playwright install chromium --with-depsパフォーマンス問題
問題13: 一覧画面が遅い(N+1問題)
症状: レコード数が増えると一覧画面の表示が遅くなる
解決策:
# コントローラでeager loadingを使用
def index
@users = User.includes(:roles, :projects).order(created_at: :desc).page(params[:page])
end問題14: 検索が遅い
解決策:
# インデックスを追加
add_index :users, :name
add_index :users, :email
# または全文検索を使用
add_index :users, "to_tsvector('japanese', name || ' ' || email)", using: :gin認証関連の問題
問題15: Deviseとの統合でセッションが保持されない
症状: ログイン後にセッションが失われる
解決策:
# spec/rails_helper.rb
RSpec.configure do |config|
config.include Warden::Test::Helpers
config.include Devise::Test::IntegrationHelpers, type: :feature
config.after(:each) do
Warden.test_reset!
end
end問題16: 自作認証でパスワードが正しく検証されない
症状: 正しいパスワードでログインできない
原因: パスワードのハッシュ化にIDが必要な場合、作成時に問題が発生
解決策:
# app/models/user.rb
before_create :set_temporary_password_hash
after_create :reencrypt_password_with_correct_salt
private
def set_temporary_password_hash
return unless password.present?
@password_for_reencryption = password
self.encrypted_password = 'temporary'
end
def reencrypt_password_with_correct_salt
return unless @password_for_reencryption.present?
update_column(:encrypted_password, encrypt_password(@password_for_reencryption))
endデバッグのヒント
Playwrightでスクリーンショットを撮る
page.driver.with_playwright_page do |playwright_page|
playwright_page.screenshot(path: "tmp/debug_#{Time.current.to_i}.png")
endヘッドレスモードを無効化してブラウザを表示
HEADLESS=false bundle exec rspec spec/features/admin/users_spec.rbTurbo StreamのHTMLを確認
# コントローラでデバッグ
def destroy
@user.destroy
respond_to do |format|
format.turbo_stream do
Rails.logger.debug render_to_string
end
end
end