I am trying to dynamically render form errors using a turbo-frame. This works great when there is an error and correctly to populates the form with the submitted params, but when the form submit is successful the redirect doesn't work.
Here is the code I tried:
app/views/orders/show.html.erb
...
<%= render "order_review_form", locals: {order: @order} %>
...
app/views/_order_review_form.html.erb
<%= turbo_frame_tag "order_review_form" do %>
<% if local_assigns[:error] %>
<p class="alert alert-danger alert-dismissible fade show">
<%= sanitize(local_assigns[:error]) %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</p>
<% end %>
<%= form_with(model: Review.new, url: order_review_path(local_assigns[:order])) do |f| %>
<div>
<%= f.label :comment %>
<%= f.text_field :comment, value: params[:comment] %>
</div>
<%= submit_tag "Submit review", class: "btn btn-sm btn-success" %>
<% end %>
<% end %>
app/controllers/order_reviews_controller.rb
...
def create
OrderReviewCreator.call(@order, params)
redirect_to(order_path(@order), status: :see_other) # this doesn't work, the page doesn't update
rescue AppError => e
render(
"_order_review_form",
locals: {
order: @order,
error: e.detail,
},
status: :unprocessable_entity,
)
end
...
The solution was to use a turbo stream response in the controller instead of a turbo frame.
app/views/orders/show.html.erb (no changes)
...
<%= render "order_review_form", locals: {order: @order} %>
...
app/views/shared/_form_error.html.erb
Notes:
target local is what links all of this together across the view and the controller<div id="<%= target %>">
<% if local_assigns[:error] %>
<p class="alert alert-danger alert-dismissible fade show">
<%= sanitize(local_assigns[:error]) %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</p>
<% end %>
</div>
app/views/_order_review_form.html.erb
Changes:
value: params[:comment] from the text field, it's not needed anymore since we're just replacing only the error section and not the entire form<%= render(partial: "shared/form_error", locals: { target: OrderReviewsController::FORM_ERROR_TARGET }) %>
<%= form_with(model: Review.new, url: order_review_path(local_assigns[:order])) do |f| %>
<div>
<%= f.label :comment %>
<%= f.text_field :comment%>
</div>
<%= submit_tag "Submit review", class: "btn btn-sm btn-success" %>
<% end %>
app/controllers/order_reviews_controller.rb
Changes:
Added the constant FORM_ERROR_TARGET so it can be referenced in the view and the controller and changed the render to render a turbo stream for the form error. Note that the FORM_ERROR_TARGET is passed in the render in two places. This effectively replaces whatever html element has the id of FORM_ERROR_TARGET with the contents of the partial.
...
FORM_ERROR_TARGET = "order-review-form-error"
...
def create
OrderReviewCreator.call(@order, params)
redirect_to(order_path(@order), status: :see_other)
rescue AppError => e
render(
turbo_stream: turbo_stream.replace(
FORM_ERROR_TARGET,
partial: "shared/form_error",
locals: {
target: FORM_ERROR_TARGET,
error: e.detail,
},
),
status: :unprocessable_entity,
)
end
...