A PropertyGrid implemented in Ruby on Rails






4.79/5 (4 votes)
Using JQuery UI and minimal Javascript to create a dynamic property grid editor that can be initialized in a fluid programming style or with a minimal DSL.
Get The Source From GitHub
git clone https://github.com/cliftonm/property_grid_demo
The code for this control can now be installed as a gem:
gem install property_grid
and can be downloaded from here:
git clone https://github.com/cliftonm/property_grid
Introduction
I needed a general purpose property grid editor that supported some fancy things like date/time pickers, color pickers, etc., based on record fields known only at runtime (this is ultimately a part of my next installment of the "Spider UI" article series.) There's a snazzy Javascript-based property grid here, but I wanted something that was minimally Javascript and more Ruby-on-Rails'ish. I also wanted a server-side control that could interface well with record field types and that would dynamically generate the grid based on schema information like table fields.
I have put together is a set of classes to facilitate building the content of a property grid control on the server-side. You will note that I opted for actual classes and a "fluid" programming style, but if you don't like the way the actual implementation looks using a "fluid" technique, I have also put together a very minimal internal Domain Specific Language (DSL) that you can use instead -- basically just method calls that hide (using static data) the internal management of building the property grid instance.
As in my previous articles, I will be using Sass and Slim scripting for the CSS and HTML markup.
Supporting Classes
There are several supporting classes:
- PropertyGrid - the container for the groups and group properties
- Group - a group of properties
- GroupProperty - a property within a group
Class PropertyGrid
# A PropertyGrid container # A property grid consists of property groups. class PropertyGrid attr_accessor :groups def initialize @groups = [] end # Give a group name, creates a group. def add_group(name) group = Group.new group.name = name @groups << group yield(group) # yields to block creating group properties self # returns the PropertyGrid instance end end
There are two important points to this class:
- Because
add_group
executesyield(group)
, the caller can provide a block for adding group properties. - Because
add_group
returnsself
, the caller can continue, in fluid programming style, to add more groups.
Thus, we can write code like this:
@property_grid = PropertyGrid.new(). add_group('Text Input') do |group| # add group properties here. end. #<---- note this syntax add_group('Date and Time Pickers') do |group| # add group properties here. end
Notice the "dot": end.
- because add_group
returns self
after the yield, we can use
a fluid programming style to continue adding groups.
Class Group
# Defines a PropertyGrid group # A group has a name and a collection of properties. class PropertyGroup attr_accessor :name attr_accessor :properties def initialize @name = nil @properties = [] end def add_property(var, name, property_type = :string, collection = nil) group_property = GroupProperty.new(var, name, property_type, collection) @properties << group_property self end end
A group has a name and manages a collection of properties. The
add_property
class returns self
, so again we can use a fluid notation:
group.add_property(:prop_c, 'Date', :date). add_property(:prop_d, 'Time', :time). add_property(:prop_e, 'Date/Time', :datetime)
Notice the "dot" after each call to add_property
, allowing us to call
add_property
again, operating on the same group instance.
Nothing about this is stopping us from using more idiomatic Ruby syntax, for example:
group.properties << GroupProperty.new(:prop_c, 'Date', :date) << GroupProperty.new(:prop_d, 'Time', :time) << GroupProperty.new(:prop_e, "Date/Time", :datetime)
Class GroupProperty
This class is the container for the actual property:
include PropertyGridHelpers class GroupProperty attr_accessor :property_var attr_accessor :property_name attr_accessor :property_type attr_accessor :property_collection # some of these use jquery: http://jqueryui.com/ def initialize(var, name, property_type, collection = nil) @property_var = var @property_name = name @property_type = property_type @property_collection = collection end def get_input_control form_type = get_property_type_map[@property_type] raise "Property '#{@property_type}' is not mapped to an input control" if form_type.nil? erb = get_erb(form_type) erb end end
I will discuss what get_erb
does later.
Note that three fields are required:
- The symbolic name of the model's property
- The display text of the property
- The property type
Optionally, a collection can be passed in, which supports dropdown controls. The collection can either be a simple array:
['Apples', 'Oranges', 'Pears']
or a "record", implementing id
and name
attributes, for example:
# A demo of using id and name in a combo box class ARecord attr_accessor :id attr_accessor :name def initialize(id, name) @id = id; @name = name end end @records = [ ARecord.new(1, 'California'), ARecord.new(2, 'New York'), ARecord.new(3, 'Rhode Island'), ]
which is suitable for collections of ActiveRecord objects.
Class ControlType
This class is a container for the information necessary to render a web control:
class ControlType attr_accessor :type_name attr_accessor :class_name def initialize(type_name, class_name = nil) @type_name = type_name @class_name = class_name end end
This is very basic - it's just the type name and an optional class name. At the moment, the class name is used just for jQuery controls.
Defining Property Types
Property types are defined in property_grid_helpers.rb - this is a simply
function that returns an array of hashes of type => ControlType
.
def get_property_type_map { string: ControlType.new('text_field'), text: ControlType.new('text_area'), boolean: ControlType.new('check_box'), password: ControlType.new('password_field'), date: ControlType.new('datepicker'), datetime: ControlType.new('text_field', 'jq_dateTimePicker'), time: ControlType.new('text_field', 'jq_timePicker'), color: ControlType.new('text_field', 'jq_colorPicker'), list: ControlType.new('select'), db_list: ControlType.new('select') } end
It is here that you would extend or change the specification for how types map to web queries. Obviously you're not limited to using jQuery controls.
What Would a DSL Implementation Look Like?
Let's see what it would look like if I wrote the property grid container objects as a DSL. If you're interested, there's a great tutorial on writing internal DSL's in Ruby here, and what I'm doing should look very similar. Basically, DSL's use a builder pattern, and if you're interested in design patterns in Ruby, here's a good tutorial.
What we want is to be able to declare a property grid instance as if it were part of the Ruby language. So I'll start with:
@property_grid = new_property_grid group 'Text Input' group_property 'Text', :prop_a group_property 'Password', :prop_b, :password group 'Date and Time Pickers' group_property 'Date', :prop_c, :date group_property 'Time', :prop_d, :date group_property 'Date/Time', :prop_e, :datetime group 'State' group_property 'Boolean', :prop_f, :boolean group 'Miscellaneous' group_property 'Color', :prop_g, :color group 'Lists' group_property 'Basic List', :prop_h, :list, ['Apples', 'Oranges', 'Pears'] group_property 'ID - Name List', :prop_i, :db_list, @records
The implementation consists of three methods:
- new_property_grid
- group
- property
that are essentially factory patterns for building an instance of the property groups and their properties. The implementation is in a module and leverages our underlying classes:
module PropertyGridDsl def new_property_grid(name = nil) @__property_grid = PropertyGrid.new @__property_grid end def group(name) group = Group.new group.name = name @__property_grid.groups << group group end def group_property(name, var, type = :string, collection = nil) group_property = GroupProperty.new(var, name, type, collection) @__property_grid.groups.last.properties << group_property group_property end end
This implementation takes advantage of the variable @__property_grid
which
maintains the current instance being applied in the DSL script. We don't
use a singleton pattern because we want to allow for multiple instances of
property grids on the same web page.
As Martin Fowler writes here, while an internal DSL can often increase "syntactic noise", a well written DSL should actually decrease "syntactic noise", as this simple DSL does. For example, compare the DSL:
@property_grid = new_property_grid group 'Text Input' group_property 'Text', :prop_a
with a non-DSL implementation:
@property_grid = PropertyGrid.new(). add_group('Text Input') do |group| group.add_property(:prop_a, 'Text'). add_property(:prop_b, 'Password', :password) end
Certainly working with the class implementation, even in its "fluid" form, is noisier than the DSL!
Putting It Together
You will need a view, a controller, and a model to put this all together.
The View
The basic view is straight-forward. Given the model, we instantiate a list control where each list item is itself a table with two columns and one row:
=fields_for @property_grid_record do |f| .property_grid ul - @property_grid.groups.each_with_index do |group, index| li.expanded class="expandableGroup#{index}" = group.name .property_group div class="property_group#{index}" table tr th Property th Value - group.properties.each do |prop| tr td = prop.property_name td.last - # must be processed here so that ERB has the context (the 'self') of the HTML pre-processor. = render inline: ERB.new(prop.get_input_control).result(binding) = javascript_tag @javascript javascript: $(".jq_dateTimePicker").datetimepicker({dateFormat: 'mm/dd/yy', timeFormat: 'hh:mm tt'}); $(".jq_timePicker").timepicker({timeFormat: "hh:mm tt"}); $(".jq_colorPicker").minicolors()
I'm not going to bother showing the CSS that drives the visual presentation of this structure.
Javascript
Note that there are two javascript sections.
One is coded directly in the form to support the jQuery dateTimePicker
,
timePicker
, and the colorPicker
controls.
The other javascript is programmatically generated because it controls whether the property group is collapsed or expanded, which requires unique handlers for each property group. Since this is known only at runtime, the javascript is generated by this function (in property_grid_helpers.rb):
def get_javascript_for_group(index) js = %Q| $(".expandableGroup[idx]").click(function() { var hidden = $(".property_group[idx]").is(":hidden"); // get the value BEFORE making the slideToggle call. $(".property_group[idx]").slideToggle('slow'); // At this point, $(".property_group0").is(":hidden"); // ALWAYS RETURNS FALSE if (!hidden) // Remember, this is state that the div WAS in. { $(".expandableGroup[idx]").removeClass('expanded'); $(".expandableGroup[idx]").addClass('collapsed'); } else { $(".expandableGroup[idx]").removeClass('collapsed'); $(".expandableGroup[idx]").addClass('expanded'); } }); |.gsub('[idx]', index.to_s) js end
The ERB
Note this line from above:
= render inline: ERB.new(prop.get_input_control).result(binding)
This takes ERB code that has been generated programmatically as well, as we
need a control specific to the property type. This is generated by the
function get_erb
which we saw earlier.
# Returns the erb for a given form type. This code handles the construction of the web control that will display # the content of a property in the property grid. # The web page must utilize a field_for ... |f| for this construction to work. def get_erb(form_type) erb = "<%= f.#{form_type.type_name} :#{@property_var}" erb << ", class: '#{form_type.class_name}'" if form_type.class_name.present? erb << ", #{@property_collection}" if @property_collection.present? && @property_type == :list erb << ", options_from_collection_for_select(f.object.records, :id, :name, f.object.#{@property_var})" if @property_collection.present? && @property_type == :db_list erb << "%>" erb end
The Model
We need a model for our property values. In the demo, the model is in property_grid_record.rb:
class PropertyGridRecord < NonPersistedActiveRecord attr_accessor :prop_a attr_accessor :prop_b attr_accessor :prop_c attr_accessor :prop_d attr_accessor :prop_e attr_accessor :prop_f attr_accessor :prop_g attr_accessor :prop_h attr_accessor :prop_i attr_accessor :records def initialize @records = [ ARecord.new(1, 'California'), ARecord.new(2, 'New York'), ARecord.new(3, 'Rhode Island'), ] @prop_a = 'Hello World' @prop_b = 'Password!' @prop_c = '08/19/1962' @prop_d = '12:32 pm' @prop_e = '08/19/1962 12:32 pm' @prop_f = true @prop_g = '#ff0000' @prop_h = 'Pears' @prop_i = 2 end end
All this does is initialize our test data.
The Controller
The controller puts it all together, instantiating the model, specifying the property grid properties and types, and acquiring the programmatically generated javascript:
include PropertyGridDsl include PropertyGridHelpers class DemoPageController < ApplicationController def index initialize_attributes end private def initialize_attributes @property_grid_record = PropertyGridRecord.new @property_grid = define_property_grid @javascript = generate_javascript_for_property_groups(@property_grid) end def define_property_grid grid = new_property_grid group 'Text Input' group_property 'Text', :prop_a group_property 'Password', :prop_b, :password group 'Date and Time Pickers' group_property 'Date', :prop_c, :date group_property 'Time', :prop_d, :date group_property 'Date/Time', :prop_e, :datetime group 'State' group_property 'Boolean', :prop_f, :boolean group 'Miscellaneous' group_property 'Color', :prop_g, :color group 'Lists' group_property 'Basic List', :prop_h, :list, ['Apples', 'Oranges', 'Pears'] group_property 'ID - Name List', :prop_i, :db_list, @property_grid_record.records grid end end
There is also the supporting function (in property_grid_helpers.rb):
def generate_javascript_for_property_groups(grid) javascript = '' grid.groups.each_with_index do |grp, index| javascript << get_javascript_for_group(index) end javascript end
And voila:
Conclusion
Something like this should be easily ported to C# / ASP.NET as well, and I'd be interested to hear from anyone who does so. Otherwise, enjoy and tell me how you've enhanced the concept.