4

I am following a rails tutorial, in the signup form, if invalid user info is submitted, the signup page is supposed to be re-rendered with error messages, but actually it doesn't. It seems that even if the signup page is rendered by "render 'new'", the @user passed to it is empty. How to fix this?

Please note that the tutorial uses Rails 6 but I'm actually using Rails 7.0.2.3 with Ruby 3.1.1. Not sure if this is the cause.

app/controllers/users_controller.rb

class UsersController < ApplicationController
  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save
    else
      render 'new'
    end
  end

  def show
    @user = User.find(params[:id])
  end

  private

  def user_params
    params.require(:user).permit(:name, :email, :password, :password_confirmation)
  end
end

app/views/users/new.html.erb

<% provide(:title, 'Sign up') %>
<h1>Sign up</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_with(model: @user, local: true) do |f| %>
      <%= render 'shared/error_messages' %>
      <%= f.label :name %>
      <%= f.text_field :name, class: 'form-control' %>
      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>
      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>
      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>
      <%= f.submit "Create my account", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>

app/views/shared/_error_messages.html.erb

<% if @user.errors.any? %>
  <div id="error_explanation">
    <div class="alert alert-danger">
      The form contains <%= pluralize(@user.errors.count, "error") %>.
    </div>
    <ul>
      <% @user.errors.full_messages.each do |msg| %>
        <li><%= msg %></li>
      <% end %>
    </ul>
  </div>
<% end %>

app/assets/stylesheets/custom.scss

@import "bootstrap-sprockets";
@import "bootstrap";

/* variables */

$gray-medium-light: #eaeaea;

/* mixins */

@mixin box_sizing {
  -moz-box-sizing: border-box;
  -webkit-box-sizing: border-box;
  box-sizing: border-box;
}

/* miscellaneous */

.debug_dump {
  clear: both;
  float: left;
  width: 100%;
  margin-top: 45px;
  @include box_sizing;
}

/* universal */

body {
  padding-top: 60px;
}

section {
  overflow: auto;
}

textarea {
  resize: vertical;
}

.center {
  text-align: center;

  h1 {
    margin-bottom: 10px;
  }
}

/* typography */

h1, h2, h3, h4, h5, h6 {
  line-height: 1;
}

h1 {
  font-size: 3em;
  letter-spacing: -2px;
  margin-bottom: 30px;
  text-align: center;
}

h2 {
  font-size: 1.2em;
  letter-spacing: -1px;
  margin-bottom: 30px;
  text-align: center;
  font-weight: normal;
  color: $gray-light;
}

p {
  font-size: 1.1em;
  line-height: 1.7em;
}

/* header */

#logo {
  float: left;
  margin-right: 10px;
  font-size: 1.7em;
  color: white;
  text-transform: uppercase;
  letter-spacing: -1px;
  padding-top: 9px;
  font-weight: bold;

  &:hover {
    color: white;
    text-decoration: none;
  }
}

/* footer */

footer {
  margin-top: 45px;
  padding-top: 5px;
  border-top: 1px solid $gray-medium-light;
  color: $gray-light;

  a {
    color: $gray;

    &:hover {
      color: $gray-darker;
    }
  }

  small {
    float: left;
  }

  ul {
    float: right;
    list-style: none;

    li {
      float: left;
      margin-left: 15px;
    }
  }
}

/* sidebar */

aside {
  section.user_info {
    margin-top: 20px;
  }

  section {
    padding: 10px 0;
    margin-top: 20px;

    &:first-child {
      border: 0;
      padding-top: 0;
    }

    span {
      display: block;
      margin-bottom: 3px;
      line-height: 1;
    }

    h1 {
      font-size: 1.4em;
      text-align: left;
      letter-spacing: -1px;
      margin-bottom: 3px;
      margin-top: 0px;
    }
  }
}

.gravatar {
  float: left;
  margin-right: 10px;
}

.gravatar_edit {
  margin-top: 15px;
}

