Open9

hotrails.dev

ohranohran

Chapter 2
Organizing CSS files in Ruby on Railsでcssを追加して、適用するためにサーバー再起動した際に

Expected to find a manifest file in app/assets/config/manifest.js

というエラーが発生した。
Sprocketsを使用してアセットパイプラインを管理しようとしていて発生した問題らしいが、Rails 7 では、デフォルトでSprocketsの代わりにPropshaftがアセットパイプラインとして使用されるらしい。
Gemfileを見るとたしかにgem "sprockets-rails"があったので、こちらを消してgem 'propshaft'としてbundle installし直すことで解決した。

sprockets-railsを追加した覚えはないので、不可解だが、これで解決した

ohranohran

rails7ではデフォルトでTurbo Driveは有効になっている。
これによりリンクとかフォームの挙動が全てインターセプトされてajax通信に書き換えられる。
またajaxのレスポンスを受けて<body>タグを更新するが、<head>タグは更新されない。

これによりリクエストの度にフォント、CSS、JSのファイルのダウンロードされることはなくなるので速くなるという理屈らしい。
そこらへんのDLってそんなに時間かかるんだという感想を持った。
確かにファイルのDLが無いに越したことはないとは思うが。

このajaxに変更する動きの影響により、フォーム送信でエラーが発生した際は、 422 status code :unprocessable_entityを返さないといけないらしい。
scaffoldでコントローラー作った際は、createとupdateに自動でstatus: :unprocessable_entityを付与してくれるらしい。

Turbo Driveを一部で動かしたくない時はdata-turbo="false"で簡単に停止できる。

<main class="container">
  <div class="header">
    <h1>Quotes</h1>
    <%= link_to "New quote",
                new_quote_path,
                class: "btn btn--primary",
                data: { turbo: false } %>
  </div>

  <%= render @quotes %>
</main>

アプリ全体でTurbo Driveを停止したい場合はapp/javascript/application.jsに以下を追記する

import { Turbo } from "@hotwired/turbo-rails"
Turbo.session.drive = false

<head>タグが更新されないと、CSSファイルが更新されても反映されない可能性がある。
Turbo Driveはリクエストごとに<head>タグのDOMを比較して、変更があればpage全体を更新する処理をしてくれる。
これは"data-turbo-track": "reload"の設定で行われるし、デフォルトでは<%# app/views/layouts/application.html.erb %>に以下の設定があるのでアプリ全体に適用される

<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
ohranohran

Chapter 4 Turbo Frames and Turbo Stream templates

Turbo Frames are independent pieces of a web page that can be appended, prepended, replaced, or removed without a complete page refresh and writing a single line of JavaScript!

「Turbo Frames は、ページ全体をリフレッシュすることなく、JavaScript のコードを一行も書かずに、ウェブページの一部を追加、前置、置換、または削除できる独立した要素です。」

<turbo-frame id="first_turbo_frame">で囲むとその中のフォーム送信やリンクのクリックをインターセプトして、frame内を独立したwebページとして扱えるようになる

Rule1: turbo frame内のリンクをクリックすると、turboは対象ページに同じIDのframeがあることを期待する。
なのでindexのturbo frame内でnewのリンクをクリックしたら、newのフォームにもindexと同じIDと割り振る必要がある。

index.html.erb

<%= turbo_frame_tag "first_turbo_frame" do %>
    <div class ="header">
      <h1>Quotes</h1>
      <%= link_to "New quote", new_quote_path, class: "btn btn-primary" %>
    </div>
  <% end %>

new.html.erb

<%= turbo_frame_tag "first_turbo_frame" do %>
    <%= render "form", quote: @quote %>
<% end %>

Rule2: turbo frame内のリンクをクリックした時、同じturbo frame IDを持つページがない場合は、frameが消えてエラーレスポンスがコーンソールログに表示される。

