Search code examples
ruby-on-railsrubypostgresqlruby-on-rails-5simple-form

Ruby On Rails PG Database problem using nested form with a table entity that has a table-less model relationship 'has many'


The main purpose of the task I'm working on is to create a form for admins that includes multiple time ranges, so I could save those time ranges in my database as a jsonb attribute. I could add as many time ranges as I want. The front-end dynamic is done, but I have struggled a lot with the back-end and the params I would like to get.

The params I would like to get from my simple form should be similar as follows:

admin: {first_name, last_name, age, time_ranges: [{start_time, end_time}, {start_time, end_time},{start_time, end_time}...]}

I have been reading a lot about nested forms, but I could not achieve what I want. I have tried creating a tables-less model for the DateRanges, so in my SuperAdmin model (that has a table in my db) I could use has_many: time_ranges and accepts_nested_attributes_for: date_ranges.

class Admin < ApplicationRecord
  has_many :time_ranges
  accepts_nested_attributes_for :time_ranges
end

class TimeRange < ApplicationRecord
    include ActiveModel::Model
    include ActiveModel::Attributes

    attribute :end_time
    attribute :start_time
  
    attr_accessor :start_time, :end_time
    belongs_to :admin
  
    def initialize(attributes = {})
      attributes.each do |name, value|
        send("#{name}=", value)
      end
    end
  
    def persisted?
      false
    end
end

and in my admin_controller I have this in my params:

    def admin_params
        params.require(:admin).permit(:first_name, :last_name, :age, time_ranges_attributes: [:start_date, :end_date])
    end

I'm using simple form in my view, so my front-end looks like this:

<%= simple_form_for @admin, url: admin_path(@admin), method: :put do |f|%>
      <tr>
        <td>Name</td>
        <td><%= f.input :first_name %></td>
        <td>Last Name</td>
        <td><%= f.input :last_name %></td>
      </tr>
      <%= f.simple_fields_for :time_ranges do |p|%>
      <tr>
        <td>
          <p class='ml-1'>Start</p>
          <%= p.input :start_time, as: :time, ignore_date: true %>
        </td>
        <td>
          <p class='ml-1'>End</p>
          <%= p.input :end_time, as: :time, ignore_date: true %>
        </td>
        <td>
        </td>
      </tr>
      <% end %>

--------------...I could add as many simple_fields_for as I want (front-end logic using StimulusJS) ---------------------------------------------

  <%= f.button :submit, class: 'button is-info' %>
<% end %>

This the way I found could give me the results I want, but I get the following error as soon as I try to access my view:

PG::UndefinedTable: ERROR:  relation "time_ranges" does not exist
LINE 1: SELECT "time_ranges".* FROM "time_ranges" WHERE "time_ranges...

I know this is because time_range entity doesn't exist in my DB, but I would like to get your help to work around this problem, or I would appreciate it if you give me another solution to get the params I want. I'm using rails 5.2.


Solution

  • has_many and nested attributes are for associations. This isn't an association, it's just a column that contains a string which Rails will serialize and deserialize. That's about all Rails knows about JSONB.

    Instead of nested attributes, you're passing an array parameter.

    def admin_params
      params.require(:admin).permit(:first_name, :last_name, :age, time_ranges: [])
    end
    

    It's up to Admin to validate time_ranges contains the correct objects.

    Your form would take inputs with the name admin[time_ranges][][start_date] and admin[time_ranges][][end_date]. See Parameter Naming Conventions.


    Since Rails does not know about JSONB associations it's going to be a lot more work. This could be done simpler as a regular association with a real table with a single tztsrange or daterange column (it's unclear if you're storing times or dates).

    create_table :admin_availability do
      t.belongs_to :admin
      t.tztsrange :availability, null: false
    end
    
    class AdminAvailability < ApplicationRecord
      belongs_to :admin
    end
    
    class Admin < ApplicationRecord
      has_many :admin_availabilities
      accepts_nested_attributes_for :admin_availability
    end
    

    Now the nested attribute machinery will work. You'll likely still have to translate from start_time/end_time parameters to a single Range object. It might be simpler to add convenience accessors to your controller.

    class AdminAvailability < ApplicationRecord
      belongs_to :admin
    
      def start_time
        availability.begin
      end
    
      def start_time=(start)
        self.availability = Range.new(start, end_time)
      end
    
      def end_time
        availability.end
      end
    
      def end_time=(end)
        self.availability = Range.new(start_time, end)
      end
    end