How to handle date fields in a non-model Rails form? - ruby-on-rails

I am creating a non-model object that will be used with a Rails form builder by using ActiveModel. This is a Rails 3 project. Here's an example of what I have so far:
class SalesReport
include ActiveModel::Validations
include ActiveModel::Conversion
extend ActiveModel::Naming
attr_accessor :promotion_code, :start_date, :end_date
def initialize(attributes = {})
attributes.each do |name, value|
send("#{name}=", value)
end
end
def persisted?
false
end
end
I happen to be using HAML and simple_form, but that's not important. Ultimately, I'm just using standard Rails date select fields:
= simple_form_for [:admin, #report], as: :report, url: admin_reports_path do |f|
= f.input :promotion_code, label: 'Promo Code'
= f.input :start_date, as: :date
= f.input :end_date, as: :date
= f.button :submit
Rails splits up the date fields into individual fields, so when the form is submitted, there are actually 3 date fields that are submitted:
{
"report" => {
"start_date(1i)" => "2014",
"start_date(2i)" => "4",
"start_date(3i)" => "1"
}
}
In my SalesReport object, I'm assigning the params to my attr methods, but I'm getting an error that I don't have a start_date(1i)= method, which I obviously haven't defined. Ultimately, I'd like to end up with a Date object that I can use instead of 3 separate fields.
How should I handle these date fields in my non-model object?

In your initialization you could manually assign the values from attributes to the class methods and down below override you start_date and end_date setter methods.
class SalesReport
include ActiveModel::Validations
include ActiveModel::Conversion
extend ActiveModel::Naming
attr_accessor :promotion_code, :start_date, :end_date
def initialize(attributes = {})
#promotion_code = attributes['promotion_code']
year = attributes['start_date(1i)']
month = attributes['start_date(2i)']
day = attributes['start_date(3i)']
self.start_date = [year, month, day]
end
def start_date=(value)
if value.is_a?(Array)
#start_date = Date.new(value[0].to_i, value[1].to_i, value[2].to_i)
else
#start_date = value
end
end
def persisted?
false
end
end
This should allow you to give the setter a Date instance or an Array with the separate date elements and the setter will assign the correct date to #start_date.
Just do the same for #end_date.
Hope this can help you.

Related

activeadmin and dynamic store accessors fails on new resource

I want to generate forms for a resource that has a postgres jsonb column :data, and I want the schema for these forms to be stored in a table in the database. After a lot of research I am 90% there but my method fails in ActiveAdmin forms upon create (not update). Can anyone explain this?
Sorry for the long code snippets. This is a fairly elaborate setup but I think it would be of some interest since if this works one could build arbitrary new schemas dynamically without hard-coding.
I am following along this previous discussion with Rails 6 and ActiveAdmin 2.6.1 and ruby 2.6.5.
I want to store Json Schemas in a table SampleActionSchema that belong_to SampleAction (using the json-schema gem for validation)
class SampleActionSchema < ApplicationRecord
validates :category, uniqueness: { case_sensitive: false }, allow_nil: false, allow_blank: true
validate :schema_is_json_schema
private
def schema_is_json_schema
metaschema = JSON::Validator.validator_for_name("draft4").metaschema
unless JSON::Validator.validate(metaschema, schema)
errors.add :schema, 'not a compliant json schema'
end
end
end
class SampleAction < ActiveRecord::Base
belongs_to :sample
validate :is_sample_action
validates :name, uniqueness: { case_sensitive: false }
after_initialize :add_field_accessors
before_create :add_field_accessors
before_update :add_field_accessors
def add_store_accessor field_name
singleton_class.class_eval {store_accessor :data, field_name.to_sym}
end
def add_field_accessors
num_fields = schema_properties.try(:keys).try(:count) || 0
schema_properties.keys.each {|field_name| add_store_accessor field_name} if num_fields > 0
end
def schema_properties
schema_arr=SampleActionSchema.where(category: category)
if schema_arr.size>0
sc=schema_arr[0]
if !sc.schema.empty?
props=sc.schema["properties"]
else
props=[]
end
else
[]
end
end
private
def is_sample_action
sa=SampleActionSchema.where(category: category)
errors.add :category, 'not a known sample action' unless (sa.size>0)
errors.add :base, 'incorrect json format' unless (sa.size>0) && JSON::Validator.validate(sa[0].schema, data)
end
end
This all works correctly; For example, for a simple schema called category: "cleave", where :data looks like data: {quality: "good"}, I can create a resource as follows in the rails console:
sa=SampleAction.new(sample_id: 6, name: "test0", data: {}, category: "cleave" )
=> #<SampleAction id: nil, name: "test0", category: "cleave", data: {}, created_at: nil, updated_at: nil, sample_id: 6>
sa.quality = "good" => true
sa.save => true
To make this system work in AA forms, I call the normal path (new or edit)_admix_sample_action_form with params: {category: "cleave"} and then I generate permit_params dynamically:
ActiveAdmin.register SampleAction, namespace: :admix do
permit_params do
prms=[:name, :category, :data, :sample_id, :created_at, :updated_at]
#the first case is creating a new record (gets parameter from admix/sample_actions/new?category="xxx"
#the second case is updating an existing record
#falls back to blank (no extra parameters)
categ = #_params[:category] || (#_params[:sample_action][:category] if #_params[:sample_action]) || nil
cat=SampleActionSchema.where(category: categ)
if cat.size>0 && !cat[0].schema.empty?
cat[0].schema["properties"].each do |key, value|
prms+=[key.to_sym]
end
end
prms
end
form do |f|
f.semantic_errors
new=f.object.new_record?
cat=params[:category] || f.object.category
f.object.category=cat if cat && new
f.object.add_field_accessors if new
sas=SampleActionSchema.where(category: cat)
is_schema=(sas.size>0) && !sas[0].schema.empty?
if session[:active_sample]
f.object.sample_id=session[:active_sample]
end
f.inputs "Sample Action" do
f.input :sample_id
f.input :name
f.input :category
if !is_schema
f.input :data, as: :jsonb
else
f.object.schema_properties.each do |key, value|
f.input key.to_sym, as: :string
end
end
end
f.actions
end
Everything works fine if I am editing an existing resource (as created in the console above). The form is displayed and all the dynamic fields are updated upon submit. But when creating a new resource where e.g. :data is of the form data: {quality: "good"} I get
ActiveModel::UnknownAttributeError in Admix::SampleActionsController#create
unknown attribute 'quality' for SampleAction.
I have tried to both add_accessors in the form and to override the new command to add the accessors after initialize (these should not be needed because the ActiveRecord callback appears to do the job at the right time).
def new
build_resource
resource.add_field_accessors
new!
end
Somehow when the resource is created in the AA controller, it seems impossible to get the accessors stored even though it works fine in the console. Does anyone have a strategy to initialize the resource correctly?
SOLUTION:
I traced what AA was doing to figure out the minimum number of commands needed. It was necessary to add code to build_new_resource to ensure that any new resource AA built had the correct :category field, and once doing so, make the call to dynamically add the store_accessor keys to the newly built instance.
Now users can create their own original schemas and records that use them, without any further programming! I hope others find this useful, I certainly will.
There are a couple ugly solutions here, one is that adding the parameters to the active admin new route call is not expected by AA, but it still works. I guess this parameter could be passed in some other way, but quick and dirty does the job. The other is that I had to have the form generate a session variable to store what kind of schema was used, in order for the post-form-submission build to know, since pressing the "Create Move" button clears the params from the url.
The operations are as follows: for a model called Move with field :data that should be dynamically serialized into fields according to the json schema tables, both
admin/moves/new?category="cleave" and admin/moves/#/edit find the "cleave" schema from the schema table, and correctly create and populate a form with the serialized parameters. And, direct writes to the db
m=Move.new(category: "cleave") ==> true
m.update(name: "t2", quality: "fine") ==> true
work as expected. The schema table is defined as:
require "json-schema"
class SampleActionSchema < ApplicationRecord
validates :category, uniqueness: { case_sensitive: false }, allow_nil: false, allow_blank: true
validate :schema_is_json_schema
def self.schema_keys(categ)
sas=SampleActionSchema.find_by(category: categ)
schema_keys= sas.nil? ? [] : sas[:schema]["properties"].keys.map{|k| k.to_sym}
end
private
def schema_is_json_schema
metaschema = JSON::Validator.validator_for_name("draft4").metaschema
unless JSON::Validator.validate(metaschema, schema)
errors.add :schema, 'not a compliant json schema'
end
end
end
The Move table that employs this schema is:
class Move < ApplicationRecord
after_initialize :add_field_accessors
def add_field_accessors
if category!=""
keys=SampleActionSchema.schema_keys(category)
keys.each {|k| singleton_class.class_eval{store_accessor :data, k}}
end
end
end
Finally, the working controller:
ActiveAdmin.register Move do
permit_params do
#choice 1 is for new records, choice 2 is for editing existing
categ = #_params[:category] || (#_params[:move][:category] if #_params[:move]) || ""
keys=SampleActionSchema.schema_keys(categ)
prms = [:name, :data] + keys
end
form do |f|
new=f.object.new_record?
f.object.category=params[:category] if new
if new
session[:current_category]=params[:category]
f.object.add_field_accessors
else
session[:current_category] = ""
end
keys=SampleActionSchema.schema_keys(f.object.category)
f.inputs do
f.input :name
f.input :category
keys.each {|k| f.input k}
end
f.actions
end
controller do
def build_new_resource
r=super
r.assign_attributes(category: session[:current_category])
r.add_field_accessors
r
end
end
end

Rails: Mapping a Date from form parameters into ActiveModel object

I have a filter-class which includes ActiveModel and consists of two dates:
class MealFilter
include ActiveModel::Model
attribute :day_from, Date
attribute :day_to, Date
end
That model is rendered into a form as following:
<%= form_for(#filter) do |f| %>
<div class="form-group form-group--date">
<%= f.label :day_from %>
<%= f.date_select :day_from %>
</div>
<div class="form-group form-group--date">
<%= f.label :day_to %>
<%= f.date_select :day_to %>
</div>
<% end %>
The problem is now, when the form gets submitted, it sends this parameters to the controller:
{"utf8"=>"✓", "meal_filter"=>{"day_from(1i)"=>"2016", "day_from(2i)"=>"1", "day_from(3i)"=>"29", "day_to(1i)"=>"2016", "day_to(2i)"=>"1", "day_to(3i)"=>"30"}, "commit"=>"Filter"}
I extract the values via Controller parameters:
def meal_filter_params
if params.has_key? :meal_filter
params.require(:meal_filter).permit(:day_from, :day_to)
end
end
if I now assign the params[:meal_filter] to my MealFilter class with #filter = MealFilter.new(meal_filter_params), my date fields are not updated correctly. It seams that the 1i, 2i, 3i parts are not correctly assigned to the dates.
However, this works fine if used an ActiveRecord class.
Do I miss some include? Does anyone know, where this magic mapping is implemented if not in ActiveModel::Model?
I came across this issue while upgrading an app from Rails 3.2 to 5.0
How I sorted it out is as follows:
For Rails 5
class MealFilter
include ActiveRecord::AttributeAssignment
attr_reader :day_from
def initialize(attributes = {})
self.attributes = attributes || {}
end
def day_from=(value)
#day_from = ActiveRecord::Type::Date.new.cast(value)
end
end
For Rails 4.2
class MealFilter
include ActiveRecord::AttributeAssignment
attr_accessor :day_from
def initialize(attributes = {})
self.attributes = attributes || {}
end
def type_for_attribute(name)
case name
when "day_from" then ActiveRecord::Type::Date.new
end
end
end
Then you can do:
attributes = { "day_from(3i)" => "1", "day_from(2i)" => "9", "day_from(1i)" => "2020" }
meal_filter = MealFilter.new(attributes)
meal_filter.day_from
# => Tue, 01 Sep 2020
Ok, found a solution.
What I needed was the MultiparameterAssignment which is actually implemented in ActiveRecord but not in ActiveModel.
As far as I can see, there is an open pull request (https://github.com/rails/rails/pull/8189) that should resolve this issue.
But in the meantime, some clever guy wrote a module that can be included into the model: https://gist.github.com/mhuggins/6c3d343fd800cf88f28e
All you need to do is to include the concern and define a class_for_attribute method that returns the class where your attribute should be mapped to - in my case Date.
You can simply access the 1i, 2i and 3i parameters from the date_select helper and combine them to make new Date in a before_validation callback:
class DateOfBirth
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations::Callbacks
attribute :date_of_birth, :date
attribute "date_of_birth(3i)", :string
attribute "date_of_birth(2i)", :string
attribute "date_of_birth(1i)", :string
before_validation :make_a_date
validates :date_of_birth, presence: { message: "You need to enter a valid date of birth" }
def make_a_date
year = self.send("date_of_birth(1i)").to_i
month = self.send("date_of_birth(2i)").to_i
day = self.send("date_of_birth(3i)").to_i
begin # catch invalid dates, e.g. 31 Feb
self.date_of_birth = Date.new(year, month, day)
rescue ArgumentError
return
end
end
end

Default ActiveRecord Associations in Rails 4

I'm trying to implement a persitent model Setting storage in Rails, using the Active Record. I've already saw other gems like ledermann/rails-settings, but I don't want other dependency, because I'll use it only for one model and need the ability to customize it.
I've created 3 models, "Company", "Setting", "CompanySetting". For the association, I done the follow:
company.rb
has_many :company_settings
setting.rb
has_many :company_settings
has_many :company, through: :company_settings
company_setting.rb
belongs_to :company
belongs_to :setting
But I've a problem, for example, I seed the Settings table with N settings, and I need to have these Settings built when I try to access the Company settings, since they don't have an CompanySetting entry for that Setting.
My attempt was the follow:
company.rb
has_many :company_settings
accepts_nested_attributes_for :company_settings
def load_company_settings
Setting.all.collect { |setting|
company_settings.find_by( setting: setting ) || company_settings.build( { setting: setting, value: '' } )
}
end
And then, in my form (using Simple Form):
= f.simple_fields_for :company_settings, #company.load_company_settings do |s|
= s.input :value
It renders the correctly number of fields (the N fields in my Setting table), and return they values if exist, otherwise, returns an empty string as value. But when I do a POST, it doesn't saves.
I believe that I'm doing the right thing in Rails 4 Strong Params, so, my companies_controller look like that:
class Company::CompaniesController < Company::BaseController
def show
#company = current_company
end
def edit
#company = current_company
end
def update
#company = current_company
if #company.update(company_params)
redirect_to company_path
else
render 'edit'
end
end
private
def company_params
params.require(:company).permit(:name, company_settings_attributes: [:id, :value, :setting])
end
end
Table Structure - Company:
id
Table Structure - Setting:
title (value to show to user)
key (value used in application)
Table Structure - CompanySetting:
company_id
setting_id
value
Thanks (:
I fixed that, with these steps:
.1 Customize the Model adding the methods for return the list of all possible settings from Setting model, and build new ones with CompanySetting. You need to create an assign method in the class, because when the Strong Params try to save, it will try to find this method.
def settings
Setting.all.collect { |setting|
company_settings.find_by( setting: setting ) || company_settings.build( { setting: setting, value: '' } )
}
end
def set_setting(key, value)
company_settings.find_or_create_by( setting: Setting.find_by(key: key) ).update(value: value)
end
def settings=(attributes)
attributes.map { |key, value|
set_setting(key, value)
}
end
asd
.2 Update the Form to use this new method (Here I added two types of fields, booleans and not booleans. This is based on is_boolean property in Setting
= f.simple_fields_for :settings do |s|
- for setting in #company.settings
.form-group
- if setting.setting.is_boolean
.checkbox
%label
= s.input_field setting.setting.key.to_sym, as: :boolean, boolean_style: :inline, checked: setting.value == "1"
= setting.setting.title
%span.help-block= setting.setting.description
- else
= s.label setting.setting.key.to_sym, setting.setting.title, class: 'control-label'
= s.input_field setting.setting.key.to_sym, class: 'form-control', value: setting.value
%span.help-block= setting.setting.description
.3 Fix your Strong Params
def company_params
params.require(:company).permit(:name,
settings: [
:setting_key_1,
:setting_key_2,
...,
:my_other_n_setting
]
)
end
Done.

2 fields, 1 model attribute, date not parsing

I have 2 fields that is asscociated with 1 attribute in my model. I am applying it in 2 of my attributes: start_at, and end_at. I am using Timepicker Plugin for jQuery.
I based my answer here: https://stackoverflow.com/questions/18461798/rails-4-convert-datetime-into-separate-date-and-time-fields#=
MY PROBLEM IS THAT THE DATE IS NOT PARSING PROPERLY.
Below are my codes:
Appointment model:
before_save :convert_to_datetime
attr_accessor :start_date, :start_time, :end_date, :end_time
def start_date
start_at.strftime("%d/%m/%Y") if start_at.present?
end
def start_time
start_at.strftime("%I:%M%p") if start_at.present?
end
def start_date=(date)
# Change back to datetime friendly format
#start_date = Date.parse(date).strftime("%Y-%m-%d")
end
def start_time=(time)
# Change back to datetime friendly format
#start_time = Time.parse(time).strftime("%H:%M:%S")
end
def end_date
end_at.strftime("%d/%m/%Y") if end_at.present?
end
def end_time
end_at.strftime("%I:%M%p") if end_at.present?
end
def end_date=(date)
# Change back to datetime friendly format
#end_date = Date.parse(date).strftime("%Y-%m-%d")
end
def end_time=(time)
# Change back to datetime friendly format
#end_time = Time.parse(time).strftime("%H:%M:%S")
end
def convert_to_datetime
self.start_at = DateTime.parse("#{#start_date} #{#start_time}")
self.end_at = DateTime.parse("#{#end_date} #{#end_time}")
end
Strong params:
params.require(:task).permit(:category_id, :subcategory_id, :title, :description, :pay_offer, :pay_type, :county_id, :area_id, appointments_attributes: [:id, :start_date, :start_time, :end_date, :end_time])
If you are wondering, appointment is a nested attribute of task model.
Here is the error:
ArgumentError (invalid date):
app/models/appointment.rb:26:in `parse'
app/models/appointment.rb:26:in `start_date='
app/controllers/tasks_controller.rb:9:in `create'
Its referring to this line: #start_date = Date.parse(date).strftime("%Y-%m-%d")
LOG:
"appointments_attributes"=>{"0"=>{"start_date"=>"3/20/2015",
"start_time"=>"12:30 AM",
"end_date"=>"3/21/2015",
"end_time"=>"01:30 AM"}}},
"commit"=>"Create Task"}
Please help. :(
The problem is that Ruby is expecting date in %d/%m/%Y format, and you are passing it in %m/%d/%Y (notice that day and month have changed place). Date "3/20/2015" is invalid because there is no 20th month. :)
Instead of using just Date.parse you should use strptime which allows you to specify date format that you want to parse.
Date.strptime(date, "%m/%d/%Y")

"error(s) on assignment of multiparameter attributes" date/time fields

My application has a model "Appointments" which have a start and end attribute both which are datetimes. I am trying to set the date and time parts separately from my form so I can use a separate date and time picker. I thought I should be able to do it like this. From what I ahve read rails should combine the two parts and then parse the combined field as a datetime like it usually would
The error I am getting:
2 error(s) on assignment of multiparameter attributes [error on assignment ["2013-09-16", "15:30"] to start (Missing Parameter - start(3)),error on assignment ["2013-09-16", "16:30"] to end (Missing Parameter - end(3))]
These are the request parameters:
{"utf8"=>"✓", "authenticity_token"=>"OtFaIqpHQFnnphmBmDAcannq5Q9GizwqvvwyJffG6Nk=", "appointment"=>{"patient_id"=>"1", "provider_id"=>"1", "start(1s)"=>"2013-09-16", "start(2s)"=>"15:30", "end(1s)"=>"2013-09-16", "end(2s)"=>"16:30", "status"=>"Confirmed"}, "commit"=>"Create Appointment", "action"=>"create", "controller"=>"appointments"}
My Model
class Appointment < ActiveRecord::Base
belongs_to :patient
belongs_to :practice
belongs_to :provider
validates_associated :patient, :practice, :provider
end
And the relevant part of the view: (its a simple form)
<%= f.input :"start(1s)", :as => :string, :input_html => { :class => 'date_time_picker' , :value => Date.parse(params[:start]) }%>
<%= f.input :"start(2s)", :as => :string, :input_html => { :class => 'date_time_picker' , :value => Time.parse(params[:start]).strftime('%R') }%>
<%= f.input :"end(1s)", :as => :string, :input_html => { :class => 'date_time_picker' , :value => Date.parse(params[:end]) }%>
<%= f.input :"end(2s)", :as => :string, :input_html => { :class => 'date_time_picker' , :value => Time.parse(params[:end]).strftime('%R') }%>
UPDATE:
THis is now how my model looks like, Ive been trying to do getter/setter methods but I am stuck because start-dat, start_time etc are nil in the model and the parameters aren't sent through
class Appointment < ActiveRecord::Base
belongs_to :patient
belongs_to :practice
belongs_to :provider
validates_associated :patient, :practice, :provider
before_validation :make_start, :make_end
############ Getter Methods for start/end date/time
def start_time
return start.strftime("%X") if start
end
def end_time
return self.end.strftime("%X") if self.end
end
def start_date
return start.strftime("%x") if start
end
def end_date
return self.end.strftime("%x") if self.end
end
def start_time=(time)
end
def end_time=(time)
end
def start_date=(date)
end
def end_date=(date)
end
def make_start
if defined?(start_date)
self.start = DateTime.parse( self.start_date + " " + self.start_time)
end
end
def make_end
if defined?(end_date)
self.start = DateTime.parse( end_date + " " + end_time)
end
end
end
Are you trying to emulate #date_select ? If yes, see second part of answer.
Date database typecast
If you want to assign a DateTime to database, it has to be a DateTime object. Here you use an array of strings, ["2013-09-16", "15:30"].
You can easily compute a datetime from those strings using regexps :
/(?<year>\d+)-(?<month>\d+)-(?<day>\d+)/ =~ params[ 'start(1s)' ]
/(?<hours>\d+):(?<minutes>\d+)/ =~ params[ 'start(2s)' ]
datetime = DateTime.new( year.to_i, month.to_i, day.to_i, hours.to_i, minutes.to_i )
This will store year, month, day, hours and minutes in local variables and create a new datatime based on it, which you can then assign to your model.
Yet, databases can't store ruby DateTime instances as is, so behind the hood, a conversion is made by rails when saving a date or datetime field to convert it as string. The method used is #to_s(:db), which gives, for example :
DateTime.now.to_s(:db) # => "2013-09-17 09:41:04"
Time.now.to_date.to_s(:db) # => "2013-09-17"
So you could theoretically simply join your strings to have proper date representation, but that wouldn't be a good idea, because :
that's implementation details, nothing say this date format won't change in next rails version
if you try to use the datetime after assigning it and before saving (like, in a before_save), it will be a string and not a datetime
Using active_record datetime helpers
As this would be a pain to do that all the time, rails has helpers to create and use datetime form inputs.
FormBuilder#datetime_select will take only the attribute you want and build all needed inputs :
<%= f.datetime_select :start %>
This will actually create 5 inputs, named respectively "start(1i)" (year), "start(2i)" (month), "start(3i)" (day), "start(4i)" (hours) and "start(5i)" (minutes).
If it feels familiar, it's because it's the exact data we retrieved for building a datetime in first part of this answer. When you assign a hash to a datatime field with those exact keys, it will build a datetime object using their values, like we did in first part.
The problem in your own code is that you've just provided "start(1i)" and "start(2i)". Rails doesn't understand, since you only passed it the year and month, a lot less than what is required to compute a datetime.
See How do ruby on rails multi parameter attributes *really* work (datetime_select)
According to this question, the multiparameter attribute method works for Date but not DateTime objects. In the case of a Date, you would pass year, month and day as separate values, hence the Missing Parameter - start(3), as the expected third parameter is not there.
DateTime, however, requires at least five params for instantiation DateTime.new(2013, 09, 16, 15, 30), so you cannot rely on the automated parsing in your case. You would have to split your params first and in that case, you could easily parse it yourself before saving the object using a before_filter or similar methods.
See the constructor:
http://www.ruby-doc.org/stdlib-1.9.3/libdoc/date/rdoc/DateTime.html#method-c-new
and the multiparam description:
http://apidock.com/rails/ActiveRecord/AttributeAssignment/assign_multiparameter_attributes

Resources