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
executes yield(group)
, the caller can provide a block for
adding group properties. - Because
add_group
returns self
, 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:
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.
The advantages are fairly obvious - the resulting script to generate the
property grid is compact and readable. The above DSL is simple - it's effectively nothing more than helper methods
that wrap the details of working with the underlying classes.
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");
$(".property_group[idx]").slideToggle('slow');
if (!hidden)
{
$(".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.