1

I want to add a dynamic number of LineItems nested within my Transaction form. My Transaction model has_many LineItems.

I'm trying a button to "Add Lines" and a number_field representing X number of LineItems. @line_count would increment by the number_field value and refresh the page.

app/views/trxes/_form.html.erb

<%= form_with(model: @trx) do |f| %>
    <%= f.label :trx_date %>
    <%= f.date_field :trx_date, :value => Date.today %>

    <% @line_count.times do %>
        <%= render 'form_line_item', f: f  %>
    <% end %>
<% end %>
<%= f.submit %>

app/controllers/trxes_controller.rb

  def new
     @trx = Trx.new
     @line_item = @trx.line_items.build
     @line_count = 3
  end

This creates 3 line items by default but adding the following to 'def new' causes

TypeError (no implicit conversion of Symbol into Integer):
app/controllers/trxes_controller.rb:6:in `[]'
  def new
     @trx = Trx.new
     @line_item = @trx.line_items.build
     if params[:add_lines[:line_count]].empty?
       @line_count = 1
     else
       @line_count = params[:add_lines[:line_count]]
     end
  end

Even if I get this right, am I approaching this correctly? (ie: using params on trxes/new) It seems the Rails Guides don't have anything (https://guides.rubyonrails.org/form_helpers.html#adding-fields-on-the-fly)

chug
  • 71
  • 6
  • I assume you want to do this: `params[:add_lines][:line_count]` – Alex Jul 06 '22 at 05:56
  • Thanks, that solves the TypeError. With the updated syntax (+small logic change): >'if params[:add_lines] && params[:add_lines][:line_count] >`\@line_count = params[:add_lines][:line_count].to_i >`else >`\@line_count = 4 >`end Adding fields via button wipes any previously entered data from existing LineItems, whereas the data persists when changing the \@line_count directly in the controller in VSCode and reloading (despite the log shows both send GET requests). – chug Jul 06 '22 at 14:45

1 Answers1

0
@trx = Trx.new # new every time, so nothing changes.

When the form is submitted you get params from it. You have to use these params to recreate the object that the form builder accepts when it renders the form again.

def new
  # NOTE: Initialize new `Trx` with submitted `line_items` params
  #       you can use `accepts_nested_attributes_for` for nested
  #       line items and `fields_for` in the form.
  @trx = Trx.new(trx_params)

  @line_item = @trx.line_items.build

  # You can keep using line_count, but it is not needed
  # @line_count = params.dig(:add_lines, :line_count) || 1

  # NOTE: @trx has `line_items` initialized and we've just added
  #       a new item. At least one `line_item` is always here.
  @line_count = @trx.line_items.size
end

private

def trx_params
  # NOTE: Use fetch and fallback to "{}" if :trx param is missing
  #       when `new` is rendered for the first time.
  #       Adjust `permit` to fit your params.
  params.fetch(:trx, {}).permit!
end

Update

You can replace params.require(:trx) with params.fetch(:trx, {}). require will raise an error if :trx is missing. fetch will fallback to {}, which is what we need. Also, fetch is the default rails scaffold and if something isn't working, you can see parameters in the log.

I'm not sure which part got you confused, because you explained exactly how the request happens.

Input name and value is how parameters are handled in the form.

<input name="trx[line_items_attributes][0][id]" value="">

When the form is submitted, name and value get converted using x-www-form-urlencoded encoding.

trx%5Bline_items_attributes%5D%5B0%5D%5Bid%5D=

If form method is POST, data is sent in the request body.

POST  /trxes/new
body: trx%5Bline_items_attributes%5D%5B0%5D%5Bid%5D=

# to see post body in rails controller `request.raw_post`

If form method is GET, data is sent as a url query.

GET /trxes/new?trx%5Bline_items_attributes%5D%5B0%5D%5Bid%5D=

# GET requests can have body as well, but it doesn't apply here, and it's rarely used.

ActionDispatch will process incoming request, parsing GET and POST data into parameters. Router will then match url path (/trxes/new, /trxes/1) and request method (GET/POST) with one of your routes. Which will break it down into :action, :controller, and parameters defined in the route pattern (:id). Then controller action is processed and along the line TrxesController#:action is called for which you're responsible. Parameters gathered along the way are available in params.

trx_params returns a hash kind of an object which is ActionController::Parameters that has permitted parameters only.

trx_params # => {"line_items_attributes"=>{"0"=>{"id"=>""}}}

When you pass it to Trx.new, this hash is broken down and is passed to individual setter methods:

Trx.new({"line_items_attributes"=>{"0"=>{"id"=>""}}}) # this is the same..

trx = Trx.new
trx.line_items_attributes = {"0"=>{"id"=>""}}         # ..as this
trx.line_items_attributes = [{"id"=>""}]              # or this
trx.line_items << [LineItem.new(id: "")]              # or this
trx

Now you have a Trx object with one line item. When you call line_items.build, another LineItem is added. In the form, these 2 line items are broken down into inputs. If you click add item again, then 2 line items are submitted as parameters, Trx is initialized again with 2 line items, calling build again adds another line item on top. Repeat forever, until url limit is reached, which is not that big.


Here is what I've come up with:

post "/trxes(/:id)/build/:association", to: "trxes#build", as: :build_trx

resources :trxes do
  post :new, on: :new # add POST /trxes/new
end
<%= form_with(model: @trx, class: "contents") do |f| %>
  <%= f.fields_for :line_items do |ff| %>
    <%= ff.text_field :id, placeholder: "ID" %>
  <% end %>
  
  <!-- doesn't work for edit -->
  <%= f.submit "Get new item",  formmethod: :get,  formaction: new_trx_path(id: f.object) %>
  <!-- because `:id` is dropped and replaced by form params -->
  <!-- a hidden input would be a solution to send :id from edit page -->

  <!-- works for new/edit -->
  <%= f.submit "Post new item", formmethod: :post, formaction: new_trx_path(id: f.object) %>

  <!-- works for new/edit and any other association -->
  <%= f.submit "Build item",    formmethod: :post, formaction: build_trx_path(f.object, :line_items) %>

  <%= f.submit %>
<% end %>
class TrxesController < ApplicationController
  # GET/POST /trxes/new
  def new
    @trx = Trx.new(trx_params) # or trx(trx_params) to make edit work.
    @trx.line_items.build
    render :new, status: :unprocessable_entity if request.post?

    # NOTE: you don't need `:unprocessable_entity` if you're not
    #       using `Turbo`, which makes above `render` call also
    #       unnecessary.
  end

  # POST /trxes(/:id)/build/:association
  def build
    trx(trx_params)
    build_permitted_association params[:association]
    action = trx.persisted? ? :edit : :new
    render action, status: :unprocessable_entity
  end

  ###

  def index; @trxes = Trx.all end
  def show; trx end
  def edit; trx end

  # POST /trxes
  def create
    if trx(trx_params).save
      redirect_to trx_url(trx), notice: "Saved."
    else
      render :new, status: :unprocessable_entity
    end
  end

  # PATCH/PUT /trxes/:id
  # `update` became literally the same as `create` here.
  alias_method :update, :create

  # DELETE /trxes/:id
  def destroy
    trx.destroy
    redirect_to trxes_url, notice: "Deleted."
  end

  private

  def build_permitted_association association
    # NOTE: Specify associations that can be built.
    permitted_associations = %w[line_items]

    # NOTE: Alternatively, use reflections to look up existing associations.
    #
    #         permitted_associations = Trx.reflect_on_all_associations(:has_many).map(&:name)
    #         # => ["line_items"]

    # NOTE: Guard against sending something else, like, "destroy".
    unless permitted_associations.include?(association)
      logger.debug { "\e[31mSkipping unpermitted association:\e[0m @trx.#{association} in #{self.class}##{action_name}. Permitted: #{permitted_associations}" }
      return
    end

    # NOTE: Now if you add another :has_many association to `Trx`, it can
    #       be built by updating `permitted_associations` or without touching
    #       this method at all when using reflections.
    trx.public_send(association).build
  end

  # NOTE: This is just a personal preference. Instead of having
  #       `before_action`, `set_trx`, and using `@trx`, just call `trx`
  #       in any action.
  #
  #          trx
  #          trx(trx_params)
  #
  def trx attributes = {}
    @trx ||= begin
               model = params[:id] ? Trx.find(params[:id]) : Trx.new
               model.attributes = attributes
               model
             end
  end

  def trx_params
    params.fetch(:trx, {}).permit!
  end
end
class Trx < ApplicationRecord
  has_many :line_items, dependent: :destroy
  accepts_nested_attributes_for :line_items
end

class LineItem < ApplicationRecord
  belongs_to :trx
end

https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html#method-i-accepts_nested_attributes_for

https://api.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-fields_for

Alex
  • 16,409
  • 6
  • 40
  • 56
  • I've struggled with this for a couple weeks and reread the Rails Getting Started Guide. Could you walk me through the Rails GET/POST sequence? My def new() now accepts params, but how does Rails know to add another line_item to @trx? I'm expecting GET to /trx/new. Rails shows 1 trx and 1 line item. I enter some info, click 'add line' and the page refreshes via GET request with trx_params, and rails rebuilds the form using params. Now there is 1 trx and 1 line item filled out, and 1 empty line item. No POST request has yet been made. – chug Jul 24 '22 at 15:21
  • And I'm already using def trx_params ...params.require(:trx)... In order to use .fetch(:trx, {}) I think I need to create a separate trx_params, something like new_trx_params? – chug Jul 24 '22 at 15:27
  • I've done a similar thing in this answer, it has more details on `fields_for` and `accepts_nested_attributes_for`: https://stackoverflow.com/a/71715794/207090 – Alex Jul 25 '22 at 18:45
  • My git is lacking but I know I wasn't explicitly sending the trx object in my add_lines submit button. The 'Get new item' button works well but the 'Post new item' btn is great. I'm having trouble with the 'Build item' btn, but the server log indicates it's my line_item_controller and my original @trx = Trx.find(params[:trx_id]). I think I can figure it out once I convert everything to your style. I appreciate the thorough answer and consider my main issue resolved. I will continue working through these other issues as well as read the answer you've referenced. – chug Jul 26 '22 at 20:38