I'm building a rails app that users get quotes on rental equipment. A user can have many quotes, but only 1 open quote. A quote can have many quote_line_items and each quote_line_item has the possibility of having 1 or many quote_line_item_components which are essentially just a self referential association through a Join Model
class Quote < ApplicationRecord
belongs_to :user, required: false
has_many :quote_line_items, autosave: true, inverse_of: :quote
has_many :quote_line_item_components, through: :quote_line_items
after_update :quote_after_update, if: :submitted_at_changed?
validates :contact_name, presence: true, length: { maximum: 255 }, if: :submitting_quote?
validates :contact_phone, presence: true, numericality: { only_integer: true }, length: { is: 10 }, if: :submitting_quote?
validates :contact_email, presence: true, length: { maximum: 255 }, email_format: { message: 'is not a valid email'}, if: :submitting_quote?
validates :business_name, presence: true, length: { maximum: 255 }, if: :submitting_quote?
validates :pickup_date, presence: true, if: :submitting_quote?
validates :return_date, presence: true, if: :submitting_quote?
validates :job_name, presence: true, length: { maximum: 255 }, if: :submitting_quote?
scope :latest, -> { order(:created_at) }
scope :submitted_quotes, -> { where.not(submitted_at: nil) }
accepts_nested_attributes_for :quote_line_items
accepts_nested_attributes_for :quote_line_item_components
# If the value of the submitted_at field is changing, we're submitting this quote.
def submitting_quote?
submitted_at_changed?
end
def quote_line_items_attributes=(quote_line_items_attributes)
quote_line_items_attributes.each do |i, quote_line_item_attributes|
self.quote_line_items.build(quote_line_item_attributes)
end
end
def add_or_update_item_quantity(**opts)
line_item = quote_line_items.where(inventory_id: opts[:inventory_id]).first_or_initialize
if line_item.new_record?
line_item.assign_attributes(opts)
elsif opts[:set_quantity].to_s =~ /true/i
line_item.update(quantity: opts.dig(:quantity)&.to_i)
else
new_quantity = line_item.quantity.to_i + opts.dig(:quantity).try(:to_i)
line_item.update(quantity: new_quantity)
end
end
end
Quote Line Item Model
class QuoteLineItem < ApplicationRecord
belongs_to :quote
has_many :quote_line_item_components, foreign_key: "quote_line_item_id", class_name: "QuoteLineItem"
accepts_nested_attributes_for :quote_line_item_components
validates :inventory_id, presence: true
validates :quantity, presence: true, numericality: { integer_only: true, greater_than_or_equal_to: 1 }
# This is a boolean/stringified boolean to tell the Quote model if we want to SET a quantity.
# VS add to it.
attr_accessor :set_quantity
def reject_components(attributes)
attributes['quantity'].to_i == 0
end
end
The QuoteLineItemComponent
class QuoteLineItemComponent < ApplicationRecord
belongs_to :quote_line_item, foreign_key: "quote_line_item_id", class_name: "QuoteLineItem"
validates :inventory_id, presence: true
validates :quantity, presence: true, numericality: { integer_only: true, greater_than_or_equal_to: 1 }
end
The form that is filled out looks like this. Using formtastic have the form referencing the current quote and the item, and if there are nested items semantic_fields_for is used.
= semantic_form_for current_quote, as: :quote, url: users_quote_path(id: :current) do |f|
= f.inputs
= f.semantic_fields_for(:quote_line_items, current_quote.quote_line_items.build) do |i|
= i.input :inventory_name, as: :hidden, input_html: { value: @item.description }
= i.input :inventory_id, as: :hidden, input_html: { value: @item.inventory_id }
= i.input :quantity, as: :number, min: 1, step: 1, wrapper_html: { class: "d-flex align-items-center gap-2 mb-3 formtastic-select"}, input_html: { class: "form-select form-select-sm"}
= i.input :price, as: :hidden, wrapper_html: { class: 'mb-0'}, input_html: { value: @item.daily_rate(@item.inventory_id) }
- if display_item_components?(@item)
li.copy-heading Components
- @item.parts.each do |part|
= i.semantic_fields_for(:quote_line_item_components, i.object.quote_line_item_components.build) do |c|
li.mb-4
= part.descriptions
- if part.included?
b * included
span.d-block
- if current_user
= price_for_item(part)
- else
| Sign in to to view prices
- if !part.included?
span.d-block
- if current_user
= price_for_item(part)
- else
| Sign in to view prices
ul.p-0.mt-3
= c.input :quote_id, as: :hidden
= c.input :inventory_id , as: :hidden, input_html: { value: part.inventory_ids }
= c.input :inventory_name, as: :hidden, input_html: { value: part.descriptions }
= c.input :quantity, as: :number, min: 0, step: 1, wrapper_html: { class: "d-flex align-items-center gap-2 formtastic-select m-0"}, input_html: { class: "form-select form-select-sm"}
= f.actions do
= f.action :submit, as: :button, label: "Add to Quote", button_html: { class: 'btn btn-pink hover-bg-white px-5' }, :wrapper_html => { :data => { :controller => "sidebar"} }
The opts/params submitted are as follows
Single Item
{"0"=>{"inventory_id"=>"00004OCW", "inventory_name"=>"1-TON GRIP VAN", "quantity"=>"1", "price"=>"325.0"}}
Item with Child Items
{"0"=>{"inventory_id"=>"0000SQ8J", "inventory_name"=>"JOLEKO 400 - 400 JOKER BUG-A-BEAM ELLIPSOIDAL KIT", "quantity"=>"1", "price"=>"210.0", "quote_line_item_components_attributes"=>{"3"=>{"inventory_id"=>"00004ILK", "inventory_name"=>"SOURCE FOUR 19 DEGREE BARREL", "quantity"=>"0", "quote_id"=>""}, "4"=>{"inventory_id"=>"00004ILS", "inventory_name"=>"SOURCE FOUR 26 DEGREE BARREL", "quantity"=>"0", "quote_id"=>""}, "5"=>{"inventory_id"=>"00004ILZ", "inventory_name"=>"SOURCE FOUR 36 DEGREE BARREL", "quantity"=>"0", "quote_id"=>""}, "6"=>{"inventory_id"=>"00004IM6", "inventory_name"=>"SOURCE FOUR 50 DEGREE BARREL", "quantity"=>"0", "quote_id"=>""}}}}
I had this working before I added nested items, but now the nested hashes are throwing the unknown attribute on the key "0".
Where it's failing is when it's assigning the opts here line_item.assign_attributes(opts)
def add_or_update_item_quantity(**opts)
line_item = quote_line_items.where(inventory_id: opts[:inventory_id]).first_or_initialize
if line_item.new_record?
line_item.assign_attributes(opts)
elsif opts[:set_quantity].to_s =~ /true/i
line_item.update(quantity: opts.dig(:quantity)&.to_i)
else
new_quantity = line_item.quantity.to_i + opts.dig(:quantity).try(:to_i)
line_item.update(quantity: new_quantity)
end
end
unknown attribute '0' for QuoteLineItem.
raise UnknownAttributeError.new(self, k.to_s)
If anyone can point out the issue, that'd be much appreciated.
The name of the join table is created by using the lexical order of the class names. So a join between author and book models will give the default join table name of 'authors_books' because 'a' outranks 'b' in lexical ordering.