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!