RUle3: data-turbo-frame attriburteを使用することで。リンクは1つ以上のframeに紐づけられることができる

_topという特別なframeを使用するとページ全体を表すことができる。つまりページ全体を置換することができる。
全てのページはデフォルトでこの_topを持っている。
この_topを使うとページのURLも書き換わる

dom_idという便利なヘルパーメソッドがあるので

<%= turbo_frame_tag @quote do %>

と書くだけで、各@quoteのIDを埋め込んだturbo frameが生成される。
turbo_frame_tag内のリンクが機能しない際は"_top"を使うことでページ全体を置換する

<%= turbo_frame_tag quote do %>
<div class="quote">
  <%= link_to quote.name, quote_path(quote), data:{turbo_frame: "_top"} %>

同様にturbo_frame内のdeleteメソッドが機能しない際は、button_todata:{turbo_frame: "_top"}を追加することで、ページ全体が置き換わり、削除ができる

<%= turbo_frame_tag quote do %>
<div class="quote">
  <%= link_to quote.name, quote_path(quote), data:{turbo_frame: "_top"} %>
  <div class="quote__actions">
    <%= button_to "Delete", quote_path(quote), method: :delete, data:{turbo_frame: "_top"},class: "btn btn--light" %>
    <%= link_to "Edit", edit_quote_path(quote), class: "btn btn-light" %>
  </div>
</div>
<% end %>

現状だと、quote2をedit状態にした状態でquote3を削除すると、quote2のedit状態が解除される。
これをdelete対象のもの以外の状態を維持するために、turbo stream formatが使用できる。
respond_to内でformat.turbo_streamをすることで、TURBO_STREAM formatsを使用することができる。

def destroy
  @quote.destroy

  respond_to do |format|
    format.html { redirect_to quotes_path, notice: "Quote was successfully destroyed." }
    format.turbo_stream
  end
end

そうるすと対応するerbが呼ばれる。
<%# app/views/quotes/destroy.turbo_stream.erb %>

<%= turbo_stream.remove @quote %>

そのなかで指定されたturbo_streamのアクション付きのHTMLが返却される。

<turbo-stream action="remove" target="quote_908005780">
</turbo-stream>

ブラウザがこのHTMLを受取り、指定されたアクションを実行する。
turbo streamには他にも以下のアクションがある

# Remove a Turbo Frame
turbo_stream.remove

# Insert a Turbo Frame at the beginning/end of a list
turbo_stream.append
turbo_stream.prepend

# Insert a Turbo Frame before/after another Turbo Frame
turbo_stream.before
turbo_stream.after

# Replace or update the content of a Turbo Frame
turbo_stream.update

index内で新規作成フォームを作る方法。

  1. indexファイル内に空のフォームを作成しておく
  2. indexファイル内に新規作成へのリンクを置く。そのリンクに新規作成のturbo_frameを設定する。
  3. newファイル内で新規作成のturbo_frameを設定する。

これをすることで新規作成された要素が、1.の空のフォームに配置される
<%# app/views/quotes/index.html.erb %>

<main class="container">
  <div class="header">
    <h1>Quotes</h1>
    <%= link_to "New quote",
                new_quote_path,
                class: "btn btn--primary",
                data: { turbo_frame: dom_id(Quote.new) } %> # このリンクが押された時にnew_quoteというturbo_frameを持つファイルが期待される
  </div>

  <%= turbo_frame_tag Quote.new %> # ここにnew_quoteというturbo_frameを持つファイルが期待される
  <%= render @quotes %>
</main>

<%# app/views/quotes/new.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; Back to quotes"), quotes_path %>

  <div class="header">
    <h1>New quote</h1>
  </div>

  <%= turbo_frame_tag @quote do %> # @quoteが新規のインスタンスなのでnew_quoteというturbo_frameが生成され、期待される箇所に割り当てられる
    <%= render "form", quote: @quote %>
  <% end %>
</main>

