TASK 1: Create messages inside an inbox without refresh. Render form errors.

hotwire-demo-1

0. Initial setup

My setup:

  • Rails 7
  • Ruby 3.0.1
  • Hotwire (Stimulus + Turbo), pre-installed in Rails 7
  • postgresql
  • => My boilerplate app

Some options to create new Rails 7 app:

rails new superails -d=postgresql
rails new superails --main --d=postgresql --css=bulma
rails new superails -d=postgresql --skip-javascript
rails new superails -d=postgresql --css tailwind
rails new superails -d=postgresql --css bootstrap
rails new superails -d=postgresql --javascript esbuild --css bootstrap

Basic views and models:

rails g controller static_pages landing_page pricing privacy terms --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework
rails g scaffold Inbox name:string --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework --no-jbuilder
rails g scaffold Message body:text inbox:references --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework --no-jbuilder
rails db:migrate

1.1. Turbo stream - stream [create, destroy, update] messages to inbox

#app/models/message.rb

  # lets you use dom_id in a model
  include ActionView::RecordIdentifier

  # add on top
  after_create_commit { broadcast_prepend_to [inbox, :messages], target: "#{dom_id(inbox)}_messages" }

  # add on bottom
  # after_create_commit { broadcast_append_to [inbox, :messages], target: "#{dom_id(inbox)}_messages" }

  after_destroy_commit { broadcast_remove_to [inbox, :messages], target: "#{dom_id(self)}" }

  after_update_commit { broadcast_update_to [inbox, :messages], target: "#{dom_id(self)}" }

#app/views/inboxes/show.html.erb

<%= turbo_stream_from @inbox, :messages %>
<div id="<%= "#{dom_id(@inbox)}_messages" %>">
  <%= render @inbox.messages %>
</div>

#app/views/messages/_message.html.erb

<div id="<%= dom_id(message) %>">
  <%= message.body %>
</div>

Test in console

Inbox.create(name: Faker::Lorem.sentence(word_count: 3))
Inbox.first.messages << Message.create(body: Faker::Lorem.sentence(word_count: 3))
Inbox.first.messages.first.update(body: SecureRandom.hex)
Inbox.first.messages.first.destroy

1.2. Turbo stream - form to create messages. Error - form with errors. Success - new form.

  • nested resources

#config/routes.rb

  resources :inboxes do
    resources :messages, only: %i[create]
  end
  • render form to create a new message inside an inbox

#app/views/inboxes/show.html.erb

<%= render partial: "messages/form", locals: { message: Message.new } %>
  • wrap the form into a turbo frame

#app/views/messages/_form.html.erb

<%= turbo_frame_tag "message_form" do %>
  <%= form_with model: message, url: inbox_messages_path(@inbox) do |form| %>
  	...
  <% end %>
<% end %>
  • send responce with format.turbo_stream to replace turbo frame with id message_form with partial messages/form

#app/controllers/messages_controller.rb

  def create
    @inbox = Inbox.find(params[:inbox_id])
    @message = @inbox.messages.new(message_params)

    respond_to do |format|
      if @message.save
        format.turbo_stream { render turbo_stream: turbo_stream.replace(
          'message_form', 
          partial: 'messages/form', 
          locals: { message: Message.new }
        ) }
        # format.html { render partial: 'messages/form', locals: { message: Message.new }}
        format.html { redirect_to @message, notice: "Message was successfully created." }
        format.json { render :show, status: :created, location: @message }
      else
        format.turbo_stream { render turbo_stream: turbo_stream.replace(
          'message_form', 
          partial: 'messages/form', 
          locals: { message: @message }
        ) }
        # format.html { render partial: 'messages/form', locals: { message: @message }}
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @message.errors, status: :unprocessable_entity }
      end
    end
  end

Resources: