formotion 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +10 -0
- data/Formotion.gemspec +19 -0
- data/README.md +168 -0
- data/Rakefile +11 -0
- data/app/app_delegate.rb +68 -0
- data/examples/KitchenSink/.gitignore +5 -0
- data/examples/KitchenSink/README.md +5 -0
- data/examples/KitchenSink/Rakefile +9 -0
- data/examples/KitchenSink/app/app_delegate.rb +101 -0
- data/examples/KitchenSink/spec/main_spec.rb +9 -0
- data/lib/formotion.rb +6 -0
- data/lib/formotion/base.rb +44 -0
- data/lib/formotion/exceptions.rb +20 -0
- data/lib/formotion/form.rb +173 -0
- data/lib/formotion/form_controller.rb +102 -0
- data/lib/formotion/form_delegate.rb +87 -0
- data/lib/formotion/patch/ui_text_field.rb +158 -0
- data/lib/formotion/row.rb +211 -0
- data/lib/formotion/row_cell_builder.rb +168 -0
- data/lib/formotion/row_type.rb +33 -0
- data/lib/formotion/section.rb +108 -0
- data/lib/formotion/version.rb +3 -0
- data/spec/form_spec.rb +106 -0
- data/spec/row_spec.rb +25 -0
- data/spec/section_spec.rb +20 -0
- metadata +105 -0
@@ -0,0 +1,211 @@
|
|
1
|
+
module Formotion
|
2
|
+
class Row < Formotion::Base
|
3
|
+
PROPERTIES = [
|
4
|
+
# @form.render will contain row's value as the value for this key.
|
5
|
+
:key,
|
6
|
+
# the user's (or configured) value for this row.
|
7
|
+
:value,
|
8
|
+
# set as cell.titleLabel.text
|
9
|
+
:title,
|
10
|
+
# set as cell.detailLabel.text
|
11
|
+
:subtitle,
|
12
|
+
# configures the type of input this is (string, phone, switch, etc)
|
13
|
+
# either Formotion::RowType or a string/symbol representation of one
|
14
|
+
# see row_type.rb
|
15
|
+
:type,
|
16
|
+
|
17
|
+
# The following apply only to text-input fields
|
18
|
+
|
19
|
+
# placeholder text
|
20
|
+
:placeholder,
|
21
|
+
# whether or not the entry field is secure (like a password)
|
22
|
+
:secure,
|
23
|
+
# given by a UIReturnKey___ integer, string, or symbol
|
24
|
+
# EX :default, :google
|
25
|
+
:return_key,
|
26
|
+
# given by a UITextAutocorrectionType___ integer, string, or symbol
|
27
|
+
# EX :yes, :no, :default
|
28
|
+
:auto_correction,
|
29
|
+
# given by a UITextAutocapitalizationType___ integer, string, or symbol
|
30
|
+
# EX :none, :words
|
31
|
+
:auto_capitalization,
|
32
|
+
# field.clearButtonMode; given by a UITextFieldViewMode__ integer, string, symbol
|
33
|
+
# EX :never, :while_editing
|
34
|
+
# DEFAULT is nil, which is used as :while_editing
|
35
|
+
:clear_button]
|
36
|
+
PROPERTIES.each {|prop|
|
37
|
+
attr_accessor prop
|
38
|
+
}
|
39
|
+
BOOLEAN_PROPERTIES = [:secure]
|
40
|
+
SERIALIZE_PROPERTIES = PROPERTIES
|
41
|
+
|
42
|
+
# Reference to the row's section
|
43
|
+
attr_accessor :section
|
44
|
+
|
45
|
+
# Index of the row in the section
|
46
|
+
attr_accessor :index
|
47
|
+
|
48
|
+
# The reuse-identifier used in UITableViews
|
49
|
+
# By default is a stringification of section.index and row.index,
|
50
|
+
# thus is unique per row (bad for memory, to fix later.)
|
51
|
+
attr_accessor :reuse_identifier
|
52
|
+
|
53
|
+
# The following apply only to text-input fields
|
54
|
+
|
55
|
+
# reference to the row's UITextField.
|
56
|
+
attr_reader :text_field
|
57
|
+
|
58
|
+
# callback for what happens when the user
|
59
|
+
# hits the enter key while editing #text_field
|
60
|
+
attr_accessor :on_enter_callback
|
61
|
+
# callback for what happens when the user
|
62
|
+
# starts editing #text_field.
|
63
|
+
attr_accessor :on_begin_callback
|
64
|
+
|
65
|
+
def initialize(params = {})
|
66
|
+
super
|
67
|
+
|
68
|
+
BOOLEAN_PROPERTIES.each {|prop|
|
69
|
+
Formotion::Conditions.assert_nil_or_boolean(self.send(prop))
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
# Makes all ::BOOLEAN_PROPERTIES queriable with an appended ?
|
74
|
+
# these should be done with alias_method but there's currently a bug
|
75
|
+
# in RM which messes up attr_accessors with alias_method
|
76
|
+
# EX
|
77
|
+
# row.editable?
|
78
|
+
# => true
|
79
|
+
# row.checkable?
|
80
|
+
# => nil
|
81
|
+
def method_missing(method, *args, &block)
|
82
|
+
boolean_method = (method.to_s[0..-2]).to_sym
|
83
|
+
if BOOLEAN_PROPERTIES.member? boolean_method
|
84
|
+
return self.send(boolean_method)
|
85
|
+
end
|
86
|
+
super
|
87
|
+
end
|
88
|
+
|
89
|
+
#########################
|
90
|
+
# pseudo-properties
|
91
|
+
|
92
|
+
def index_path
|
93
|
+
NSIndexPath.indexPathForRow(self.index, inSection:self.section.index)
|
94
|
+
end
|
95
|
+
|
96
|
+
def form
|
97
|
+
self.section.form
|
98
|
+
end
|
99
|
+
|
100
|
+
def reuse_identifier
|
101
|
+
@reuse_identifier || "SECTION_#{self.section.index}_ROW_#{self.index}"
|
102
|
+
end
|
103
|
+
|
104
|
+
def next_row
|
105
|
+
# if there are more rows in this section, use that.
|
106
|
+
return self.section.rows[self.index + 1] if self.index < (self.section.rows.count - 1)
|
107
|
+
|
108
|
+
# if there are more sections, then use the first row of that section.
|
109
|
+
return self.section.next_section.rows[0] if self.section.next_section
|
110
|
+
|
111
|
+
nil
|
112
|
+
end
|
113
|
+
|
114
|
+
def previous_row
|
115
|
+
return self.section.rows[self.index - 1] if self.index > 0
|
116
|
+
|
117
|
+
# if there are more sections, then use the first row of that section.
|
118
|
+
return self.section.previous_section.rows[-1] if self.section.previous_section
|
119
|
+
|
120
|
+
nil
|
121
|
+
end
|
122
|
+
|
123
|
+
def editable?
|
124
|
+
Formotion::RowType::TEXT_FIELD_TYPES.member? self.type
|
125
|
+
end
|
126
|
+
|
127
|
+
def submit_button?
|
128
|
+
self.type == Formotion::RowType::SUBMIT
|
129
|
+
end
|
130
|
+
|
131
|
+
def switchable?
|
132
|
+
self.type == Formotion::RowType::SWITCH
|
133
|
+
end
|
134
|
+
|
135
|
+
def checkable?
|
136
|
+
self.type == Formotion::RowType::CHECK
|
137
|
+
end
|
138
|
+
|
139
|
+
#########################
|
140
|
+
# setter overrides
|
141
|
+
def type=(type)
|
142
|
+
@type = Formotion::RowType.for(type)
|
143
|
+
end
|
144
|
+
|
145
|
+
def return_key=(value)
|
146
|
+
@return_key = const_int_get("UIReturnKey", value)
|
147
|
+
end
|
148
|
+
|
149
|
+
def auto_correction=(value)
|
150
|
+
@auto_correction = const_int_get("UITextAutocorrectionType", value)
|
151
|
+
end
|
152
|
+
|
153
|
+
def auto_capitalization=(value)
|
154
|
+
@auto_capitalization = const_int_get("UITextAutocapitalizationType", value)
|
155
|
+
end
|
156
|
+
|
157
|
+
def clear_button=(value)
|
158
|
+
@clear_button = const_int_get("UITextFieldViewMode", value)
|
159
|
+
end
|
160
|
+
|
161
|
+
#########################
|
162
|
+
# setters for callbacks
|
163
|
+
|
164
|
+
def on_enter(&block)
|
165
|
+
self.on_enter_callback = block
|
166
|
+
end
|
167
|
+
|
168
|
+
def on_begin(&block)
|
169
|
+
self.on_begin_callback = block
|
170
|
+
end
|
171
|
+
|
172
|
+
#########################
|
173
|
+
# Methods for making cells
|
174
|
+
# Called in UITableViewDataSource methods
|
175
|
+
# in form_delegate.rb
|
176
|
+
def make_cell
|
177
|
+
cell, text_field = Formotion::RowCellBuilder.make_cell(self)
|
178
|
+
@text_field = text_field
|
179
|
+
cell
|
180
|
+
end
|
181
|
+
|
182
|
+
#########################
|
183
|
+
# Retreiving data
|
184
|
+
def to_hash
|
185
|
+
super
|
186
|
+
end
|
187
|
+
|
188
|
+
private
|
189
|
+
def const_int_get(base, value)
|
190
|
+
return value if value.is_a? Integer
|
191
|
+
value = value.to_s.camelize
|
192
|
+
Kernel.const_get("#{base}#{value}")
|
193
|
+
end
|
194
|
+
|
195
|
+
# Looks like RubyMotion adds UIKit constants
|
196
|
+
# at compile time. If you don't use these
|
197
|
+
# directly in your code, they don't get added
|
198
|
+
# to Kernel and const_int_get crashes.
|
199
|
+
def load_constants_hack
|
200
|
+
[UITextAutocapitalizationTypeNone, UITextAutocapitalizationTypeWords,
|
201
|
+
UITextAutocapitalizationTypeSentences,UITextAutocapitalizationTypeAllCharacters,
|
202
|
+
UITextAutocorrectionTypeNo, UITextAutocorrectionTypeYes, UITextAutocorrectionTypeDefault,
|
203
|
+
UIReturnKeyDefault, UIReturnKeyGo, UIReturnKeyGoogle, UIReturnKeyJoin,
|
204
|
+
UIReturnKeyNext, UIReturnKeyRoute, UIReturnKeySearch, UIReturnKeySend,
|
205
|
+
UIReturnKeyYahoo, UIReturnKeyDone, UIReturnKeyEmergencyCall,
|
206
|
+
UITextFieldViewModeNever, UITextFieldViewModeAlways, UITextFieldViewModeWhileEditing,
|
207
|
+
UITextFieldViewModeUnlessEditing
|
208
|
+
]
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
@@ -0,0 +1,168 @@
|
|
1
|
+
#################
|
2
|
+
#
|
3
|
+
# Formotion::RowCellBuilder
|
4
|
+
# RowCellBuilder handles taking Formotion::Rows
|
5
|
+
# and configuring UITableViewCells based on their properties.
|
6
|
+
#
|
7
|
+
#################
|
8
|
+
module Formotion
|
9
|
+
class RowCellBuilder
|
10
|
+
# The new UITextField in a UITableViewCell
|
11
|
+
# will be assigned this tag, if applicable.
|
12
|
+
TEXT_FIELD_TAG=1000
|
13
|
+
|
14
|
+
# PARAMS row.is_a? Formotion::Row
|
15
|
+
# RETURNS [cell configured to that row, a UITextField for that row if applicable or nil]
|
16
|
+
def self.make_cell(row)
|
17
|
+
cell, text_field = nil
|
18
|
+
|
19
|
+
cell = UITableViewCell.alloc.initWithStyle(UITableViewCellStyleSubtitle, reuseIdentifier:row.reuse_identifier)
|
20
|
+
|
21
|
+
cell.accessoryType = UITableViewCellAccessoryNone
|
22
|
+
cell.textLabel.text = row.title
|
23
|
+
cell.detailTextLabel.text = row.subtitle
|
24
|
+
|
25
|
+
if row.submit_button?
|
26
|
+
make_submit_cell(row, cell)
|
27
|
+
elsif row.switchable?
|
28
|
+
make_switch_cell(row, cell)
|
29
|
+
elsif row.checkable?
|
30
|
+
make_check_cell(row, cell)
|
31
|
+
elsif row.editable?
|
32
|
+
text_field = make_text_field(row, cell)
|
33
|
+
end
|
34
|
+
[cell, text_field]
|
35
|
+
end
|
36
|
+
|
37
|
+
# This is actually called whenever again cell is checked/unchecked
|
38
|
+
# in the UITableViewDelegate callbacks. So (for now) don't
|
39
|
+
# instantiate long-lived objects in them.
|
40
|
+
# Maybe that logic should be moved elsewhere?
|
41
|
+
def self.make_check_cell(row, cell)
|
42
|
+
cell.accessoryType = row.value ? UITableViewCellAccessoryCheckmark : UITableViewCellAccessoryNone
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.make_switch_cell(row, cell)
|
46
|
+
cell.selectionStyle = UITableViewCellSelectionStyleNone
|
47
|
+
switchView = UISwitch.alloc.initWithFrame(CGRectZero)
|
48
|
+
cell.accessoryView = switchView
|
49
|
+
switchView.setOn(row.value || false, animated:false)
|
50
|
+
switchView.when(UIControlEventValueChanged) do
|
51
|
+
row.value = switchView.isOn
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Does a clever little trick to override #layoutSubviews
|
56
|
+
# for just this one UITableViewCell object, in order to
|
57
|
+
# center it's labels horizontally.
|
58
|
+
def self.make_submit_cell(row, cell)
|
59
|
+
cell.class.send(:alias_method, :old_layoutSubviews, :layoutSubviews)
|
60
|
+
cell.instance_eval do
|
61
|
+
def layoutSubviews
|
62
|
+
old_layoutSubviews
|
63
|
+
|
64
|
+
center = lambda {|frame, dimen|
|
65
|
+
((self.frame.size.send(dimen) - frame.size.send(dimen)) / 2.0)
|
66
|
+
}
|
67
|
+
|
68
|
+
self.textLabel.center = CGPointMake(self.frame.size.width / 2 - 10, self.textLabel.center.y)
|
69
|
+
self.detailTextLabel.center = CGPointMake(self.frame.size.width / 2 - 10, self.detailTextLabel.center.y)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Configures the cell to have a new UITextField
|
75
|
+
# which is used to enter data. Consists of
|
76
|
+
# 1) setting up that field with the appropriate properties
|
77
|
+
# specified by `row` 2) configures the callbacks on the field
|
78
|
+
# to call any callbacks `row` listens for.
|
79
|
+
# Also does the layoutSubviews swizzle trick
|
80
|
+
# to size the UITextField so it won't bump into the titleLabel.
|
81
|
+
def self.make_text_field(row, cell)
|
82
|
+
field = UITextField.alloc.initWithFrame(CGRectZero)
|
83
|
+
field.tag = TEXT_FIELD_TAG
|
84
|
+
|
85
|
+
field.placeholder = row.placeholder
|
86
|
+
field.text = row.value
|
87
|
+
|
88
|
+
field.clearButtonMode = UITextFieldViewModeWhileEditing
|
89
|
+
field.contentVerticalAlignment = UIControlContentVerticalAlignmentCenter
|
90
|
+
field.textAlignment = UITextAlignmentRight
|
91
|
+
|
92
|
+
case row.type
|
93
|
+
when RowType::EMAIL
|
94
|
+
field.keyboardType = UIKeyboardTypeEmailAddress
|
95
|
+
when RowType::PHONE
|
96
|
+
field.keyboardType = UIKeyboardTypePhonePad
|
97
|
+
when RowType::NUMBER
|
98
|
+
field.keyboardType = UIKeyboardTypeDecimalPad
|
99
|
+
else
|
100
|
+
field.keyboardType = UIKeyboardTypeDefault
|
101
|
+
end
|
102
|
+
|
103
|
+
field.secureTextEntry = true if row.secure?
|
104
|
+
field.returnKeyType = row.return_key || UIReturnKeyNext
|
105
|
+
field.autocapitalizationType = row.auto_capitalization if row.auto_capitalization
|
106
|
+
field.autocorrectionType = row.auto_correction if row.auto_correction
|
107
|
+
field.clearButtonMode = row.clear_button || UITextFieldViewModeWhileEditing
|
108
|
+
|
109
|
+
if row.on_enter_callback
|
110
|
+
field.should_return? do |text_field|
|
111
|
+
if row.on_enter_callback.arity == 0
|
112
|
+
row.on_enter_callback.call
|
113
|
+
elsif row.on_enter_callback.arity == 1
|
114
|
+
row.on_enter_callback.call(row)
|
115
|
+
end
|
116
|
+
false
|
117
|
+
end
|
118
|
+
elsif field.returnKeyType == UIReturnKeyDone
|
119
|
+
field.should_return? do |text_field|
|
120
|
+
text_field.resignFirstResponder
|
121
|
+
false
|
122
|
+
end
|
123
|
+
else
|
124
|
+
field.should_return? do |text_field|
|
125
|
+
if row.next_row && row.next_row.text_field
|
126
|
+
row.next_row.text_field.becomeFirstResponder
|
127
|
+
else
|
128
|
+
text_field.resignFirstResponder
|
129
|
+
end
|
130
|
+
true
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
field.on_begin do |text_field|
|
135
|
+
row.on_begin_callback && row.on_begin_callback.call
|
136
|
+
end
|
137
|
+
|
138
|
+
field.should_begin? do |text_field|
|
139
|
+
row.section.form.active_row = row
|
140
|
+
true
|
141
|
+
end
|
142
|
+
|
143
|
+
field.on_change do |text_field|
|
144
|
+
row.value = text_field.text
|
145
|
+
end
|
146
|
+
|
147
|
+
cell.class.send(:alias_method, :old_layoutSubviews, :layoutSubviews)
|
148
|
+
cell.instance_eval do
|
149
|
+
def layoutSubviews
|
150
|
+
old_layoutSubviews
|
151
|
+
|
152
|
+
# viewWithTag is terrible, but I think it's ok to use here...
|
153
|
+
formotion_field = self.viewWithTag(Formotion::RowCellBuilder::TEXT_FIELD_TAG)
|
154
|
+
formotion_field.sizeToFit
|
155
|
+
|
156
|
+
field_frame = formotion_field.frame
|
157
|
+
field_frame.origin.x = self.textLabel.frame.origin.x + self.textLabel.frame.size.width + 20
|
158
|
+
field_frame.origin.y = ((self.frame.size.height - field_frame.size.height) / 2.0).round
|
159
|
+
field_frame.size.width = self.frame.size.width - field_frame.origin.x - 20
|
160
|
+
formotion_field.frame = field_frame
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
cell.addSubview(field)
|
165
|
+
field
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Formotion
|
2
|
+
class RowType
|
3
|
+
STRING=0
|
4
|
+
EMAIL=1
|
5
|
+
PHONE=2
|
6
|
+
NUMBER=3
|
7
|
+
SUBMIT=4
|
8
|
+
SWITCH=5
|
9
|
+
CHECK=6
|
10
|
+
STATIC=100
|
11
|
+
|
12
|
+
TYPES = [STRING, EMAIL, PHONE, NUMBER, SUBMIT, SWITCH, CHECK, STATIC]
|
13
|
+
TEXT_FIELD_TYPES=[STRING, EMAIL, PHONE, NUMBER]
|
14
|
+
|
15
|
+
class << self
|
16
|
+
def for(string_or_sym_or_int)
|
17
|
+
type = string_or_sym_or_int
|
18
|
+
|
19
|
+
if type.is_a?(Symbol) or type.is_a? String
|
20
|
+
string = type.to_s.upcase
|
21
|
+
if not const_defined? string
|
22
|
+
raise Formotion::InvalidClassError, "Invalid RowType value #{string_or_sym}"
|
23
|
+
end
|
24
|
+
Formotion::RowType.const_get(string)
|
25
|
+
elsif type.is_a? Integer and TYPES.member? type
|
26
|
+
TYPES[type]
|
27
|
+
else
|
28
|
+
raise Formotion::InvalidClassError, "Attempted row type #{type.inspect} is not a valid RowType."
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
module Formotion
|
2
|
+
class Section < Formotion::Base
|
3
|
+
PROPERTIES = [
|
4
|
+
# Displayed in the section header row
|
5
|
+
:title,
|
6
|
+
# Arranges the section as a 'radio' section,
|
7
|
+
# such that only one row can be checked at a time.
|
8
|
+
:select_one,
|
9
|
+
# IF :select_one is true, then @form.render will contain
|
10
|
+
# the checked row's value as the value for this key.
|
11
|
+
# ELSE it does nothing.
|
12
|
+
:key
|
13
|
+
]
|
14
|
+
PROPERTIES.each {|prop|
|
15
|
+
attr_accessor prop
|
16
|
+
}
|
17
|
+
SERIALIZE_PROPERTIES = PROPERTIES + [:rows]
|
18
|
+
|
19
|
+
# Relationships
|
20
|
+
|
21
|
+
# This section's form
|
22
|
+
attr_accessor :form
|
23
|
+
|
24
|
+
# This section's index in it's form.
|
25
|
+
attr_accessor :index
|
26
|
+
|
27
|
+
def initialize(params = {})
|
28
|
+
super
|
29
|
+
|
30
|
+
Formotion::Conditions.assert_nil_or_boolean(self.select_one)
|
31
|
+
|
32
|
+
rows = params[:rows] || params["rows"]
|
33
|
+
rows && rows.each {|row_hash|
|
34
|
+
row = create_row(row_hash)
|
35
|
+
}
|
36
|
+
end
|
37
|
+
|
38
|
+
def build_row(&block)
|
39
|
+
row = create_row
|
40
|
+
block.call(row)
|
41
|
+
row
|
42
|
+
end
|
43
|
+
|
44
|
+
def create_row(hash = {})
|
45
|
+
row = hash
|
46
|
+
if hash.class == Hash
|
47
|
+
row = Formotion::Row.new(hash)
|
48
|
+
end
|
49
|
+
row.section = self
|
50
|
+
row.index = self.rows.count
|
51
|
+
self.rows << row
|
52
|
+
row
|
53
|
+
end
|
54
|
+
|
55
|
+
#########################
|
56
|
+
# attribute overrides
|
57
|
+
|
58
|
+
def rows
|
59
|
+
@rows ||= []
|
60
|
+
end
|
61
|
+
|
62
|
+
def rows=(rows)
|
63
|
+
rows.each {|row|
|
64
|
+
Formotion::Conditions.assert_class(row, Formotion::Row)
|
65
|
+
}
|
66
|
+
@rows = rows
|
67
|
+
end
|
68
|
+
|
69
|
+
def index=(index)
|
70
|
+
@index = index.to_i
|
71
|
+
end
|
72
|
+
|
73
|
+
#########################
|
74
|
+
# pseudo-properties
|
75
|
+
|
76
|
+
# should be done with alias_method but there's currently a bug
|
77
|
+
# in RM which messes up attr_accessors with alias_method
|
78
|
+
def select_one?
|
79
|
+
self.select_one
|
80
|
+
end
|
81
|
+
|
82
|
+
def next_section
|
83
|
+
# if there are more sections in this form, use that.
|
84
|
+
if self.index < self.form.sections.count - 1
|
85
|
+
return self.form.sections[self.index + 1]
|
86
|
+
end
|
87
|
+
|
88
|
+
nil
|
89
|
+
end
|
90
|
+
|
91
|
+
def previous_section
|
92
|
+
# if there are more sections in this form, use that.
|
93
|
+
if self.index > 0
|
94
|
+
return self.form.sections[self.index - 1]
|
95
|
+
end
|
96
|
+
|
97
|
+
nil
|
98
|
+
end
|
99
|
+
|
100
|
+
#########################
|
101
|
+
# Retreiving data
|
102
|
+
def to_hash
|
103
|
+
h = super
|
104
|
+
h[:rows] = self.rows.collect {|row| row.to_hash}
|
105
|
+
h
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|