Phlex'ing in View

Phlex by Joel Drapper is some very clever Ruby code to componentizing your views, for all in Rails via the phlex-rails gem!

For what seems only like yesterday but probably is way more than a year I've kept adding components (what Rails labels partials I believe) to 'my tool-belt' building from this gem.

My goal was to be able to do

<%= render_form( resource: resource, 
    assoc: :participantable, 
    clipboard_prefix: (resource.new_record? ? 
      nil : 
      equipment_url(resource.assetable))) do |form| %> 

    <!-- 8< -- >
    <%= form.text_field( :name, 
      required: true, 
      focus: true, 
      placeholder: t(:name),
      input_css: "block w-full ...-300 rounded-md") %>

  <%= render_form_action_buttons 
    resource: resource, 
    delete_url: resource_url(), 
    deleteable: !resource.new_record? %>
    
<% end %>

To that end I started by handing the task to a helper


  def render_form(**attribs, &block)
    attribs[:action] ||= params[:action]
    render Views::Components::Form::Form.new **attribs, &block
  end

That prompted the erection of a module

module Views
  #
  # the form component is a wrapper for the form_with form helper
  # and it handles nested forms by adding an argument - assoc - to the initializer
  # another argument supports the clipboard prefix - copy/pasting of form field values
  #
  # Caveats: the form will not support deep nested forms - ie only one level of nesting!
  #
  class Components::Form::Form < Phlex::HTML
    include Phlex::Rails::Helpers::FormWith
    # include Phlex::Rails::Helpers::FieldsFor

    def initialize( **attribs, &block )
      @resource = attribs[:resource]
      @assoc = attribs[:assoc]
      @action = attribs[:action]
      @url = attribs[:url] || nil
      @clipboard_prefix = attribs[:clipboard_prefix]
      @classes = attribs[:css] || "scrollbar-hide h-full flex flex-col bg-white shadow-xl overflow-y-scroll "
      @screen_height = attribs[:screen_height] || "h-screen max-h-screen"
      @screen_width = attribs[:screen_width] || "w-screen max-w-md"
      @fields_css = attribs[:fields_css] || "border-b border-gray-900/10 pb-12 mb-14"
      @form_id = "form_#{Current.user.id rescue 0}"
    end

    def template(&)
      div( id: "#{@form_id}", class: "pointer-events-auto #{@screen_height} #{@screen_width}") do
        form_with( model: @resource, 
          url: set_url( helpers.resource_url()), 
          data: { form_sleeve_target: 'form' }, 
          html: { id: "#{@form_id}_innerform", enctype: "multipart/form-data", class: @classes }) do |form|
            hidden_field( :account_id, value: (Current.account.id rescue nil) ) unless %(account service).include? @resource.class.to_s.underscore
            unless @assoc.nil?
              hidden_field( "#{@assoc}_type", value: @resource.send(@assoc).class.to_s) 
              hidden_field( :id, assoc: @assoc, value: set_id ) 
            end
            div( 
              class:"flex-1 relative space-y-12",
              data_controller: "form",
              data_form_form_sleeve_outlet: "#form-sleeve",
              data_form_list_outlet: "#list",
              data_form_clipboard_prefix_value: @clipboard_prefix,
              data_action: "keydown->form#keydownHandler speicherMessage@window->form#handleMessages"
              ) do
                div( class: @fields_css) do
                  yield
                end
            end
          end
      end
    end

    def set_url helper_url
      u = if @url.nil?
        helper_url
      else
        @url
      end
      re = /(\/[0-9]*\/clonez)$/
      if u =~ re 
        u = u.gsub(re, "")
      end
      u
    end

    def set_id 
      return '' unless @action == 'edit'
      return @assoc.nil? ? @resource.id : @resource.send(@assoc).id
    end

    def boolean_slider_field(field, **attribs, &block)
      attribs[:resource] ||= @resource
      render Views::Components::Form::BooleanSliderField.new field, **attribs, &block
    end

    def checkbox_field(field, **attribs, &block)
      attribs[:resource] ||= @resource
      render Views::Components::Form::CheckboxField.new field, **attribs, &block
    end

    def combo_field(field, **attribs, &block)
      attribs[:resource] ||= @resource
      attribs[:elem_css] = attribs[:elem_css] || "space-y-1 z-10 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5"
      render Views::Components::Form::ComboField.new field, **attribs, &block
    end

    def datetime_field(field, **attribs, &block)
      attribs[:resource] ||= @resource
      render Views::Components::Form::DateTimeField.new field, **attribs, &block
    end

    def email_field(field, **attribs, &block)
      attribs[:resource] ||= @resource
      render Views::Components::Form::EmailField.new field, **attribs, &block
    end
    
    def file_upload_field(field, **attribs, &block)
      attribs[:resource] ||= @resource
      render Views::Components::Form::FileUploadField.new field, **attribs, &block
    end

    def hidden_field( field, **attribs)
      attribs[:resource] ||= @resource
      render Views::Components::Form::HiddenField.new field, **attribs
    end

    def number_field(field, **attribs, &block)
      attribs[:resource] ||= @resource
      render Views::Components::Form::NumberField.new field, **attribs, &block
    end

    def password_field(field, **attribs, &block)
      attribs[:resource] ||= @resource
      render Views::Components::Form::PasswordField.new field, **attribs, &block
    end

    def radio_field(field, **attribs, &block)
      attribs[:resource] ||= @resource
      render Views::Components::Form::RadioField.new field, **attribs, &block
    end

    def text_area(field, **attribs, &block)
      attribs[:resource] ||= @resource
      attribs[:elem_css] = attribs[:elem_css] || "space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5"
      render Views::Components::Form::TextArea.new field, **attribs, &block
    end

    def text_field(field, **attribs, &block)
      attribs[:resource] ||= @resource
      attribs[:elem_css] = attribs[:elem_css] || "space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5"
      render Views::Components::Form::TextField.new field, **attribs, &block
    end

    def token_field(field, **attribs, &block)
      attribs[:resource] ||= @resource
      render Views::Components::Form::TokenField.new field, **attribs, &block
    end

    def uris(field, **attribs, &block)
      attribs[:resource] ||= @resource
      attribs[:assoc] ||= @assoc
      render Views::Components::Form::Uris.new field, **attribs, &block
    end

  end
