presenting 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (89) hide show
  1. data/LICENSE +20 -0
  2. data/README +10 -0
  3. data/Rakefile +22 -0
  4. data/app/assets/javascript/grid.js +0 -0
  5. data/app/assets/javascript/search.js +13 -0
  6. data/app/assets/stylesheets/details-color.css +7 -0
  7. data/app/assets/stylesheets/details.css +10 -0
  8. data/app/assets/stylesheets/form.css +1 -0
  9. data/app/assets/stylesheets/grid-color.css +71 -0
  10. data/app/assets/stylesheets/grid.css +64 -0
  11. data/app/assets/stylesheets/search-color.css +16 -0
  12. data/app/assets/stylesheets/search.css +45 -0
  13. data/app/controllers/presentation/assets_controller.rb +42 -0
  14. data/app/views/presentations/_details.erb +11 -0
  15. data/app/views/presentations/_field_search.erb +14 -0
  16. data/app/views/presentations/_form.erb +20 -0
  17. data/app/views/presentations/_grid.erb +70 -0
  18. data/app/views/presentations/_search.erb +7 -0
  19. data/config/routes.rb +3 -0
  20. data/lib/presentation/base.rb +48 -0
  21. data/lib/presentation/details.rb +19 -0
  22. data/lib/presentation/field_search.rb +65 -0
  23. data/lib/presentation/form.rb +149 -0
  24. data/lib/presentation/grid.rb +160 -0
  25. data/lib/presentation/search.rb +9 -0
  26. data/lib/presenting/attribute.rb +51 -0
  27. data/lib/presenting/configurable.rb +10 -0
  28. data/lib/presenting/defaults.rb +10 -0
  29. data/lib/presenting/field_set.rb +26 -0
  30. data/lib/presenting/form_helpers.rb +51 -0
  31. data/lib/presenting/helpers.rb +110 -0
  32. data/lib/presenting/sanitize.rb +19 -0
  33. data/lib/presenting/search.rb +185 -0
  34. data/lib/presenting/sorting.rb +87 -0
  35. data/lib/presenting/view.rb +3 -0
  36. data/lib/presenting.rb +9 -0
  37. data/rails/init.rb +12 -0
  38. data/test/assets_test.rb +58 -0
  39. data/test/attribute_test.rb +61 -0
  40. data/test/configurable_test.rb +20 -0
  41. data/test/details_test.rb +68 -0
  42. data/test/field_search_test.rb +102 -0
  43. data/test/field_set_test.rb +46 -0
  44. data/test/form_test.rb +287 -0
  45. data/test/grid_test.rb +219 -0
  46. data/test/helpers_test.rb +72 -0
  47. data/test/presenting_test.rb +15 -0
  48. data/test/rails/app/controllers/application_controller.rb +15 -0
  49. data/test/rails/app/controllers/users_controller.rb +36 -0
  50. data/test/rails/app/helpers/application_helper.rb +3 -0
  51. data/test/rails/app/helpers/users_helper.rb +2 -0
  52. data/test/rails/app/models/user.rb +2 -0
  53. data/test/rails/app/views/layouts/application.html.erb +15 -0
  54. data/test/rails/app/views/users/index.html.erb +10 -0
  55. data/test/rails/app/views/users/new.html.erb +2 -0
  56. data/test/rails/app/views/users/show.html.erb +1 -0
  57. data/test/rails/config/boot.rb +109 -0
  58. data/test/rails/config/database.yml +17 -0
  59. data/test/rails/config/environment.rb +13 -0
  60. data/test/rails/config/environments/development.rb +17 -0
  61. data/test/rails/config/environments/production.rb +24 -0
  62. data/test/rails/config/environments/test.rb +22 -0
  63. data/test/rails/config/locales/en.yml +5 -0
  64. data/test/rails/config/routes.rb +5 -0
  65. data/test/rails/db/development.sqlite3 +0 -0
  66. data/test/rails/db/migrate/20090213085444_create_users.rb +13 -0
  67. data/test/rails/db/migrate/20090213085607_populate_users.rb +13 -0
  68. data/test/rails/db/schema.rb +23 -0
  69. data/test/rails/db/test.sqlite3 +0 -0
  70. data/test/rails/log/development.log +858 -0
  71. data/test/rails/public/404.html +30 -0
  72. data/test/rails/public/422.html +30 -0
  73. data/test/rails/public/500.html +33 -0
  74. data/test/rails/public/javascripts/application.js +2 -0
  75. data/test/rails/public/javascripts/jquery.livequery.min.js +11 -0
  76. data/test/rails/public/javascripts/prototype.js +4320 -0
  77. data/test/rails/script/console +3 -0
  78. data/test/rails/script/dbconsole +3 -0
  79. data/test/rails/script/destroy +3 -0
  80. data/test/rails/script/generate +3 -0
  81. data/test/rails/script/plugin +3 -0
  82. data/test/rails/script/runner +3 -0
  83. data/test/rails/script/server +3 -0
  84. data/test/sanitize_test.rb +15 -0
  85. data/test/search_conditions_test.rb +137 -0
  86. data/test/search_test.rb +30 -0
  87. data/test/sorting_test.rb +63 -0
  88. data/test/test_helper.rb +66 -0
  89. metadata +217 -0