新規作成したquoteをindexの先頭に表示する方法

  1. controller内でturbo_steamで返すように設定する
  2. turbo_stream.erbの設定で、新規作成した要素をprependする設定と、新規作成フォームを初期化する設定をする
  3. indexファイル内で@quotesをturbo_frame_tagで囲む
def create
  @quote = Quote.new(quote_params)

  if @quote.save
    respond_to do |format|
      format.html { redirect_to quotes_path, notice: "Quote was successfully created." }
      format.turbo_stream
    end
  else
    render :new, status: :unprocessable_entity
  end
end

<%# app/views/quotes/create.turbo_stream.erb %>

<%= turbo_stream.prepend "quotes", partial: "quotes/quote", locals: { quote: @quote } %>
<%= turbo_stream.update Quote.new, "" %>

<%# app/views/quotes/index.html.erb %>

...
  <%= turbo_frame_tag "quotes" do %>
    <%= render @quotes %>
  <% end %>
ohranohran

chapter5 Real-time updates with Turbo Streams

Turbo StreamはAction Cableと組み合わせることで、リアルタイム通信を実現できる。(チャットなど)

Quotes#indexに表示された一覧にリアルタイム通信でquoteを追加削除する

class Quote < ApplicationRecord
  # All the previous code

  after_create_commit -> { broadcast_prepend_to 'quotes', partial: 'quotes/quote', locals:{quote: self}, target: 'quotes'}
end

Quoteモデルに上のcallbackを設定する。
after_create_commitはDBにレコードに新規登録されるたびに実行される。
broadcast_prepend_to "quotes", partial: "quotes/quote", locals: { quote: self }, target: "quotes"
の部分は、作成されたquoteのHTMLが、quotesをサブスクライブしているuserにブロードキャストされ、quotesのDOMリストにprepend(先頭に追加)する。

※DOMリストの最後に追加したい場合はbroadcast_append_toを使う

broadcastを受け取りたい箇所で以下の様にして通信を受け取るhelperを追加する

<%= turbo_stream_from "quotes" %>

これは以下のようなHTMLを作成する。

<turbo-cable-stream-source
  channel="Turbo::StreamsChannel"
  signed-stream-name="very-long-string"
>
</turbo-cable-stream-source>

これによりsigned-stream-nameの名前のchannelをサブスクライブする。
signed-stream-nameは、turbo_stream_fromで引数として渡したquptesを署名化して、悪意ある第3者のユーザーが改ざんできないようにし、また受け取るべきでないHTMLを受け取れないようにする。

この時redisが必要になるため、設定が必要
config/cable.yml

development:
  adapter: redis
  url: redis://localhost:6379/1

と追加が必要。

自分の場合はdockerで行っているため
compose.ymlを以下の様に更新した。

services:
  redis:
    image: redis:alpine
    restart: always
  db:
    image: postgres
    # dbのユーザー名とパスワードでこれが無いとdbが起動できなかった
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    # 無くても動くけど指定しておくとdocker-composeを停止してもdbの内容が永続化されるため、指定することが多いと思われる
    # https://matsuand.github.io/docs.docker.jp.onthefly/storage/volumes/
    volumes:
      - postgres_volume:/var/lib/postgresql/data
    # 無くても動くが指定しておくとコンテナ停止時にサービスが再起動してくれる
    # https://docs.docker.jp/v19.03/config/container/start-containers-automatically.html
    restart: always
  web:
    build: .
    # tmp/pids/server.pidが残ってたら `A server is already running. ~~` のエラーでrailsを起動できないので事前に消してから、`rails sever` する
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    # 上記のdbイメージで指定したユーザー名とパスワードをrails側でも指定するため環境変数に設定。
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      TZ: "Asia/Tokyo"
    # ホストのカレントディレクトリ(.)とイメージ内の/myappディレクトリを同期させている
    # volumes:
    #   - .:/myapp
    volumes:
    - type: bind
      source: ./
      target: /myapp
    - type: volume
      source: gemdata
      target: /usr/local/bundle
    - type: volume
      source: node_modules
      target: /myapp/node_modules
    ports:
      - "3000:3000"
    restart: always # コンテナが停止すると常に再起動
    tty: true # 疑似ターミナル (pseudo-TTY) を割り当て。https://docs.docker.jp/compose/compose-file/index.html#tty
    stdin_open: true # サービス コンテナに標準入力を割り当てて実行するよう設定(https://docs.docker.jp/compose/compose-file/index.html#stdin-open)。
    depends_on:
      - db
      - redis
