1

I have an object that uses the ActiveModel::Model concepts:

class TransitProvider
  include ActiveModel::Model
 
# ... more stuff

Underneath the covers, this object is an aggregate for a Provider record and a Service record.

Everything seems to be working very well but the form_with helper doesn't recognize a TransitProvider instance as persisted (because it doesn't have it's own ID) and thus the edit action shows the form with data but submits it as a create instead of an update.

Is there a way to add to an ActiveModel class something so that form_with will treat it as an existing instance instead of a new instance?

Do I need to define something around id or persisted? or something like that?

I can't seem to find anything specific to this use case.

Thanks!

Dan Sharp
  • 1,209
  • 2
  • 12
  • 31
  • 1
    How do you know what record to update when there is no id? Do you use the ids of the underlying provider and service records? Why do not use an aggregate of those ids as an id for the transit provider? – spickermann Jul 03 '22 at 13:10
  • 1
    may be adding a method option (post or put) to the `form_with` can solve problem. if you are rendering this form from new and edit html.erb files then u can pass the method parameter when rendering the form. – Mehmet Adil İstikbal Jul 03 '22 at 17:51
  • Turns out Alex had it right in the answer. I just needed to define `persisted?` and I was good to go. I defined that as: `def persisted?; id.present? end` – Dan Sharp Jul 07 '22 at 01:20

1 Answers1

1

Override persisted? method. It is defined in ActiveModel::API:

def persisted?
  false
end

This method is used by the form builder to decide if it needs to send a post or patch request.

# app/models/transit_provider.rb
class TransitProvider
  include ActiveModel::Model
  attr_accessor :provider, :service

  # NOTE: This is set to `false` by default. See `ActiveModel::API`.
  # TODO: Decide what it means for `TransitProvider`
  #       to be persisted. Could `provider` be persisted while 
  #       `service` is not?
  def persisted?
    provider.persisted? && service.persisted?
  end

  # NOTE: `id` would be required for the update route
  #       for plural `resources`.
  #       Don't need it for a singular `resource`. See routes.rb.
  # def id
  #   1
  # end
end

# config/routes.rb
Rails.application.routes.draw do
  resource :transit_provider, only: [:create, :update]
  #       ^
  # NOTE: Singular. Don't need `id` in routes, we're not asking
  #       for any data from this controller.

  # NOTE: Make url mapping always resolve to singular route.
  #
  #         `transit_provider_path`
  #
  #       Otherwise, in the form url would resolve to undefined
  #       plural `transit_providers_path` for `create` action.
  resolve("TransitProvider") { [:transit_provider] }

  # NOTE: Change it if you need `id`. Also add `id` method to 
  #       `TransitProvider`
  # resources :transit_providers
end

# app/controllers/transit_providers_controller.rb
class TransitProvidersController < ApplicationController
  def create
    # TODO: create
  end

  def update
    # TODO: update
  end
end
# NOTE: persisted
<% model = TransitProvider.new(
             provider: Provider.first,
             service:  Service.first)
%>

# NOTE: not persisted
# model = TransitProvider.new(provider: Provider.new, service: Service.new)

<%= form_with model: model do |f| %>

  <%= f.fields_for :provider, model.provider do |ff| %>
    <%= ff.text_field :id if ff.object.persisted? %>
    <%= ff.text_field :name %>
  <% end %>

  <%= f.fields_for :service, model.service do |ff| %>
    <%= ff.text_field :id if ff.object.persisted? %>
    <%= ff.text_field :name %>
  <% end %>

  <%= f.submit %>
<% end %>

For persisted TransitProvider form does a PATCH request to update.

Started PATCH "/transit_provider" for 127.0.0.1 at 2022-07-03 15:47:57 -0400
Processing by TransitProvidersController#update as TURBO_STREAM
  Parameters: {"authenticity_token"=>"[FILTERED]", "transit_provider"=>{"provider"=>{"id"=>"1", "name"=>"provide"}, "service"=>{"id"=>"1", "name"=>"service"}}, "commit"=>"Update Transit provider"}

Otherwise it is a POST to create.

Started POST "/transit_provider" for 127.0.0.1 at 2022-07-03 16:13:43 -0400
Processing by TransitProvidersController#create as TURBO_STREAM
  Parameters: {"authenticity_token"=>"[FILTERED]", "transit_provider"=>{"provider"=>{"name"=>""}, "service"=>{"name"=>""}}, "commit"=>"Create Transit provider"}

Update what is persisted?

ActiveModel gets its persisted? method from ActiveModel::API it is unrelated to ActiveRecord's persisted? method. Neither take id attribute into account to decide if the record is persisted:

# ActiveModel's persisted? is just `false`

# ActiveRecord
Service.create(name: "one")            # => #<Service: id: 1, name: "one">

Service.new.persisted?                 # => false
Service.first.persisted?               # => true

Service.new(id: 1).persisted?          # => false
Service.new(id: 1).reload.persisted?   # => true


s = Service.select(:name).first
s.id                                   # => nil
s.persisted?                           # => true

s = Service.first.destroy
s.id                                   # => 1
s.persisted?                           # => false

This is important because form builder uses this method to choose between POST and PATCH method and url_for helper uses it to build polymorphic routes.

url_for(Service.first)               # => "/services/1"  
url_for(Service.new(id: 1))          # => "/services"
url_for(Service.new)                 # => "/services"

# NOTE: it is different from named route helpers,
#       which will grab required `params` from anything
#       argument, hash, model, or url params.

service_path(Service.first)          # => "/services/1"                  
service_path(Service.new(id: 1))     # => "/services/1"
service_path({id: 1})                # => "/services/1"
service_path(1)                      # => "/services/1"

# and if `params` have id: 1 (as in show action)
service_path                         # => "/services/1"

Note that, by default, ActiveModel::API implements persisted? to return false, which is the most common case. You may want to override it in your class to simulate a different scenario.

https://api.rubyonrails.org/classes/ActiveModel/API.html#method-i-persisted-3F

https://api.rubyonrails.org/classes/ActiveModel/Model.html

https://api.rubyonrails.org/classes/ActionDispatch/Routing/Mapper/Resources.html#method-i-resource

https://api.rubyonrails.org/classes/ActionDispatch/Routing/Mapper/CustomUrls.html#method-i-resolve

Alex
  • 16,409
  • 6
  • 40
  • 56
  • 1
    Alex, thank you! This is exactly what I was looking for. I was able to get this working by defining `persisted?` and then getting the `create` and `update` methods working separately. It's still odd because I have an object that presents an `id` but the `id` is really the `id` of one of the constituent objects. However, it does work and gets the job done. Thank you! – Dan Sharp Jul 07 '22 at 01:22
  • @DanSharp see the update. hope it helps. – Alex Jul 07 '22 at 06:12
  • Thanks Alex! Yeah, the ID is a delegated ID. Basically if the ActiveModel is populated via a persisted Provider and Service, it should act as if it's a persisted record. So checking to see if the delegated Provider is persisted or not and having that be the answer to whether the TransitProvider is persisted seems to work just fine. – Dan Sharp Jul 07 '22 at 14:04