Search code examples
ruby-on-railsrubyactiverecordruby-on-rails-6activemodel

Using form_with with an ActiveModel object?


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!


Solution

  • 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