複雑なステート(ショップの例)
ここでは価格変更の計算やステートの保持をすべてサーバに持たせる例を紹介します。
デモはこちらに用意しています。
class IphonesController < ApplicationController layout "iphone" before_action :set_iphone def show end # ... private def set_iphone session[:iphone] ||= {} @iphone = Iphone.new(session[:iphone]) end end
IphonesController#showをエンドポイントとします@iphoneインスタンスはIphoneオブジェクトのインスタンスです。DBを使わずに、ステートはすべてsessionで管理ます。そのためIphoneインスタンスはsessionを使って初期化しますclass Iphone Price = Data.define(:lump, :monthly) DEFAULT_MODEL = "6-1inch" DEFAULT_COLOR = "naturaltitanium" DEFAULT_RAM = "256GB" def initialize(iphone_session) @iphone_session = iphone_session end def state case when @iphone_session["ram"] then :ram_entered when @iphone_session["color"] then :color_entered when @iphone_session["model"] then :model_entered else :nothing_entered end end def model_enterable? true end def color_enterable? state.in? [ :model_entered, :color_entered, :ram_entered ] end def ram_enterable? state.in? [ :color_entered, :ram_entered ] end def model=(string) return unless model_enterable? @iphone_session["model"] = string end def model @iphone_session["model"] end def color=(string) return unless color_enterable? @iphone_session["color"] = string end def color @iphone_session["color"] end def ram=(string) return unless ram_enterable? @iphone_session["ram"] = string end def ram @iphone_session["ram"] end def color_name color_name_for_value(color) end def color_name_for_value(value) color_table = { "naturaltitanium" => "Color – Natural Titanium", "bluetitanium" => "Color – Blue Titanium", "whitetitanium" => "Color – White Titanium", "blacktitanium" => "Color – Black Titanium" } color_table[value || DEFAULT_COLOR] end def image_path "iphone_images/iphone-15-pro-finish-select-202309-#{model || DEFAULT_MODEL}-#{color || DEFAULT_COLOR}.webp" end def pricing Iphone.pricing_for(model, ram) end def self.pricing_for(model, ram) Price.new(lump: 0, monthly: 0).then do |price| case model || DEFAULT_MODEL when "6-1inch" then Price.new(lump: price.lump + 999, monthly: price.monthly + 41.62) when "6-7inch" then Price.new(lump: price.lump + 1199, monthly: price.monthly + 49.95) else raise "bad model: #{model}" end end.then do |price| case ram || DEFAULT_RAM when "256GB" then price when "512GB" then Price.new(lump: price.lump + 200, monthly: price.monthly + 8.34) when "1TB" then Price.new(lump: price.lump + 400, monthly: price.monthly + 26.77) end end end def to_hash { model:, color:, color_name: } end end
Iphoneクラスに全てのビジネスロジックを収めています
本当のストアであれば製品オプションの情報や価格算出はDB等で管理すると思いますが、今回はとりあえずハードコードしました。
<%= form_with url: iphone_path, method: :post do %> <%= fieldset_tag nil, disabled: !@iphone.model_enterable?, class: "disabled:opacity-30" do %> <% [{ model: "6-1inch", title: "iPhone 15 Pro", subtitle: "6.1-inch display" }, { model: "6-7inch", title: "iPhone 15 Pro Max", subtitle: "6.7-inch display" }].each do |attributes| %> <%= render 'option', name: :model, value: attributes[:model], selected: @iphone.model == attributes[:model], title: attributes[:title], subtitle: attributes[:subtitle], pricing_lines: item_pricing(attributes[:model], @iphone.ram) %> <% end %> <% end %> <% end %>
<%= label_tag [name, value].join('_'), class: "mt-4 flex justify-between items-center p-4 block border-2 rounded-lg w-full cursor-pointer has-[:checked]:border-blue-500" do %> <%= radio_button_tag name, value, selected, class: "hidden", onchange: "this.form.requestSubmit()" %> <div> <div class="text-lg"><%= title %></div> <% if subtitle %> <div class="text-sm text-gray-500"><%= subtitle %></div> <% end %> </div> <div> <% pricing_lines.each do |line| %> <div class="text-xs text-gray-500 text-right"><%= line %></div> <% end %> </div> <% end %>
app/views/iphones/_option.html.erbの中でradio_button_tagとして実装しています。radioを使いますので、楽観的UIはブラウザネイティブのものが使えますradio_buttonが変更されたらonchangeでformをsubmitしますhas-[:checked]:border-blue-500で処理されます。これは楽観的UIですapp/views/iphones/_iphone.html.erbに記されている普通のform_withで実装しています。Turboがインストールされていますので、submitされると非同期でサーバにリクエストを送信しますclass IphonesController < ApplicationController layout "iphone" before_action :set_iphone # ... def create @iphone.model = params[:model] if params[:model] @iphone.color = params[:color] if params[:color] @iphone.ram = params[:ram] if params[:ram] respond_to do |format| format.turbo_stream end end # ... end
IphonesController#submitに来ます@iphone (Iphoneクラスのインスタンス)にparamsが渡され、ブラウザで選択されたオプションがsessionに反映されますturbo_streamsで応答しています。これは規約に従ってcreate.turbo_stream.erbをテンプレートとして使用します<%= turbo_stream.replace "iphone", method: "morph" do %> <%= render "iphone", iphone: @iphone %> <% end %>
iphoneの場所に、partialのiphoneを入れ替えています。つまり更新された内容で描き直していますmethod: "morph"をしていますので、単純にDOMを新しいものと入れ替えるのではなく、変更された箇所だけを入れ替えます。ブラウザのステートをなるべくそのままにしますので、よりスムーズなUI/UXになります<%= tag.div data: { controller: "image-switcher", image_switcher_iphone_value: @iphone } do %> <div class="text-xl my-4" data-image-switcher-target="colorText"><%= @iphone.color_name %></div> <%= form_with url: iphone_path, method: :post do %> <%= fieldset_tag nil, disabled: !@iphone.color_enterable?, class: "disabled:opacity-30" do %> <% [{ color: "naturaltitanium", class: "bg-gray-400" }, { color: "bluetitanium", class: "bg-indigo-800" }, { color: "whitetitanium", class: "bg-white" }, { color: "blacktitanium", class: "bg-black" }].each do |attributes| %> <%= render 'color_option', value: attributes[:color], color: attributes[:class], iphone: @iphone %> <% end %> <% end %> <% end %> <% end %>
<% local_assigns => {value:, color:, iphone:} %> <%= label_tag [:color, value].join('_'), data: { action: "mouseenter->image-switcher#setColorText mouseleave->image-switcher#resetColorText", image_switcher_color_name_param: iphone.color_name_for_value(value) }, class: "#{color} inline-block w-8 h-8 border-2 rounded-full cursor-pointer outline-2 outline outline-offset-0.5 outline-transparent has-[:checked]:outline-blue-500" do %> <%= radio_button_tag :color, value, iphone.color == value, class: "hidden", onchange: "this.form.requestSubmit()" %> <% end %>
_color_option.html.erb partialで書いていますimage_switcher Stimulus Controllerが担当します
data-image-switcher-target="colorText"の箇所ですaction: "mouseenter->image-switcher#setColorText mouseleave->image-switcher#resetColorText"で、mouseenter, mouseleaveイベントに応じて呼び出されますimage_switcher_color_name_param:で指定していますimport { Controller } from "@hotwired/stimulus" // Connects to data-controller="image-switcher" export default class extends Controller { static values = {iphone: Object} static targets = ["colorText"] connect() { } setColorText(event) { const colorName = event.params.colorName this.colorTextTargets.forEach(target => target.textContent = colorName) } resetColorText(event) { this.colorTextTargets.forEach(target => target.textContent = this.iphoneValue.color_name) } }
setColorText, resetColorTextが呼び出され、Targetの内容を直接更新するものです
params.colorNameから読み込んでいますformを送信するだけです。ラジオボタンを押した時にformを自動的に送信するインラインJavaScriptを書いているのみで、ほとんど何もしていませんradioで実装していますので、コードを書かなくても楽観的UIが実現できます。CSS擬似要素の:checkedて適宜UIを更新しますIphoneオブジェクトを作り、更新しているだけです。Railsのごく一般的なControllerですIphoneクラスに集約されていますapp/views/iphones/create.turbo_stream.erbを省略できます。ただしその場合は POST/Redirect/GETのパターンになりますので、オプション選択時にサーバ通信が2回発生します。今回のようにTurbo Streams + Morphingであれば1回で済みます上述のように、製品オプションを選択するたびにサーバ通信をするやり方であっても、UI/UX上は特に問題になりません。楽観的UIも実装できますし、