volumes:
  postgres_volume:
  gemdata:
  node_modules:

またこのredisのconainerを使うため
config/cable.ymlは以下とした

development:
  adapter: redis
  url: redis://redis:6379

Error loading the 'redis' Action Cable pubsub adapter. Missing a gem it depends on? redis is not part of the bundle. Add it to your Gemfile.
のエラーが発生した。
gemfileの以下のコメントアウトを外し、bundleしてgemを追加した。

# Use Redis adapter to run Action Cable in production
gem "redis", "~> 4.0"

その後bin/rails turbo:installコマンドを実行した。
結果は以下で、

root@41742cb7a2a4:/myapp# bin/rails turbo:install
Import Turbo
   unchanged  app/javascript/application.js
Install Turbo
         run  yarn add @hotwired/turbo-rails from "."
Run turbo:install:redis to switch on Redis and use it in development for turbo streams
root@41742cb7a2a4:/myapp# 

Run turbo:install:redis to switch on Redis and use it in development for turbo streamsということは、こちらのコマンドを実行する必要があるのかと思ったが、この時点で問題なく動いたので関係なかった。
実はprodctionモードで動いてるのかと思ったが、"RAILS_ENV"=>"development",だったのでそんなこともなかった

railsコンソールでQuoteを新規作成した結果