end

All of this really could've been unnecessary, had it not been for my unability to lure Phlex into accepting my nested forms in the `form_with`. As it turned out, though, I gained more than I probably lost in that battle.

In the form I render fields of all sorts. To that end I've constructed a rather monstrous generic_field.rb which most of my form elements inherits from. It looks somewhat like this [Disclaimer: there is most certainly no 'fiber rich meal' hidden here - only spaghetti I'm afraid]


module Views
  class Components::Form::GenericField < Phlex::HTML
    include Phlex::Rails::Helpers::Label

    # TODO: serious sanitization required!
    def initialize(field, **attribs, &block)
      @resource = attribs[:resource] || nil
      @assoc = attribs[:assoc] || nil
      @assoc_cls = @assoc.nil? ? 
        nil :
        @resource.send(@assoc).class.to_s.underscore
      @lookupClass = attribs[:lookup_class] || 
        (field.to_s.classify.constantize rescue false) || 
        @resource.class.to_s.underscore || 
        nil
        
      ---8<--- AHOALO 'stage setting' aka variables 
 
      type_s = @combo_type.to_s
      @is_single  = !(type_s =~ /single/).nil?
      @is_multi   = !@is_single
      @is_drop   = !(type_s =~ /drop/).nil?
      @is_list   = !(type_s =~ /list/).nil?
      @is_tags   = !(type_s =~ /tag/).nil?
      @is_search = !(type_s =~ /search/).nil?
      @is_add    = !(type_s =~ /add/).nil?
      @is_modal  = attribs[:modal] || false
      @showIcon  = attribs[:show_icon] || false
      @is_key_value = attribs[:key_value] || false

      @field_placeholder = attribs[:placeholder] || ''
      @elem_css = attribs[:elem_css] || 
        "space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5"
      @label_css = attribs[:label_css] || 
        "block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
      @input_css = attribs[:input_css] || 
        "block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"

    end

    def template(&)
      yield
    end

    def set_field_value **attribs
      values = (attribs[:value] || @obj.send(@field_name)) rescue nil
      return nil if values.nil? || 
        values == '' || 
        values == []
      @value_filter.nil? ? 
        values : 
        @value_filter.call( values, @items )
    end
    
    def set_display_value **attribs
      values = @values
      return '' if values.nil? || 
        values == '' || 
        values == []
      return @display_filter.call( values, @items) if !@display_filter.nil?     
      return values if (values.class.ancestors.include?( String) || 
        values.class.ancestors.include?( Numeric) || 
        values.class.ancestors.include?( TrueClass) ||
        values.class.ancestors.include?( FalseClass) )
      return values if values.class.ancestors.include?( ActiveSupport::TimeWithZone)
      [values].flatten.map{ |v| v[@item_label.to_sym] }.join(", ")
    end

    def label_for **attribs, &block 
      return unless @label_visible
      containercls = attribs[:container_class] || ""
      lblcls = attribs[:label_class] || @label_css
      fld = attribs[:field] || @field
      title = attribs[:title] || @title
      div( class: containercls ) do
        if block_given?
          label( @obj, fld, title, name: @field_input_name, class: lblcls, data: { action: "click->form#fieldFaq" }) do
            div( class: "text-sm text-gray-500" ) { @description } if @description.present?
            yield
          end
        else
          if( title =~ /translation missing/i)
            (title =~/<span/i) ? text( title ) : span( class: "translation_missing", title: title ) {fld.to_s}
            div( class: "text-sm text-gray-500" ) { @description } if @description.present?
          else
            label( @obj, fld, title, name: @field_input_name, class: lblcls, data: { action: "click->form#fieldFaq" })
            div( class: "text-sm text-gray-500" ) { @description } if @description.present?
          end
        end
      end
    end

    def values_for **attribs 
      values = nil
      return parent_values if @parent 
      return params_values( attribs[:values] ) if !attribs[:values].nil?
      return model_values if !@obj.nil? and @obj.respond_to? @field_name

      (@obj&.send("values_for_#{@field}") if @obj&.respond_to? "values_for_#{@field}") || []
    end    

    def parent_values
      begin
        return [{ id: @parent.id, cls: @parent.class.to_s, label: @parent.send(@lookup_label)}] if @parent
      rescue
        raise "Parent not found - or lookup_label wrong"
      end
    end

    def params_values values
      begin      
        case true
        when @field==:role && @resource.class==Role;                    set_role_values(values)
        when values.class.ancestors.include?( Integer);                 [{ id: values, cls: "", @item_label => "key" }]
        ---8<--- more cases
        else
          values
        end
      rescue
        raise "Parent not found - or lookup_label wrong"
      end
    end

    def set_role_values values
      values = "" if values.nil? || values.empty?
      values.chars.collect{ |v| set_item(v) }
    end

    def model_values
      begin
        return params_values @obj.send(@field) if @field != @field_name
        params_values @obj.send(@field_name)
      rescue
        raise "(generic_field) Model lookup failed - or lookup_label wrong"
        return nil
      end
    end

    def items_for **attribs
      items = attribs[:items] || []
      items.collect{|i| set_item(i)}
    end

    def set_item item 
      case true
      when item.class.ancestors.include?( String);                  i= (item.split(";").size < 2) ? ({ id: item, cls: item, @item_label => item }) : ({ id: item.split(";")[0], cls: item.split(";")[2], @item_label => item.split(";")[1] })
      ---8<--- more cases
      else
        i={ id: item[:id], cls: item[:cls], @item_label => item[@item_label] }
      end
      OpenStruct.new(i)
    end
        
  end