/* forms */

input, textarea, select, .uneditable-input {
  border: 1px solid #bbb;
  width: 100%;
  margin-bottom: 15px;
  @include box_sizing;
}

input {
  height: auto !important;
}

#error_explanation {
  color: red;
  ul {
    color: red;
    margin: 0 0 30px 0;
  }
}

.field_with_errors {
  @extend .has-error;
  .form-control {
    color: $state-danger-text;
  }
}

Debug info of rails server when form submit button is clicked with invalid

Started POST "/users" for ::1 at 2022-04-06 00:55:04
Processing by UsersController#create as TURBO_STREAM
  Parameters: {"authenticity_token"=>"[FILTERED]", "user"=>{"name"=>"foo123", "email"=>"foo123@asdf", "password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "commit"=>"Create my account"}
  TRANSACTION (0.0ms)  begin transaction
  ↳ app/controllers/users_controller.rb:8:in `create'
  User Exists? (0.1ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "foo123@asdf"], ["LIMIT", 1]]
  ↳ app/controllers/users_controller.rb:8:in `create'
  TRANSACTION (0.0ms)  rollback transaction
  ↳ app/controllers/users_controller.rb:8:in `create'
  Rendering layout layouts/application.html.erb
  Rendering users/new.html.erb within layouts/application
  Rendered shared/_error_messages.html.erb (Duration: 0.4ms | Allocations: 403)
  Rendered users/new.html.erb within layouts/application (Duration: 1.9ms | Allocations: 1891)
  Rendered layouts/_shim.html.erb (Duration: 0.0ms | Allocations: 15)
  Rendered layouts/_header.html.erb (Duration: 0.1ms | Allocations: 78)
  Rendered layouts/_footer.html.erb (Duration: 0.1ms | Allocations: 51)
  Rendered layout layouts/application.html.erb (Duration: 9.5ms | Allocations: 8695)
Completed 200 OK in 213ms (Views: 9.8ms | ActiveRecord: 0.2ms | Allocations: 11628)
Romstar
  • 1,139
  • 3
  • 14
  • 21
  • I think this is probably due to Rails 7.0 using turbo and the signup page is not rendered again? If this is the case, how to display the error messages? – Romstar Apr 05 '22 at 13:21
  • Please note that this issue is indeed the result of using Rails 7 instead of Rails 6; as indicated in [Section 1.1.2](https://www.learnenough.com/ruby-on-rails-6th-edition-tutorial#sec-installing_rails), it is important to use exact version numbers to get consistent results. The [7th edition](https://www.learnenough.com/ruby-on-rails-7th-edition-tutorial) of the *Rails Tutorial* uses Rails 7 and includes code to get error messages to display correctly. – mhartl Jan 11 '23 at 18:05

2 Answers2

12

In rails 7 forms are submitting as TURBO_STREAM by default. After submitting a form Turbo expects a redirect unless response status is in 400-599 range.

render :new  # default status is 200

With status code 200 Turbo shows an error in the browser console and page doesn't re-render.

To make Turbo accept rendered html, change the response status. Default seems to be :unprocessable_entity (status code 422)

render :new, status: :unprocessable_entity

https://turbo.hotwired.dev/handbook/drive#redirecting-after-a-form-submission

https://github.com/hotwired/turbo/commit/4670f2b57c5d0246dfc0f6d10ff7d9a52a63fdca


Update: a note on "Content-Type". This applies to default form submission with turbo.

In this set up turbo is expecting an html response with Content-Type: text/html;. @puerile noted that omitting .html extension in your views also breaks the response.

Rails uses .html extension to set response content type to text/html. When extension is omitted content type is set to text/vnd.turbo-stream.html because the form is submitted as TURBO_STREAM, since our response doesn't have a <turbo-stream> it is the wrong content type.

>> Mime[:turbo_stream].to_str
=> "text/vnd.turbo-stream.html"

If we have a view views/users/new.erb, this will not work:

if @user.save
  redirect_to @user
else
  # NOTE: this will render `new.erb` and set 
  #       `Content-Type: text/vnd.turbo-stream.html` header;
  #       turbo is not happy.
  render :new, status: :unprocessable_entity
end

To fix it, use respond_to method:

respond_to do |format|
  if @user.save
    format.html { redirect_to @user }
  else
    # NOTE: this will render `new.erb` and set 
    #       `Content-Type: text/html` header;
    #       turbo is happy.
    format.html { render(:new, status: :unprocessable_entity) }
  end
end

or set content type manually:

if @user.save
  redirect_to @user
else
  render :new, status: :unprocessable_entity, content_type: "text/html"

  # NOTE: you can also set headers like this
  headers["Content-Type"] = "text/html"
end

One caveat with the last set up is that the layout has to be without .html extension as well, otherwise, render :new will render new.erb without a layout and turbo won't be happy again. This is not an issue when using respond_to method.


https://api.rubyonrails.org/classes/ActionController/MimeResponds.html#method-i-respond_to

Alex
  • 16,409
  • 6
  • 40
  • 56
  • This is also an elegant fix, without disabling turbo for the form. Thanks! If I use this method, do I still need `local: true` or `data: { turbo: false }` for `form_with`? – Romstar Apr 06 '22 at 18:33
  • I think 400 Bad Request is better for invalid form data. – Romstar Apr 06 '22 at 18:58
  • 1
    you don't need anything else, just plain `form_with`. `400` is a bit harsh, it means server cannot process the request at all, like if you were missing `user` param you could return 400, but our form request processes just fine even with invalid data. since it is not public api, it doesn't really matter. https://datatracker.ietf.org/doc/html/rfc4918#section-11.2 – Alex Apr 06 '22 at 19:22
  • 1
    You successfully persuaded me that 422 is more appropriate here. Thanks. – Romstar Apr 06 '22 at 20:05
  • 2
    Also found out with **Turbo** that if you generate your views and you change it to just like `new.erb` (cause other people do that!), then it will break as well and won't show error messages even if you have `render :new, status: :unprocessable_entity`. So it's best to leave views with the `.html` in it e.g. (`new.html.erb`) – puerile May 30 '22 at 00:44
  • 1
    @puerile thanks for pointing it out. I updated my answer, it didn't fit in the comment. hope it clarifies a few things. – Alex May 30 '22 at 06:54
  • I did not know the exact reason behind it, greate explanation! @Alex – puerile May 30 '22 at 08:37
4

If you look at the logs you can see that Rails is getting an AJAX request in the form of a turbo stream:

Processing by UsersController#create as TURBO_STREAM

Where it should read:

Processing by UsersController#create as HTML

To disable turbo you want need to set a data-turbo="false" attribute on the form:

<% provide(:title, 'Sign up') %>
<h1>Sign up</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_with(model: @user, data: { turbo: false }) do |f| %>
      <%= render 'shared/error_messages' %>
      <%= f.label :name %>
      <%= f.text_field :name, class: 'form-control' %>
      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>
      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>
      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>
      <%= f.submit "Create my account", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>

The local: false option only works with the old Rails UJS javascript library which was the default prior to Rails 7. You can also disable Turbo by default with:

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

See:

https://turbo.hotwired.dev/handbook/drive#disabling-turbo-drive-on-specific-links-or-forms

max
  • 96,212
  • 14
  • 104
  • 165
  • 1
    On a side note it will probally be easier on you if you use the same version of Rails as the tutorial and then when you have figured out the basics move on to Rails 7. – max Apr 05 '22 at 18:51
  • Thank you for your answer and your suggestion. Where should I put those two lines for disabling Turbo by default? – Romstar Apr 05 '22 at 23:35
  • 'data: { turbo: false }' works like a charm. – Romstar Apr 05 '22 at 23:43
  • `app\javascript\application.js` – max Apr 06 '22 at 00:21