=> #<Quote:0x0000ffffb6fc9228 id: 908005772, name: "broadcast test", created_at: Sat, 10 Aug 2024 04:50:55.028340000 UTC +00:00, updated_at: Sat, 10 Aug 2024 04:50:55.028340000 UTC +00:00>
irb(main):002> Quote.create!(name: "broadcast test2")
  TRANSACTION (0.4ms)  BEGIN
  Quote Create (0.7ms)  INSERT INTO "quotes" ("name", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id"  [["name", "broadcast test2"], ["created_at", "2024-08-10 04:51:04.393186"], ["updated_at", "2024-08-10 04:51:04.393186"]]
  TRANSACTION (1.3ms)  COMMIT
  Rendered quotes/_quote.html.erb (Duration: 0.6ms | Allocations: 285)
[ActionCable] Broadcasting to quotes: "<turbo-stream action=\"prepend\" target=\"quotes\"><template><turbo-frame id=\"quote_908005773\">\n  <div class=\"quote\">\n    <a data-turbo-frame=\"_top\" href=\"/quotes/908005773\">broadcast test2</a>\n    <div class=\"quote__actions\">\n      <form class=\"button_to\" method=\"post\" action=...
=> #<Quote:0x0000ffffb6501b88 id: 908005773, name: "broadcast test2", created_at: Sat, 10 Aug 2024 04:51:04.393186000 UTC +00:00, updated_at: Sat, 10 Aug 2024 04:51:04.393186000 UTC +00:00>
irb(main):003> ENV

insertと同時にBroadcastingされ、画面にも反映された

targetの部分はデフォルトではmodel_name.pluralとなるので省略できる。
before

after_create_commit -> { broadcast_prepend_to "quotes", partial: "quotes/quote", locals: { quote: self }, target: "quotes" }

after

after_create_commit -> { broadcast_prepend_to 'quotes', partial: 'quotes/quote', locals:{quote: self}}

partiallocalsについてもデフォルト値があり、省略できる。
partialのデフォルト値はto_partial_pathと一緒なので、Quoteモデルのデフォルト値は"quotes/quote"となる

localsのデフォルト値は{ model_name.element.to_sym => self }であり、Quoteモデルの場合、{ quote: self }となる
そのため最終的なリファクタ後の形式は

after_create_commit -> { broadcast_prepend_to "quotes" }

となる。

create,update,destroyそれぞれの時にリアルタイム通信するためのコードが以下となる

after_create_commit -> { broadcast_prepend_to 'quotes'}
after_update_commit -> { broadcast_replace_to 'quotes'}
after_destroy_commit -> { broadcast_remove_to 'quotes'}

またこのリアルタイム通信を非同期にするために以下の様に変更される。
ただし削除については非同期にできない

after_create_commit -> { broadcast_prepend_later_to "quotes" }
after_update_commit -> { broadcast_replace_later_to "quotes" }
after_destroy_commit -> { broadcast_remove_to "quotes" }

このような3つの設定を1つにまとめられるのが以下のコード

broadcasts_to -> (qupte){'quotes'}, inserts_by: :prepend
ohranohran

PCをスリープしてから再度開いたら謎にwidthが上昇し続ける現象に遭遇

ohranohran

chapter6 Turbo Streams and security

この章ではセキュリティについて学ぶ。
重要情報が、第3者にbradcastされると困るため

色々と設定して、
companyテーブルとuserテーブルを作成
quoteとuserはcompanyに所属する。
deviseによるログイン機能を追加
quoteは自分の会社のもののみ表示するよう改修する。

この状態で、2つのwindowでそれぞれ違う会社のユーザーでログインする。
会社1のユーザーでquoteを作成すると会社2のユーザーにも表示される。
これはturbo_stream_fromで作成されたturbo-cable-stream-sourcesigned-stream-nameが同じになっているため、送信を受け付けてしまうから。

app/models/quote.rb

  broadcasts_to ->(quote) { "quotes" }, inserts_by: :prepend

このコードは、quoteモデルの変更が常に、quotesというチャンネルにbroadcastされることを表す。

app/views/quotes/index.html.erb

<%= turbo_stream_from "quotes" %>

そしれこれはturbo_stream_fromquotesを指定してsubscribeしているため、ここで受信される。
これが原因であるため、会社ごとに違うチャンネル名を作成する必要がある。

app/models/quote.rb

  broadcasts_to ->(quote) { [quote.company,"quotes"] }, inserts_by: :prepend

このように変更することにより、lambdaに渡されたarrayからsigned stream nameが生成されるため、会社ごとに違うsigned stream nameとなる。
また、これを受信するために
app/views/quotes/index.html.erb

<%= turbo_stream_from current_company ,"quotes" %>

と変更する必要がある
これにより、確かに会社ごとに違うsigned stream nameが生成され、別の会社のquote情報を受け取ることがなくなった。

ohranohran

#7 Flash messages with Hotwire

turboでflashメッセージを追加する方法とStimulusでアニメーションを作る方法

まずturboがない状態でflashを動作させる必要がある
app/javascript/application.js

import { Turbo } from "@hotwired/turbo-rails"
Turbo.session.drive = false

とあるが、実際はこれでは効かなかった。
原因は謎。
サーバ再起動やdockerの再buildを試したが効果はなかった。

turbo driveを無効化するのは今度調べるとして、今回はturbo driveがある状態でflashを表示する方法をまとめる。
Turbo Streamがある状態では、turbo_streamの戻り値にflash情報を詰める必要がある

respond_to do |format|
  format.html { redirect_to quotes_path, notice: "Quote was successfully created." }
  format.turbo_stream { flash.now[:notice] = "Quote was successfully created." }
end

またturbo_streamのviewファイルにもflashの情報を追加する必要がある。
app/views/quotes/create.turbo_stream.erb

<%= turbo_stream.prepend "flash", partial: "layouts/flash" %>

複数のflashを表示したい場合は、turbo_stream.prepend
画面に1つのflashしか表示しない場合は、turbo_stream.replace

またこの場合、flashというIDを持つDOMがまだないので、データの行き先が無い。
そのため
app/views/layouts/application.html.erbに以下を追加する

<div id="flash" class="flash">
  <%= render "layouts/flash" %>
</div>

また同様の処理をupdate、destroyなどにも追加する必要があるが、処理を共通化できる
app/helpers/application_helper.rb

def render_turbo_stream_flash_messages
  turbo_stream.prepend "flash", partial: "layouts/flash"
end

app/views/quotes/create.turbo_stream.erbapp/views/quotes/update.turbo_stream.erbapp/views/quotes/destroy.turbo_stream.erb

<%= render_turbo_stream_flash_messages %>
ohranohran

#8 Two ways to handle empty states with Hotwire

この章では、Turboで空の状態をどのように扱うかを学ぶ。
1つ目は Turbo FramesとTurbo Streamsを使う方法
2つ目はonly-child CSS pseudo-classだけを使う方法

quoteが空の状態の時に、「メッセージがまだありません」というようなガイダンスが表示されたほうが親切
それを実現するために、まずqupteがない状態の時に表示するファイルを作成
app/views/quotes/_empty_state.html.erb

<div class="empty-state">
  <p class="empty-state__text">
    You don't have any quotes yet!
  </p>

  <%= link_to "Add quote", new_quote_path, class: "btn btn--primary" %>
</div>

app/views/quotes/index.html.erbを改造して、quoteが無い場合にこのファイルをrenderするよう設定する。`

<%= turbo_stream_from current_company, "quotes" %>

<div class="container">
  <div class="header">
    <h1>Quotes</h1>
    <%= link_to "New quote",
                new_quote_path,
                class: "btn btn--primary",
                data: { turbo_frame: dom_id(Quote.new) } %>
  </div>

  <%= turbo_frame_tag Quote.new do %>
    <% if @quotes.none? %>
      <%= render "quotes/empty_state" %>
    <% end %>
  <% end %>

  <%= turbo_frame_tag "quotes" do %>
    <%= render @quotes %>
  <% end %>
</div>

この状態だと、全quoteを削除した時には表示されるが、一度quoteを新規作成し、削除した後では再度表示されない。
destroy.turbo_stream.erb内で、new_quoteのIDを持つTurbo Frameのコンテントをquotes/empty_stateで更新しなければいけない。
app/views/quotes/destroy.turbo_stream.erb

<%= turbo_stream.remove @quote %>
<%= render_turbo_stream_flash_messages %>

<% unless current_company.quotes.exists? %>
  <%= turbo_stream.update Quote.new do %>
    <%= render "quotes/empty_state" %>
  <% end %>
<% end %>

こうすることで、再度quoteがなくなった際に、_empty_state.html.erbの内容が表示される。
ただし、_empty_state.html.erbが表示された状態で、別タブなどから新規quoteが作成されると、quoteがあるのに、_empty_state.html.erbが表示されたままとなる。

この解決は次章で扱う。

またこれまでのことをonly-child CSS pseudo-classだけで達成可能。

まずapp/views/quotes/index.html.erb内で、empty_stateファイルを、quotesリスト内でrenderするように変更

<%= turbo_stream_from current_company, "quotes" %>

<div class="container">
  <div class="header">
    <h1>Quotes</h1>
    <%= link_to "New quote",
                new_quote_path,
                class: "btn btn--primary",
                data: { turbo_frame: dom_id(Quote.new) } %>
  </div>

  <%= turbo_frame_tag Quote.new %>

  <%= turbo_frame_tag "quotes" do %>
    <%= render "quotes/empty_state" %>
    <%= render @quotes %>
  <% end %>
</div>

app/assets/stylesheets/components/_empty_state.scss:only-child pseudo-classを使い
quotesというidを持つ唯一のTurbo Frameの場合にのみ表示するようにする。
app/assets/stylesheets/components/_empty_state.scss

.empty-state {
  padding: var(--space-m);
  border: var(--border);
  border-style: dashed;
  text-align: center;

  &__text {
    font-size: var(--font-size-l);
    color: var(--color-text-header);
    margin-bottom: var(--space-l);
    font-weight: bold;
  }

  &--only-child {
    display: none;

    &:only-child {
      display: revert;
    }
  }
}

またapp/views/quotes/_empty_state.html.erbで、new_quoteというIDを明確に指定するよう変更する必要がある。(?)

<div class="empty-state empty-state--only-child">
  <p class="empty-state__text">
    You don't have any quotes yet!
  </p>

  <%= link_to "Add quote",
              new_quote_path,
              class: "btn btn--primary",
              data: { turbo_frame: dom_id(Quote.new) } %>
</div>

ohranohran

#9 Another CRUD controller with Turbo Rails

quotesのdateを扱うCRUD Controllerの作成を通して、これまでの復習を行う

app/views/quotes/show.html.erb内にline_item_dateの新規作成フォームが表示されるようにする

<main class="container">
  <%= link_to sanitize("&larr; Back to quotes"), quotes_path %>

  <div class="header">
    <h1>
      <%= @quote.name %>
    </h1>

    <%= link_to "New date",
                new_quote_line_item_date_path(@quote),
                data: { turbo_frame: dom_id(LineItemDate.new) },
                class: "btn btn--primary" %>
  </div>

  <%= turbo_frame_tag LineItemDate.new %>
  <%= render @line_item_dates, quote: @quote %>
</main>

data: { turbo_frame: dom_id(LineItemDate.new) },<%= turbo_frame_tag LineItemDate.new %>を追加
これによりQuotes#show pageページにnew_line_item_dateというIDを持つturbo_fram_tagを保持することになる

app/views/line_item_dates/new.html.erb

<main class="container">
  <%= link_to sanitize("&larr; Back to quote"), quote_path(@quote) %>

  <div class="header">
    <h1>New date</h1>
  </div>

  <%= turbo_frame_tag @line_item_date do %>
    <%= render "form", quote: @quote, line_item_date: @line_item_date %>
  <% end %>
</main>

<%= turbo_frame_tag @line_item_date do %>の箇所を追加することで、new_line_item_dateというIDを持つturbo_fram_tagの内容を宣言する? この状態で、不正な情報でline_item_dateを作成するとエラー文言が画面に表示される。 これはLineItemDates controllerが不正入力によるLineItemDates#newを生成するから これによりnew_line_item_date`のIDを持つTurbo Frame内にあるformが同じIDを持つ、エラー文言付きのviewで置換されている。

しかし、有効なフォームを送信すると、
フォームが new_line_item_date という ID を持つ Turbo フレーム内にあり、レスポンスが、空のフレームを持つ Quotes#show ページにリダイレクトされ、フォームが消えてる。
これは、フォームを含むTurboフレームが空のフレームに置き換えられるため。
これを回避するために、create.turbo_stream.erb テンプレートが必要になる。

create.turbo_stream.erb内で以下をやる必要がある。
new_line_item_dateのIDのを持つturbo frameを空のformと置換する
正しい順番でになるようにline item dateリストに追加する

まずLineItemDatesController#createをturbo_streamフォーマットに変更
app/controllers/line_item_dates_controller.rb

  def create
    @line_item_date = @quote.line_item_dates.build(line_item_date_params)

    if @line_item_date.save
      respond_to do |format|
        format.html { redirect_to quote_path(@quote), notice: "Date was successfully created." }
        format.turbo_stream { flash.now[:notice] = "Date was successfully created." }
      end
    else
      render :new, status: :unprocessable_entity
    end
  end

正しい順番でになるようにline item dateリストに追加するために、
app/views/line_item_dates/create.turbo_stream.erbを以下とする

<%# Step 1: remove the form from the Quotes#index page %>
<%= turbo_stream.update LineItemDate.new, "" %>

<%# Step 2: add the date at the right place %>
<% if previous_date = @quote.line_item_dates.ordered.where("date < ?", @line_item_date.date).last %>
  <%= turbo_stream.after previous_date do %>
    <%= render @line_item_date, quote: @quote %>
  <% end %>
<% else %>
  <%= turbo_stream.prepend "line_item_dates" do %>
    <%= render @line_item_date, quote: @quote %>
  <% end %>
<% end %>

<%= render_turbo_stream_flash_messages %>

Step 1ではformを持つturbo frameを空のformにする
Step 2では、新規作成したline_item_dateより古い日付のものがあれば取得して、新規作成したものを追加する。
もし古い日付のものがなければline_item_dateのリストにただ追加する。
これを機能させるためにline_item_datesのIDを持つturbo_frame_tag内の line item datesを囲む必要がある。
新規作成したline_item_dateを追加するために
app/views/quotes/show.html.erb

<%# All the previous code... %>

<%= turbo_frame_tag "line_item_dates" do %>
  <%= render @line_item_dates, quote: @quote %>
<% end %>

<%# All the previous code... %>

またturbo_frame_tag内の各line item dateを囲む必要がある
これは各line item date をIDのによって特定できるようにするため。
特定のline item dateの後に挿入する必要があるときのため
pp/views/line_item_dates/_line_item_date.html.erb

<%= turbo_frame_tag line_item_date do %>
  <div class="line-item-date">
    <!-- All the previous code -->
  </div>
<% end %>

編集機能の作成
最初に編集フォームがQuotes#showpageのHTMLを置換する必要がある
app/views/line_item_dates/edit.html.erb

<main class="container">
  <%= link_to sanitize("&larr; Back to quote"), quote_path(@quote) %>

  <div class="header">
    <h1>Edit date</h1>
  </div>

  <%= turbo_frame_tag @line_item_date do %>
    <%= render "form", quote: @quote, line_item_date: @line_item_date %>
  <% end %>
</main>

この時点ですでにformによる置換ができるが、一方でline_item_dateの表示順を保証できない
まずはupdateメソッド内のTurbo Streamを更新する必要がある。
app/controllers/line_item_dates_controller.rb

def update
  if @line_item_date.update(line_item_date_params)
    respond_to do |format|
      format.html { redirect_to quote_path(@quote), notice: "Date was successfully updated." }
      format.turbo_stream { flash.now[:notice] = "Date was successfully updated." }
    end
  else
    render :edit, status: :unprocessable_entity
  end
end

app/views/line_item_dates/update.turbo_stream.erb

<%# Step 1: remove the form %>
<%= turbo_stream.remove @line_item_date %>

<%# Step 2: insert the updated date at the correct position %>
<% if previous_date = @line_item_date.previous_date %>
  <%= turbo_stream.after previous_date do %>
    <%= render @line_item_date, quote: @quote %>
  <% end %>
<% else %>
  <%= turbo_stream.prepend "line_item_dates" do %>
    <%= render @line_item_date, quote: @quote %>
  <% end %>
<% end %>

<%= render_turbo_stream_flash_messages %>

destroy action

app/controllers/line_item_dates_controller.rb

def destroy
  @line_item_date.destroy

  respond_to do |format|
    format.html { redirect_to quote_path(@quote), notice: "Date was successfully destroyed." }
    format.turbo_stream { flash.now[:notice] = "Date was successfully destroyed." }
  end
end

app/views/line_item_dates/destroy.turbo_stream.erb

<%= turbo_stream.remove @line_item_date %>
<%= render_turbo_stream_flash_messages %>