end

That leaves me with a fairly easy task when fx adding a component to display a password field:

module Views
  #
  # the password_field component is a wrapper for the password_field form input field helper
  #
  class Components::Form::PasswordField < Views::Components::Form::GenericField
    include Phlex::Rails::Helpers::PasswordField

    # arguments: field, assoc: nil, title: nil, data: {}, required: false, focus: false, disabled: false, 
    # text_css: "", label_css: "", input_css: ""
    def initialize( field, **attribs, &block )
      @url = attribs[:url] || ""
      super( field, **attribs, &block )

      @elem_css = "space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5 #{attribs[:elem_css]}"
      @label_css = "block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2 #{attribs[:label_css]}"
      @input_css = "block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md #{attribs[:input_css]}}"
    end

    def template(&)
      div class: @elem_css do
        label_for
        div( class: "sm:col-span-2") do
          password_field( @obj, @field, 
            id: @field_input_id,
            name: @field_input_name,
            value: @field_value,
            disabled: @disabled,
            placeholder: @field_placeholder,
            autocomplete: @autocomplete,
            tabindex: @tabindex,
            required: @required, 
            data: @data,
            class: @input_css) do |f|
              yield
            end
          div( class:"text-sm text-red-800" ) { @obj.errors.where(@field).map( &:full_message).join( "og ") }
        end
      end
    end
  end
end

– and it brings back memories of `simple_form` when I get to do

<%= form.password_field :password, 
  autocomplete: "current-password", 
  required: true, 
  placeholder: t('password'),
  class: "appearance-none block w-full px-3 ..." %>

in my forms (knowning that it is all testable w/o any headless chrome, being 'just' PORO's, and promising to greedily suck out every last cycle the "cloud" may provide.

Life is sweat! Thank you Joel!