Skip to content

[Feature Request + Solution] Add support for nested forms gem like "Cocoon" #204

@jamesst20

Description

@jamesst20

Hi,

One of the great feature of StoreModel is its compatibility with Rails built in features like accepts_nested_attributes_for.

One bummer is that it won't work with basic gem like https://github.com/nathanvda/cocoon where you can easily have let's say multiple phone numbers with an Add / Trash button because they expect to have an ActiveRecord::Relation but they get a simple Array.

I'm sharing my workaround here but it would be very great to a better/deeper implementation.

Demo usage

class Member < ApplicationRecord
  include StoreModel::NestedAttributes

  attribute :contact, Contact.to_type

  accepts_nested_attributes_for :contact, allow_destroy: true, update_only: true, reject_if: :all_blank
class Contact < ApplicationStoreModel
  attribute :id, :integer
  attribute :name, :string
  attribute :phones, Phone.to_array_type

  accepts_nested_attributes_for :phones, allow_destroy: true, update_only: true, reject_if: :all_blank
end
class Phone < ApplicationStoreModel
  attribute :id, :integer
  attribute :number, :string
  attribute :type_id, :integer
end
app/views/shared/phones/form.html.haml

- model_class = Phone
- partial_fields = "shared/phones/form_fields"

#phones
  = model_class.model_name.human(count: 2)
  = f.simple_fields_for :phones do |ff|
    = render partial_fields, f: ff
  .phones.mb-2
    = link_to_add_association f, :phones, partial: partial_fields, class: "btn btn-outline-secondary my-2"  do
      %i.fas.fa-plus
      = t("helpers.links.add")
app/views/shared/phones/form_fields.html.haml

.nested-fields
  = f.input :number
  = f.input :type_id

  = link_to_remove_association f, class: "btn btn-outline-danger align-self-end" do
    %i.fa-solid.fa-trash

That's it for the Demo. Use cocoon as if everything were standard ActiveRecord model.

Solution / Workaround to get this to work

The trick is to override the phones getter to return an extended Array type that respond to expected methods by Cocoon. The simplest way I got around it is to override the attribute class methods to re-define my getters at the same time. Then i created a base class that I make every StoreModel extends of.

config/initializers/store_model.rb

module CocoonCompatibility
  extend ActiveSupport::Concern

  class ArrayForm < Array
    attr_reader :klass

    def initialize(klass)
      @klass = klass
      super()
    end

    def build
      new_item = klass.new
      push new_item
      new_item
    end
  end

  included do |klass|
    ##
    # Redefine the getter for every StoreModel array type to use the "ArrayForm"
    def self.attribute(name, type = nil, **args)
      super

      return unless type.is_a? StoreModel::Types::Many

      define_method name do
        values = super()

        return values if values.is_a? CocoonCompatibility::ArrayForm

        array = CocoonCompatibility::ArrayForm.new(type.model_klass)
        array.push(*values)
        array
      end
    end

    def self.reflect_on_association(association)
      return nil unless public_method_defined? association

      ActiveRecord::Reflection::HasManyReflection.new(association, nil, {}, OpenStruct.new) # rubocop:disable Style/OpenStructUse
    end

    def new_record?
      id.nil?
    end

    def mark_for_destruction
      @marked_for_destruction = true
    end

    def marked_for_destruction?
      @marked_for_destruction || false
    end

    def _destroy
      marked_for_destruction?
    end
  end
end

class ApplicationStoreModel
  include StoreModel::Model
  include StoreModel::NestedAttributes

  include Draper::Decoratable # If you use the draper gem, this works.

  include CocoonCompatibility
end

Metadata

Metadata

Assignees

No one assigned

    Labels

    help wantedExtra attention is needed

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions