16

I'm trying to solve a pretty common (as I thought) task.

There're three models:

class Product < ActiveRecord::Base  
  validates :name, presence: true

  has_many :categorizations
  has_many :categories, :through => :categorizations

  accepts_nested_attributes_for :categorizations
end

class Categorization < ActiveRecord::Base
  belongs_to :product
  belongs_to :category

  validates :description, presence: true # note the additional field here
end

class Category < ActiveRecord::Base
  validates :name, presence: true
end

My problems begin when it comes to Product new/edit form.

When creating a product I need to check categories (via checkboxes) which it belongs to. I know it can be done by creating checkboxes with name like 'product[category_ids][]'. But I also need to enter a description for each of checked relations which will be stored in the join model (Categorization).

I saw those beautiful Railscasts on complex forms, habtm checkboxes, etc. I've been searching StackOverflow hardly. But I haven't succeeded.

I found one post which describes almost exactly the same problem as mine. And the last answer makes some sense to me (looks like it is the right way to go). But it's not actually working well (i.e. if validation fails). I want categories to be displayed always in the same order (in new/edit forms; before/after validation) and checkboxes to stay where they were if validation fails, etc.

Any thougts appreciated. I'm new to Rails (switching from CakePHP) so please be patient and write as detailed as possible. Please point me in the right way!

Thank you. : )

ok32
  • 1,311
  • 12
  • 27

3 Answers3

31

Looks like I figured it out! Here's what I got:

My models:

class Product < ActiveRecord::Base
  has_many :categorizations, dependent: :destroy
  has_many :categories, through: :categorizations

  accepts_nested_attributes_for :categorizations, allow_destroy: true

  validates :name, presence: true

  def initialized_categorizations # this is the key method
    [].tap do |o|
      Category.all.each do |category|
        if c = categorizations.find { |c| c.category_id == category.id }
          o << c.tap { |c| c.enable ||= true }
        else
          o << Categorization.new(category: category)
        end
      end
    end
  end

end

class Category < ActiveRecord::Base
  has_many :categorizations, dependent: :destroy
  has_many :products, through: :categorizations

  validates :name, presence: true
end

class Categorization < ActiveRecord::Base
  belongs_to :product
  belongs_to :category

  validates :description, presence: true

  attr_accessor :enable # nice little thingy here
end

The form:

<%= form_for(@product) do |f| %>
  ...
  <div class="field">
    <%= f.label :name %><br />
    <%= f.text_field :name %>
  </div>

  <%= f.fields_for :categorizations, @product.initialized_categorizations do |builder| %>
    <% category = builder.object.category %>
    <%= builder.hidden_field :category_id %>

    <div class="field">
      <%= builder.label :enable, category.name %>
      <%= builder.check_box :enable %>
    </div>

    <div class="field">
      <%= builder.label :description %><br />
      <%= builder.text_field :description %>
    </div>
  <% end %>

  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>

And the controller:

class ProductsController < ApplicationController
  # use `before_action` instead of `before_filter` if you are using rails 5+ and above, because `before_filter` has been deprecated/removed in those versions of rails.
  before_filter :process_categorizations_attrs, only: [:create, :update]

  def process_categorizations_attrs
    params[:product][:categorizations_attributes].values.each do |cat_attr|
      cat_attr[:_destroy] = true if cat_attr[:enable] != '1'
    end
  end

  ...

  # all the rest is a standard scaffolded code

end

From the first glance it works just fine. I hope it won't break somehow.. :)

Thanks all. Special thanks to Sandip Ransing for participating in the discussion. I hope it will be useful for somebody like me.

BenKoshy
  • 33,477
  • 14
  • 111
  • 80
ok32
  • 1,311
  • 12
  • 27
  • 2
    nicely done. i get the feeling there may be an easier way, though. – courtsimas May 27 '13 at 17:32
  • Thank so much for sharing, I had also to complement with http://stackoverflow.com/a/15920542/148421 because my values where not being saved and I was missing how to allow nested attributes – Andrea Giovacchini Dec 13 '15 at 23:19
  • With strong params you need to add categorizations_attributes: [:id, :category_id, :description, :_destroy] – Vassilis Sep 23 '18 at 21:40
1

I just did the following. It worked for me..

<%= f.label :category, "Category" %>
<%= f.select :category_ids, Category.order('name ASC').all.collect {|c| [c.name, c.id]}, {} %>
Mats de Swart
  • 538
  • 1
  • 4
  • 10
1

use accepts_nested_attributes_for to insert into intermediate table i.e. categorizations view form will look like -

# make sure to build product categorizations at controller level if not already
class ProductsController < ApplicationController
  before_filter :build_product, :only => [:new]
  before_filter :load_product, :only => [:edit]
  before_filter :build_or_load_categorization, :only => [:new, :edit]

  def create
    @product.attributes = params[:product]
    if @product.save
      flash[:success] = I18n.t('product.create.success')
      redirect_to :action => :index
    else
      render_with_categorization(:new)
    end
  end 

  def update
    @product.attributes = params[:product]
    if @product.save
      flash[:success] = I18n.t('product.update.success')
      redirect_to :action => :index
    else
      render_with_categorization(:edit)
    end
  end

  private
  def build_product
    @product = Product.new
  end

  def load_product
    @product = Product.find_by_id(params[:id])
    @product || invalid_url
  end

  def build_or_load_categorization
    Category.where('id not in (?)', @product.categories).each do |c|
      @product.categorizations.new(:category => c)
    end
  end

  def render_with_categorization(template)
    build_or_load_categorization
    render :action => template
  end
end

Inside view

= form_for @product do |f|
  = f.fields_for :categorizations do |c|
   %label= c.object.category.name
   = c.check_box :category_id, {}, c.object.category_id, nil
   %label Description
   = c.text_field :description
Sandip Ransing
  • 7,583
  • 4
  • 37
  • 48
  • Thank you! But it seems that your solution doesn't play well with validation and other stuff. Checkboxes are always checked and even if I uncheck them it is ignored (they are back after failed validation or if validation passes relations aren't removed). Also I want to list categories always in the same order (in your example you're appending unassigned categories after those which are already associated with the product). Any ideas? – ok32 Feb 07 '12 at 12:01
  • you can do ordering on collection `@product.categorizations` – Sandip Ransing Feb 07 '12 at 13:38
  • can you paste paramaters getting passed ? – Sandip Ransing Feb 07 '12 at 13:42
  • Well.. Now I've tried your whole piece of code (copypasted it). And I have to say it doesn't work at all.:) Did you try it yourself before posting here? Even if I make it work by fixing some bugs it has, there're still some big issues (i.e. categoriztions with NULL category_id are created in the database, etc). Could you try to make it work correct (as I described in the initial question) please? Oh btw I'm on Rails 3.2. – ok32 Feb 07 '12 at 21:05
  • well. above code is to give you proper directions.. dont expect everything will go as smooth as you expected..you have to cater for things which code is not performing. – Sandip Ransing Feb 08 '12 at 06:16
  • regarding NULL `category_id`, `reject categorizations` with blank category_id. can be done like `@product.categorizations.reject!{|c| c.category_id.blank?}` before save inside controller. – Sandip Ransing Feb 08 '12 at 06:19
  • Ok. Thanks. Let's guess it won't create those NULL values. But what if validation fails? Will checkboxes stay where they should be? And will Categorizations be delted if I uncheck them? – ok32 Feb 08 '12 at 09:35
  • @SandipRansing thx very much. would you also know how to handle strong params for this particular situation? – BenKoshy Apr 13 '17 at 05:19