@@ -0,0 +1,149 @@
1
+ module Presentation
2
+ class Form < Base
3
+ # TODO
4
+ # field type extra details?
5
+ # * text
6
+ # * text_area
7
+ # * password
8
+ # * check_box checked/unchecked values
9
+ # * radio (= dropdown) options
10
+ # * dropdown (= radio) options
11
+ # * multi-select options
12
+ # * recordselect url?
13
+ # * calendar constraints
14
+ # * time constraints
15
+ # * date
16
+ # * datetime
17
+ #
18
+ # other
19
+ # - example / description / help text
20
+ # - nested fields
21
+
22
+ # Fields may be grouped. Groups may or may not have names. Here's how:
23
+ #
24
+ # Presentation::Form.new(:groups => [
25
+ # [:a, :b], # creates a nameless group with fields :a and :b
26
+ # {"foo" => [:c, :d]} # creates a group named "foo" with fields :c and :d
27
+ # ])
28
+ #
29
+ # Note that if you don't need groups it'll be simpler to just use fields= instead.
30
+ def groups
31
+ @groups ||= GroupSet.new
32
+ end
33
+ def groups=(args)
34
+ args.each do |group|
35
+ groups << group
36
+ end
37
+ end
38
+
39
+ class GroupSet < Array
40
+ def <<(val)
41
+ if val.is_a? Hash
42
+ opts = {:name => val.keys.first, :fields => val.values.first}
43
+ else
44
+ opts = {:fields => val}
45
+ end
46
+ super Group.new(opts)
47
+ end
48
+ end
49
+
50
+ class Group
51
+ include Presenting::Configurable
52
+
53
+ # a completely optional group name
54
+ attr_accessor :name
55
+
56
+ # the fields in the group
57
+ def fields
58
+ @fields ||= Presenting::FieldSet.new(Field, :name, :type)
59
+ end
60
+ def fields=(args)
61
+ args.each do |field|
62
+ fields << field
63
+ end
64
+ end
65
+ end
66
+
67
+ # Used to define fields in a group-less form.
68
+ def fields
69
+ if groups.empty?
70
+ groups << []
71
+ end
72
+ groups.first.fields
73
+ end
74
+ def fields=(args)
75
+ args.each do |field|
76
+ fields << field
77
+ end
78
+ end
79
+
80
+ # The url where the form posts. May be anything that url_for accepts, including
81
+ # a set of records.
82
+ def url
83
+ @url ||= presentable
84
+ end
85
+ attr_writer :url
86
+
87
+ # What method the form should use to post. Should default intelligently enough from
88
+ # the presentable. Not sure what use case would require it being set manually.
89
+ def method
90
+ @method ||= presentable.new_record? ? :post : :put
91
+ end
92
+ attr_writer :method
93
+
94
+ # the text on the submit button
95
+ def button
96
+ @button ||= presentable.new_record? ? 'Create' : 'Update'
97
+ end
98
+ attr_writer :button
99
+
100
+ # a passthrough for form_for's html. useful for classifying a form for ajax behavior (e.g. :html => {:class => 'ajax'})
101
+ attr_accessor :html
102
+
103
+ class Field
104
+ include Presenting::Configurable
105
+
106
+ # the display label of the field
107
+ def label
108
+ @label ||= name.to_s.titleize
109
+ end
110
+ attr_writer :label
111
+
112
+ # the parameter name of the field
113
+ attr_accessor :name
114
+
115
+ # where the value for this field comes from.
116
+ # - String: a fixed value
117
+ # - Symbol: a method on the record (no arguments)
118
+ # - Proc: a custom block that accepts the record as an argument
119
+ def value
120
+ @value ||= name.to_sym
121
+ end
122
+ attr_writer :value
123
+
124
+ def value_from(obj) #:nodoc:
125
+ v = case value
126
+ when Symbol: obj.send(value)
127
+ when String: value
128
+ when Proc: value.call(obj)
129
+ end
130
+ end
131
+
132
+ # the widget type for the field. use type_options to pass arguments to the widget.
133
+ def type
134
+ @type ||= :string
135
+ end
136
+ attr_writer :type
137
+
138
+ # unrestricted options storage for the widget type. this could be a list of options for a select, or extra configuration for a calendar widget.
139
+ attr_accessor :type_options
140
+ end
141
+
142
+ def iname; :form end
143
+
144
+ delegate :request_forgery_protection_token, :allow_forgery_protection, :to => :controller
145
+ def protect_against_forgery? #:nodoc:
146
+ allow_forgery_protection && request_forgery_protection_token
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,160 @@
1
+ module Presentation
2
+ # TODO: ability to render a hash
3
+ # TODO: custom css classes for rows and/or cells
4
+ # TODO: document or complain for required options -- id and fields
5
+ # TODO: make fields= accept an ActiveRecord::Base.columns array for a default field set
6
+ class Grid < Base
7
+ # The id for this presentation. Required.
8
+ attr_accessor :id
9
+
10
+ # The display title for this presentation. Will default based on the id.
11
+ attr_writer :title
12
+ def title
13
+ @title ||= self.id.titleize
14
+ end
15
+
16
+ # Paradigm Example:
17
+ # Grid.new(:fields => [
18
+ # :email,
19
+ # {"Full Name" => proc{|r| [r.first_name, r.last_name].join(' ')}},
20
+ # {"Roles" => {:value => :roles, :type => :collection}}
21
+ # ])
22
+ #
23
+ # Is equivalent to:
24
+ # g = Grid.new
25
+ # g.fields << :email
26
+ # g.fields << {"Full Name" => proc{|r| [r.first_name, r.last_name].join(' ')},
27
+ # g.fields << {"Roles" => {:value => :roles, :type => :collection}}
28
+ def fields=(args)
29
+ args.each do |field|
30
+ self.fields << field
31
+ end
32
+ end
33
+
34
+ def fields
35
+ @fields ||= Presenting::FieldSet.new(Field, :name, :value)
36
+ end
37
+
38
+ def colspan
39
+ @colspan ||= fields.size + (record_links.empty? ? 0 : 1)
40
+ end
41
+
42
+ def iname; :grid end
43
+
44
+ class Field < Presenting::Attribute
45
+ # Defines how this field sorts. This means two things:
46
+ # 1. whether it sorts
47
+ # 2. what name it uses to sort
48
+ #
49
+ # Examples:
50
+ #
51
+ # # The field is sortable and assumes the sort_name of "first_name".
52
+ # # This is the default.
53
+ # Field.new(:sortable => true, :name => "First Name")
54
+ #
55
+ # # The field is sortable and assumes the sort_name of "first".
56
+ # Field.new(:sortable => 'first', :name => 'First Name')
57
+ #
58
+ # # The field is unsortable.
59
+ # Field.new(:sortable => false)
60
+ def sortable=(val)
61
+ @sort_name = case val
62
+ when TrueClass: self.id
63
+ when FalseClass, NilClass: nil
64
+ else val.to_s
65
+ end
66
+ end
67
+
68
+ # if the field is sortable at all
69
+ def sortable?
70
+ self.sortable = Presenting::Defaults.grid_is_sortable unless defined? @sort_name
71
+ !@sort_name.blank?
72
+ end
73
+
74
+ attr_reader :sort_name
75
+
76
+ # is this field sorted in the given request?
77
+ def is_sorted?(request)
78
+ @is_sorted ||= if sortable? and sorting = request.query_parameters["sort"] and sorting[sort_name]
79
+ sorting[sort_name].to_s.match(/desc/i) ? 'desc' : 'asc'
80
+ else
81
+ false
82
+ end
83
+ end
84
+
85
+ # for the view -- modifies the current request such that it would sort this field.
86
+ def sorted_url(request)
87
+ if current_direction = is_sorted?(request)
88
+ next_direction = current_direction == 'desc' ? 'asc' : 'desc'
89
+ else
90
+ next_direction = 'desc'
91
+ end
92
+ request.path + '?' + request.query_parameters.merge("sort" => {sort_name => next_direction}).to_param
93
+ end
94
+
95
+ ##
96
+ ## Planned
97
+ ##
98
+
99
+ # TODO: discover "type" from data class (ActiveRecord) if available
100
+ # TODO: decorate a Hash object so type is specifiable there as well
101
+ # PLAN: type should determine how a field renders. custom types for custom renders. this should be the second option to present().
102
+ # attr_accessor :type
103
+
104
+ # PLAN: a field's description would appear in the header column, perhaps only visibly in a tooltip
105
+ # attr_accessor :description
106
+
107
+ # PLAN: any field may be linked. this would happen after :value and :type.
108
+ # attr_accessor :link
109
+ end
110
+
111
+ # Links are an area where I almost made the mistake of too much configuration. Presentations are configured in the view,
112
+ # and all of the view helpers are available. When I looked at the (simple) configuration I was building and realized that
113
+ # I could just as easily take the result of link_to, well, I felt a little silly.
114
+ #
115
+ # Compare:
116
+ #
117
+ # @grid.links = [
118
+ # {:name => 'Foo', :url => foo_path, :class => 'foo'}
119
+ # ]
120
+ #
121
+ # vs:
122
+ #
123
+ # @grid.links = [
124
+ # link_to('Foo', foo_path, :class => 'foo')
125
+ # ]
126
+ #
127
+ # Not only is the second example (the supported example, by the way) shorter and cleaner, it encourages the developer
128
+ # to stay in touch with the Rails internals and therefore discourages a configuration-heavy mindset.
129
+ def links=(set)
130
+ set.compact.each do |link|
131
+ raise ArgumentError, "Links must be strings, such as the output of link_to()." unless link.is_a?(String)
132
+ links << link
133
+ end
134
+ end
135
+ def links
136
+ @links ||= []
137
+ end
138
+
139
+ # Like links, except the link will appear for each record. This means that the link must be a block that accepts the
140
+ # record as its argument. For example:
141
+ #
142
+ # @grid.record_links = [
143
+ # proc{|record| link_to("Foo", foo_path(record), :class => 'foo') }
144
+ # ]
145
+ #
146
+ def record_links=(set)
147
+ set.compact.each do |link|
148
+ raise ArgumentError, "Record links must be blocks that accept the record as an argument." unless link.respond_to?(:call) and link.arity == 1
149
+ record_links << link
150
+ end
151
+ end
152
+ def record_links
153
+ @record_links ||= []
154
+ end
155
+
156
+ def paginate?
157
+ defined? WillPaginate and presentable.is_a?(WillPaginate::Collection)
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,9 @@
1
+ module Presentation
2
+ class Search < Base
3
+ def iname; :search end
4
+
5
+ def url
6
+ request.path + '?' + request.query_parameters.except("search").to_param
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,51 @@
1
+ module Presenting
2
+ # represents an attribute meant to be read from a record
3
+ # used for things like Grid and Details.
4
+ # not intended for things like Form or FieldSearch
5
+ class Attribute
6
+ include Presenting::Configurable
7
+
8
+ def name=(val)
9
+ self.value ||= val # don't lazy define :value, because we're about to typecast here
10
+ if val.is_a? Symbol
11
+ @name = val.to_s.titleize
12
+ else
13
+ @name = val.to_s
14
+ end
15
+ end
16
+ attr_reader :name
17
+
18
+ # The short programmatic name for this field. Can be used as a CSS class, sorting name, etc.
19
+ def id=(val)
20
+ @id = val.to_s
21
+ end
22
+
23
+ def id
24
+ @id ||= name.to_s.underscore.gsub(/[^a-z0-9]/i, '_').gsub(/__+/, '_').sub(/_$/, '')
25
+ end
26
+
27
+ # Where a field's value comes from. Depends heavily on the data type you provide.
28
+ # - String: fixed value (as provided)
29
+ # - Symbol: a method on the record (no arguments)
30
+ # - Proc: a custom block that accepts the record as an argument
31
+ attr_accessor :value
32
+
33
+ def value_from(obj) #:nodoc:
34
+ case value
35
+ when Symbol: obj.is_a?(Hash) ? obj[value] : obj.send(value)
36
+ when String: value
37
+ when Proc: value.call(obj)
38
+ end
39
+ end
40
+
41
+ # whether html should be sanitize. right now this actually means html escaping.
42
+ # consider: by default, do not sanitize if value is a String?
43
+ attr_writer :sanitize
44
+ def sanitize?
45
+ unless defined? @sanitize
46
+ @sanitize = Presenting::Defaults.sanitize_fields
47
+ end
48
+ @sanitize
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,10 @@
1
+ module Presenting
2
+ module Configurable
3
+ def initialize(options = {}, &block)
4
+ options.each do |k, v|
5
+ self.send("#{k}=", v)
6
+ end
7
+ yield self if block_given?
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ module Presenting::Defaults
2
+ # Whether the columns in the Grid should be sortable by default
3
+ mattr_accessor :grid_is_sortable
4
+ self.grid_is_sortable = true
5
+
6
+ # Whether fields should be sanitized by default
7
+ mattr_accessor :sanitize_fields
8
+ self.sanitize_fields = true
9
+ end
10
+
@@ -0,0 +1,26 @@
1
+ class Presenting::FieldSet < Array
2
+ def initialize(field_class, primary, secondary)
3
+ @klass = field_class
4
+ @primary_attribute = primary
5
+ @secondary_attribute = secondary
6
+ end
7
+
8
+ def <<(field)
9
+ if field.is_a? Hash
10
+ k, v = *field.to_a.first
11
+ opts = v.is_a?(Hash) ? v : {@secondary_attribute => v}
12
+ opts[@primary_attribute] = k
13
+ else
14
+ opts = {@primary_attribute => field}
15
+ end
16
+ super @klass.new(opts)
17
+ end
18
+
19
+ def [](key)
20
+ detect{|i| i.send(@primary_attribute) == key}
21
+ end
22
+
23
+ def []=(key, val)
24
+ self << {key => val}
25
+ end
26
+ end
@@ -0,0 +1,51 @@
1
+ module Presenting::FormHelpers
2
+ def present(field)
3
+ send("present_#{field.type}_input", field)
4
+ end
5
+
6
+ def present_readonly_input(field)
7
+ text_field field.name, :disabled => true, :value => field.value_from(object)
8
+ end
9
+
10
+ def present_string_input(field)
11
+ text_field field.name, :value => field.value_from(object)
12
+ end
13
+
14
+ def present_hidden_input(field)
15
+ hidden_field field.name, :value => field.value_from(object)
16
+ end
17
+
18
+ def present_text_input(field)
19
+ text_area field.name, :value => field.value_from(object)
20
+ end
21
+
22
+ def present_password_input(field)
23
+ password_field field.name
24
+ end
25
+
26
+ def present_boolean_input(field)
27
+ check_box field.name
28
+ end
29
+
30
+ def present_dropdown_input(field)
31
+ view.select_tag "#{object_name}[#{field.name}]", view.options_for_select(field.type_options, object.send(field.name))
32
+ end
33
+ alias_method :present_select_input, :present_dropdown_input
34
+
35
+ def present_multi_select_input(field)
36
+ view.select_tag "#{object_name}[#{field.name}][]", view.options_for_select(field.type_options, object.send(field.name)), :multiple => true
37
+ end
38
+
39
+ def present_radios_input(field)
40
+ field.type_options.collect do |(display, value)|
41
+ label("#{field.name}_#{value}", display) +
42
+ radio_button(field.name, value)
43
+ end.join
44
+ end
45
+
46
+ private
47
+
48
+ def view
49
+ @template
50
+ end
51
+ end
@@ -0,0 +1,110 @@
1
+ module Presenting
2
+ module Helpers
3
+ def presentation_stylesheets(*args)
4
+ stylesheet_link_tag presentation_stylesheet_path(args.sort.join(','))
5
+ end
6
+
7
+ def presentation_javascript(*args)
8
+ javascript_include_tag presentation_javascript_path(args.sort.join(','))
9
+ end
10
+
11
+ def present(*args, &block)
12
+ options = args.length > 1 ? args.extract_options! : {}
13
+
14
+ if args.first.is_a? Symbol
15
+ object, presentation = nil, args.first
16
+ else
17
+ object, presentation = args.first, args.second
18
+ end
19
+
20
+ if presentation
21
+ klass = "Presentation::#{presentation.to_s.camelcase}".constantize rescue nil
22
+ if klass
23
+ instance = klass.new(options, &block)
24
+ instance.presentable = object
25
+ instance.controller = controller
26
+ instance.render
27
+ elsif respond_to?(method_name = "present_#{presentation}")
28
+ send(method_name, object, options)
29
+ else
30
+ raise ArgumentError, "unknown presentation `#{presentation}'"
31
+ end
32
+ elsif object.respond_to?(:loaded?) # AssociationProxy
33
+ present_association(object, options)
34
+ else
35
+ present_by_class(object, options)
36
+ end
37
+ end
38
+
39
+ # presents a text search widget for the given field (a Presentation::FieldSearch::Field, probably)
40
+ def present_text_search(field, options = {})
41
+ current_value = (params[:search][field.param][:value] rescue nil)
42
+ text_field_tag options[:name], h(current_value)
43
+ end
44
+
45
+ # presents a checkbox search widget for the given field (a Presentation::FieldSearch::Field, probably)
46
+ def present_checkbox_search(field, options = {})
47
+ current_value = (params[:search][field.param][:value] rescue nil)
48
+ check_box_tag options[:name], '1', current_value.to_s == '1'
49
+ end
50
+
51
+ # presents a dropdown/select search widget for the given field (a Presentation::FieldSearch::Field, probably)
52
+ def present_dropdown_search(field, options = {})
53
+ current_value = (params[:search][field.param][:value] rescue nil)
54
+ select_tag options[:name], options_for_select(normalize_dropdown_options_to_strings(field.options), current_value)
55
+ end
56
+
57
+ protected
58
+
59
+ # We want to normalize the value elements of the dropdown options to strings so that
60
+ # they will match against params[:search].
61
+ #
62
+ # Need to handle the three different dropdown options formats:
63
+ # * array of strings
64
+ # * array of arrays
65
+ # * hash
66
+ def normalize_dropdown_options_to_strings(options)
67
+ options.to_a.map do |element|
68
+ if element.is_a? String
69
+ [element, element]
70
+ else
71
+ [element.first, element.last.to_s]
72
+ end
73
+ end
74
+ end
75
+
76
+ # TODO: special handling for associations (displaying activerecords)
77
+ def present_association(object, options = {})
78
+ present_by_class(object, options)
79
+ end
80
+
81
+ def present_by_class(object, options = {})
82
+ case object
83
+ when Array
84
+ content_tag "ol" do
85
+ object.collect do |i|
86
+ content_tag "li", present(i, options)
87
+ end.join.html_safe
88
+ end
89
+
90
+ when Hash
91
+ # sort by keys
92
+ content_tag "dl" do
93
+ object.keys.sort.collect do |k|
94
+ content_tag("dt", k) +
95
+ content_tag("dd", present(object[k], options))
96
+ end.join.html_safe
97
+ end
98
+
99
+ when TrueClass, FalseClass
100
+ object ? "True" : "False"
101
+
102
+ when Date, Time, DateTime
103
+ l(object, :format => :default)
104
+
105
+ else
106
+ options[:h] ? h(object.to_s) : object.to_s
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,19 @@
1
+ module Presenting::Sanitize
2
+ class << self
3
+ include ERB::Util
4
+
5
+ # escape but preserve Arrays and Hashes
6
+ def h(val)
7
+ case val
8
+ when Array
9
+ val.map{|i| h(i)}
10
+
11
+ when Hash
12
+ val.clone.each{|k, v| val[h(k)] = h(v)}
13
+
14
+ else
15
+ html_escape(val)
16
+ end
17
+ end
18
+ end
19
+ end