Ruby on Rails 实现的 PropertyGrid






4.79/5 (4投票s)
使用 JQuery UI 和最少的 Javascript 创建一个动态属性网格编辑器,可以以流畅的编程风格或最小的 DSL 进行初始化。

从 GitHub 获取源代码
git clone https://github.com/cliftonm/property_grid_demo
此控件的代码现在可以作为 gem 安装
gem install property_grid
并可从此处下载
git clone https://github.com/cliftonm/property_grid
引言
我需要一个通用的属性网格编辑器,它支持一些高级功能,如日期/时间选择器、颜色选择器等,基于仅在运行时才知道的记录字段(这最终是我“Spider UI”文章系列下一部分的内容)。 这里有一个时髦的基于 Javascript 的属性网格 这里,但我想要一些 Javascript 最少,并且更“Ruby-on-Rails”的东西。 我还想要一个可以与记录字段类型很好地集成的服务器端控件,它会根据表字段等模式信息动态生成网格。
我构建了一组类来方便在服务器端构建属性网格控件的内容。 您会注意到我选择了实际的类和“流畅”的编程风格,但如果您不喜欢实际实现使用“流畅”技术的方式,我还构建了一个非常小的内部领域特定语言 (DSL),您可以使用它代替——基本上只是隐藏(使用静态数据)构建属性网格实例的内部管理的调用方法。
与我之前的文章一样,我将使用 Sass 和 Slim 脚本来编写 CSS 和 HTML 标记。
支持类
有几个支持类
- PropertyGrid - 组和组属性的容器
- Group - 属性组
- GroupProperty - 组内的属性
类 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
此类有两个要点
- 因为 add_group执行yield(group),所以调用者可以提供一个块来添加组属性。
- 因为 add_group返回self,所以调用者可以继续以流畅的编程风格添加更多组。
因此,我们可以编写这样的代码
@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
注意“点”:end. - 因为 add_group 在 yield 之后返回 self,所以我们可以使用流畅的编程风格继续添加组。
类 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
一个组有一个名称并管理一个属性集合。 add_property 类返回 self,所以我们再次可以使用流畅的表示法
group.add_property(:prop_c, 'Date', :date). add_property(:prop_d, 'Time', :time). add_property(:prop_e, 'Date/Time', :datetime)
注意每次调用 add_property 后的“点”,允许我们再次调用 add_property,操作同一个组实例。
这并不能阻止我们使用更符合 Ruby 习惯的语法,例如
group.properties << GroupProperty.new(:prop_c, 'Date', :date) << GroupProperty.new(:prop_d, 'Time', :time) << GroupProperty.new(:prop_e, "Date/Time", :datetime)
类 GroupProperty
此类是实际属性的容器
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: https://jqueryui.jqueryjs.cn/
  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
我稍后会讨论 get_erb 的作用。
请注意,需要三个字段
- 模型属性的符号名称
- 属性的显示文本
- 属性类型
可选地,可以传入一个集合,它支持下拉控件。 集合可以是简单的数组
['Apples', 'Oranges', 'Pears']
或“记录”,实现 id 和 name 属性,例如
# 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'),
]
这适用于 ActiveRecord 对象集合。
类 ControlType
此类是渲染 Web 控件所需信息的容器
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
这非常基础——它只是类型名称和一个可选的类名。 目前,类名仅用于 jQuery 控件。
定义属性类型
属性类型定义在 property_grid_helpers.rb 中——这是一个简单的函数,返回一个 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
您可以在此处扩展或更改类型映射到 Web 查询的规范。 显然,您不限于使用 jQuery 控件。
DSL 实现会是什么样子?
让我们看看如果我将属性网格容器对象写成 DSL 会是什么样子。 如果您有兴趣,这里有一篇关于在 Ruby 中编写内部 DSL 的精彩教程 这里,我正在做的事情应该看起来非常相似。 基本上,DSL 使用 Builder 模式,如果您对 Ruby 中的设计模式感兴趣,这里是一个很好的教程。
我们想要的是能够声明一个属性网格实例,就好像它是 Ruby 语言的一部分一样。 所以我将从
@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
实现包含三个方法
- new_property_grid
- group
- 属性
它们基本上是用于构建属性组及其属性实例的工厂模式。 实现位于一个模块中,并利用我们底层的类
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
此实现利用了 @__property_grid 变量,该变量维护当前在 DSL 脚本中应用的实例。  我们不使用单例模式,因为我们希望允许在同一网页上拥有多个属性网格实例。
正如 Martin Fowler 在 这里所写,虽然内部 DSL 经常会增加“语法噪音”,但一个写得好的 DSL 实际上应该减少“语法噪音”,就像这个简单的 DSL 所做的那样。 例如,比较 DSL
@property_grid = new_property_grid group 'Text Input' group_property 'Text', :prop_a
与非 DSL 实现:
@property_grid = PropertyGrid.new().
  add_group('Text Input') do |group|
    group.add_property(:prop_a, 'Text').
    add_property(:prop_b, 'Password', :password)
  end 
当然,使用类实现,即使是“流畅”的形式,也比 DSL 更嘈杂!
整合
您需要一个视图、一个控制器和一个模型来将所有这些组合在一起。
视图
基本视图很简单。 给定模型,我们实例化一个列表控件,其中每个列表项本身都是一个具有两列和一行的表格
=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()
我将不展示驱动此结构视觉呈现的 CSS。
Javascript
请注意,有两个 Javascript 部分。  一个是直接编码在表单中以支持 jQuery dateTimePicker、timePicker 和 colorPicker 控件。
另一个 Javascript 是以编程方式生成的,因为它控制属性组是折叠还是展开,这需要为每个属性组提供唯一的处理程序。 由于这仅在运行时知道,因此 Javascript 由此函数(在 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 
ERB
请注意上面这一行
= render inline: ERB.new(prop.get_input_control).result(binding)
这会处理 ERB 代码,该代码也是以编程方式生成的,因为我们需要一个特定于属性类型的控件。  这是由我们之前看到的 get_erb 函数生成的。
# 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 
模型
我们需要一个模型来存储我们的属性值。 在演示中,模型位于 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
这只是初始化我们的测试数据。
控制器
控制器将所有内容组合在一起,实例化模型,指定属性网格属性和类型,并获取以编程方式生成的 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
还有一个支持函数(在 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
瞧

结论
像这样的东西应该很容易移植到 C# / ASP.NET,我很乐意听任何这样做的人的反馈。 否则,请尽情享用,并告诉我您是如何改进这个概念的。


