multi_model_wizard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a50b7f01a93c84a15b78ce62f4b6f9269c44424f6d72b893c51145ad50cde5b0
4
+ data.tar.gz: d567b88c92d7b6fcf8d1e1dc45ab546a151c65d52ae3fb1bf62688cefae62f34
5
+ SHA512:
6
+ metadata.gz: 40eed0735fade293059f9501b35b1567da14bd953f27acef37a370a0bccd8bae5c135c32774c80332359b04ad1038cca127739ed26b1a8e11fe79cf1637882ea
7
+ data.tar.gz: b7644a0d5f212951ce1019d838463cd1dbe852c506a7e2a85433ccfaef6e440279190286891fd25647af5680138cf53050fe42a270ff94bbe1d29fdc4b8345b8
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2023-02-14
4
+
5
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ gem "rake", "~> 13.0"
8
+ gem "rspec", "~> 3.0"
data/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # MultiModelWizard
2
+
3
+ MultiModelWizard is a way to create and update multiple ActiveRecord models using one form object. Creates a smart object for your wizards or forms. Create one form and form object that can update multiple models with ease.
4
+
5
+ ## Install
6
+
7
+ Add this to your Gemfile
8
+ ```
9
+ $ gem install multi_model_wizard
10
+ ```
11
+
12
+ Then run `bundle install` and you're ready to start
13
+
14
+ ## Use
15
+
16
+ Initialize the gem by creating an initializer file and then configuring your settings:
17
+ ```
18
+ # config/initializers/multi_model_wizard.rb
19
+ #
20
+ MultiModelWizard.configure do |config|
21
+ config.store = { location: :redis, redis_instance: Redis.current }
22
+ config.form_key = 'custom_car_wizard'
23
+ end
24
+ ```
25
+ The above code snippet is an example configuration. You only need to specify an initializer if you want to change the form key
26
+ or if you want to use Redis as the storage location.
27
+
28
+ Note: If your form is going to be over 4kb then you will have to use Redis. Data larger than 4kb can not be stored in cookies (which is the default configuration).
29
+
30
+ Create a new form object that inherits from the base class. Make sure to override the `form_steps`, `create`, and `update`.
31
+ ```
32
+ # form_objects/custom_vehicle_form.rb
33
+ #
34
+ class CustomVehicleForm < FormObject::Base
35
+ cattr_accessor :form_steps do
36
+ %i[
37
+ basic_configuration
38
+ body
39
+ engine
40
+ review
41
+ ].freeze
42
+ end
43
+
44
+ def create
45
+ created = false
46
+ begin
47
+ ActiveRecord::Base.transaction do
48
+ car = Car.new(attributes_for(Car))
49
+ car.parts = car_parts
50
+ car.save!
51
+ end
52
+ created = true
53
+ rescue StandardError => err
54
+ return created
55
+ end
56
+ created
57
+ end
58
+
59
+ def update
60
+ updated = false
61
+ begin
62
+ ActiveRecord::Base.transaction do
63
+ car = Car.find(car_id)
64
+ car.attributes = attributes_for(Car)
65
+ car.parts = car_parts
66
+ car.save!
67
+ end
68
+ updated = true
69
+ rescue StandardError => err
70
+ return updated
71
+ end
72
+ updated
73
+ end
74
+ end
75
+ ```
76
+
77
+ Use form in your controller:
78
+
79
+ ```
80
+ # controllers/vehicle_wizard_controller.rb
81
+ #
82
+ def set_vehicle_form
83
+ @form ||= Wizards::FormObjects::CarForm.create_form do |form|
84
+ form.add_model Manufacturer
85
+ form.add_model Dealer
86
+ form.add_multiple_instance_model model: Part, instances: parts
87
+ form.add_dynamic_model prefix: 'vehicle', model: Vehicle
88
+ form.add_extra_attributes prefix: 'vehicle', attributes: %i[leather_origin], model: Vehicle
89
+ end
90
+ end
91
+
92
+ before_action :set_form_id
93
+
94
+ def set_form_id
95
+ @form_id = params[:vehicle_id]
96
+ end
97
+ ```
98
+
99
+ Setting form_id will allow the gem to differ between a new wizard from (creating new data) and an existing form (editing existing models)
100
+ Note: The `@form_id` should be set equal to whatever model id you are using in your form route.
101
+
102
+ You can now pass `@form` to your form views and start interacting with user input. The attributes of the form are the model attributes prefixed with the model name. Example:
103
+ ```
104
+ dealer = Dealer.new
105
+
106
+ dealer.name
107
+ # => nil
108
+
109
+ @form.dealer_name
110
+ # => nil
111
+ ```
112
+
113
+ In the above example you might have `dealer_name` as an open text field in your form. That attribute would be mapped to the `Dealer` model and get validated using those validations.
114
+ ```
115
+ # views/vehicle_wizard/basic_configuration.rb
116
+ #
117
+ <%= form_for @form, url: my_wizard_path, method: :put do |f| %>
118
+ <%= f.text_field :dealer_name %>
119
+
120
+ <%= link_to 'Back', previous_wizard_path, class: 'btn btn-secondary' %>
121
+ <%= f.submit 'Next', value: 'Next: Configuration', class: 'btn btn-primary'%>
122
+ <% end %>
123
+ ```
124
+
125
+ Models added with `add_multi_model_instance_model` are different. The form attribute to access these will be the pluralized version of the model name.
126
+ ```
127
+ @form.parts
128
+ #=> [{ name: nil, type: nil, size: nil}, { name: nil, type: nil, size: nil}]
129
+ ```
130
+
131
+ These will also be mapped to the model and validated using its validators.
132
+
133
+ `add_dynamic_model` models initialized with the dynamic attribute method will be referenced using whatever you set as the prefix and then the attribute name.
134
+ ```
135
+ @form.vehicle_type
136
+ #=> nil
137
+ ```
138
+
139
+
140
+ ## Development
141
+
142
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
143
+
144
+ To install this gem onto your local machine, run `bundle exec rake install`.
145
+
146
+ ## Contributing
147
+
148
+ ### Content
149
+
150
+ * Write articles
151
+ * Recording screencasts
152
+ * Submit presentations
153
+
154
+ Pull requests are welcome! Feel free to submit bugs as well.
155
+
156
+ 1. Fork it! ( https://github.com/schneems/wicked/fork )
157
+ 2. Create your feature branch: `git checkout -b my-new-feature`
158
+ 3. Commit your changes: `git commit -am 'Add some feature'`
159
+ 4. Push to the branch: `git push origin my-new-feature`
160
+ 5. Create a new Pull Request :D
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,497 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+ require 'multi_model_wizard/dynamic_validation'
5
+
6
+ module FormObject
7
+ class AttributeNameError < StandardError; end
8
+ class Base
9
+ include MultiModelWizard::DynamicValidation
10
+ include ActiveModel::Model
11
+ include ActiveModel::AttributeAssignment
12
+
13
+ class << self
14
+ # Creates a new instance of the form object with all models and configuration
15
+ # @note This is how all forms should be instantiated
16
+ # @param block [Block] this yields to a block with an instance of its self
17
+ # @return form object [FormObjects::Base]
18
+ def create_form
19
+ instance = new
20
+ yield(instance)
21
+ instance.send(:init_attributes)
22
+ instance
23
+ end
24
+
25
+ # Needs to be overridden by child class.
26
+ # @note This method needs to be overridden with an array of symbols
27
+ # @return array [Array]
28
+ def form_steps
29
+ raise NotImplementedError
30
+ end
31
+ end
32
+
33
+ # These are the default atrributes for all form objects
34
+ ATTRIBUTES = %i[
35
+ current_step
36
+ new_form
37
+ ]
38
+
39
+ attr_reader :extra_attributes, :models, :dynamic_models, :multiple_instance_models
40
+
41
+ # WARNING: Light meta programming
42
+ # We will create an attr_accessor for all atributes
43
+ # @note This ATTRIBUTES can be overriden in child classes
44
+ ATTRIBUTES.each { |attribute| attr_accessor attribute }
45
+
46
+ alias new_form? new_form
47
+
48
+ def initialize
49
+ @models = []
50
+ @dynamic_models = []
51
+ @multiple_instance_models = []
52
+ @extra_attributes = []
53
+ @new_form = true
54
+ end
55
+
56
+ # Checks if the form and its attributes are valid
57
+ # @note This method is here because the Wicked gem automagically runs this methdo to move to the next step
58
+ # @note This method needs return a boolean after attempting to create the records
59
+ # @return boolean [Boolean]
60
+ def save
61
+ valid?
62
+ end
63
+
64
+ # Add a custom error message and makes the object invalid
65
+ #
66
+ def invalidate!(error_msg = nil)
67
+ errors.add(:associated_model, error_msg) unless error_msg.nil?
68
+ errors.add(:associated_model, 'could not be properly save')
69
+ end
70
+
71
+ # Boolean method returns if the object is on the first step or not
72
+ # @return boolean [Boolean]
73
+ def first_step?
74
+ return false if current_step.nil?
75
+ return true unless current_step.to_sym
76
+
77
+ form_steps.first == current_step.to_sym
78
+ end
79
+
80
+ # Gets all of the form objects present attributes and returns them in a hash
81
+ # @note This method will only return a key value pair for attributes that are not nil
82
+ # @note It ignores the models arrays, errors, etc.
83
+ # @return hash [ActiveSupport::HashWithIndifferentAccess]
84
+ def attributes
85
+ hash = ActiveSupport::HashWithIndifferentAccess.new
86
+ instance_variables.each_with_object(hash) do |attribute, object|
87
+ next if %i[@errors @validation_context
88
+ @models @dynamic_models
89
+ @multiple_instance_models
90
+ @extra_attributes].include?(attribute)
91
+
92
+ key = attribute.to_s.gsub('@','').to_sym
93
+ object[key] = self.instance_variable_get(attribute)
94
+ end
95
+ end
96
+
97
+ # Returns an list of all attribute names as symbols
98
+ # @return array [Array] of symbol attribute names
99
+ def attribute_keys
100
+ ATTRIBUTES
101
+ end
102
+
103
+ # Returns all of the attributes for a model
104
+ # @note If you dont pass a model to extra attributes they will not show up here
105
+ # @note attributes for model does not work for multiple_instance_models
106
+ # @param model class [ActiveRecord]
107
+ # @return hash [Hash]
108
+ def attributes_for(model)
109
+ model_is_activerecord?(model)
110
+
111
+ hash = ActiveSupport::HashWithIndifferentAccess.new
112
+
113
+ attribute_lookup.each_with_object(hash) do |value, object|
114
+ lookup = value[1]
115
+ form_attribute = value[0]
116
+ object[lookup[:original_method]] = attributes[form_attribute] if lookup[:model] == model.name
117
+ end
118
+ end
119
+
120
+ # Takes a hash of key value pairs and assigns those attributes values to its
121
+ # corresponding methods/instance variables
122
+ # @note If you give it a key that is not a defined method of the class it will simply move on to the next
123
+ # @param hash [Hash]
124
+ # @return self [FormObject] the return value is the instance of the form object with its updated values
125
+ def set_attributes(attributes_hash)
126
+ attributes_hash.each do |pair|
127
+ key = "#{pair[0].to_s}="
128
+ value = pair[1]
129
+ self.send(key, value)
130
+ rescue NoMethodError
131
+ next
132
+ end
133
+ self
134
+ end
135
+
136
+
137
+ # Given n number of atrribute names this method will iterate over each attribute and validate that attribute
138
+ # using the model that the attribute orignated from to validate it
139
+ # @note this method uses a special method #validate_attribute_with_message this method comes from
140
+ # the an DynamicValidation module and is not built in with ActiveRecord
141
+ # @note this method will add the model errors to your object instance and invalidate it.
142
+ # The model errors are using the original attribute names
143
+ # @param symbol names of instance methods [Symbol]
144
+ # @return boolean [Boolean] this method will return true if all attributes are valid and false if not
145
+ def validate_attributes(*attributes)
146
+ raise ArgumentError, 'Attributes must be a Symbol' unless attributes.all? { |x| x.is_a?(Symbol) }
147
+
148
+ attributes.map do |single_attr|
149
+ unless respond_to?(single_attr)
150
+ raise FormObject::AttributeNameError, "#{single_attr.to_s} is not a valid attribute of this form object"
151
+ end
152
+
153
+ original_attribute = attribute_lookup.dig(single_attr.to_sym, :original_method)
154
+ attribute_hash = { "#{original_attribute}": send(single_attr) }
155
+ instance = attribute_lookup.dig(single_attr.to_sym, :model)&.constantize&.new
156
+ instance&.send("#{original_attribute}=", send(single_attr))
157
+ next if instance.nil?
158
+
159
+ validation = validate_attribute_with_message(attribute_hash, model_instance: instance)
160
+ if validation.valid.eql?(false)
161
+ validation.messages.each { |err| errors.add(single_attr.to_sym, err) }
162
+ end
163
+
164
+ validation.valid
165
+ end.compact.all?(true)
166
+ end
167
+
168
+ # Much like #validate_attributes this method will validate the attributes
169
+ # of a instance model using the original model
170
+ # @note this method uses a special method #validate_attribute_with_message this method comes from
171
+ # the an DynamicValidation module and is not built in with ActiveRecord
172
+ # @note this method will add the model errors to your object instance and invalidate it.
173
+ # The model errors are using the original attribute names
174
+ # @param symbol name of instance method [Symbol]
175
+ # @return boolean [Boolean] this method will return true if all attributes are valid and false if not
176
+ def validate_multiple_instance_model(attribute)
177
+ raise ArgumentError, 'Attribute must be a Symbol' unless attribute.is_a?(Symbol)
178
+ unless respond_to?(attribute)
179
+ raise FormObject::AttributeNameError, "#{attribute.to_s} is not a valid attribute of this form object"
180
+ end
181
+
182
+ model_instance = attribute_lookup.dig(attribute.to_sym, :model)&.constantize&.new
183
+ return nil if model_instance.nil?
184
+
185
+ send(attribute).map do |hash_instance|
186
+ hash_instance.map do |key, value|
187
+ model_instance&.send("#{key}=", value)
188
+
189
+ validation = validate_attribute_with_message({ "#{key}": value }, model_instance: model_instance )
190
+ if validation.valid.eql?(false)
191
+ validation.messages.each { |err| errors.add(attribute.to_sym, err) }
192
+ end
193
+ validation.valid
194
+ end
195
+ end.compact.all?(true)
196
+ end
197
+
198
+ # This method should be used when instantiating a new object. It is used to add extra attributes to the
199
+ # form object that may not be accessible from the models passed in.
200
+ # @note to have these attributes validated using the #validate_attributes method you must pass in a model
201
+ # @note model can be an instance or the class
202
+ # @note attributes should be an array of symbols
203
+ # @param prefix is a string that you want to hcae in front of all your extra attributes [String]
204
+ # @param attributes should be an array of symbols [Array]
205
+ # @param model class or model instance [ActiveRecord] this is the class that you want these extra attributes to be related to
206
+ # @return array of all the extra attributes [Array]
207
+ def add_extra_attributes(prefix: nil, attributes:, model: nil )
208
+ if prefix.present?
209
+ raise ArgumentError, 'Prefix must be a String' unless prefix.is_a?(String)
210
+ end
211
+ raise ArgumentError, 'All attributes must be Symbols' unless attributes.all? { |x| x.is_a?(Symbol) }
212
+ model_is_activerecord?(model)
213
+
214
+ hash = {
215
+ prefix: prefix || model_prefix(model),
216
+ attributes: attributes,
217
+ model: model
218
+ }
219
+ extra_attributes << hash
220
+ end
221
+
222
+ # This method should be used when instantiating a new object. It is used to add dynamic models to the
223
+ # form object.
224
+ # Dynamic models are models that share a base class and are of the same family but can vary depending on child class
225
+ # Example: A Truck model, Racecar model, and a Semi model who all have a base class of Vehicle
226
+ # This method allows your form to recieve any of these models and keep the UI and method calls the same.
227
+ # @note model can be an instance or the class
228
+ # @param prefix is a string that you want to hcae in front of all your extra attributes [String]
229
+ # @param model class or model instance [ActiveRecord] this is the class that you want these extra attributes to be related to
230
+ # @return array of all the dynamic models [Array]
231
+ def add_dynamic_model(prefix:, model:)
232
+ raise ArgumentError, 'Prefix must be a String' unless prefix.is_a?(String)
233
+ model_is_activerecord?(model)
234
+
235
+ @dynamic_models << { prefix: prefix, model: instance_of_model(model) }
236
+ end
237
+
238
+ # The add_multiple_instance_model is an instance method that is used for adding ActiveRecord models
239
+ # multiple instance models are models that would be child models in a has_many belongs_to relationship
240
+ # EXAMPLE: Car has_many parts
241
+ # In this example the multiple instance would be parts because a car can have an infinte number of parts
242
+ # @param name of the form object atrribute to retrieve these multiple instances of a model [String]
243
+ # @param model class or instance [ActiveRecord] this is the same model that the instances should be
244
+ # @param instances is an array of ActiveRecord models [Array] these are usually the has_many relation instances
245
+ # @return array of all the multiple instance models models [Array]
246
+ def add_multiple_instance_model(attribute_name: nil, model:, instances: [])
247
+ if attribute_name.present?
248
+ raise ArgumentError, 'Attribute name must be a String' unless attribute_name.is_a?(String)
249
+ end
250
+ model_is_activerecord?(model)
251
+
252
+ attribute_name = attribute_name || model_prefix(model, pluralize: true)
253
+ hash = { attribute_name: attribute_name, model: instance_of_model(model), instances: instances }
254
+
255
+ @multiple_instance_models << hash
256
+ end
257
+
258
+ # The add_model is an instance method that is used for adding ActiveRecord models
259
+ # @param prefix is optional and is used to change the prefix of the models attributes [String] the prefix defaults to the model name
260
+ # @param model class or instance [ActiveRecord] this is the same model that the instances should be
261
+ # @return array of all the models [Array]
262
+ def add_model(model, prefix: nil)
263
+ if prefix.present?
264
+ raise ArgumentError, 'Prefix must be a String' unless prefix.is_a?(String)
265
+ end
266
+ model_is_activerecord?(model)
267
+
268
+ hash = { prefix: prefix || model_prefix(model), model: instance_of_model(model) }
269
+
270
+ @models << hash
271
+ end
272
+
273
+ # This method is used to turn the attributes of the form into a stringified object that resembles json
274
+ # @return form atttrubytes as a strigified hash [Hash]
275
+ def as_json
276
+ instance_variables.each_with_object({}) do |var, obj|
277
+ obj[var.to_s.gsub('@','')] = instance_variable_get(var)
278
+ end.stringify_keys
279
+ end
280
+
281
+ # This method is used to help validate the form object. Use required for step to do step contional validations of attributes
282
+ # @param step is used to compare the current step [Symbol]
283
+ # @return a true or false value if the step give is equal to or smaller in the form_steps [Boolean]
284
+ def required_for_step?(step)
285
+ # note: this line is specific if using the wicked gem
286
+ return true if current_step == 'wicked_finish' || current_step.nil?
287
+
288
+ form_steps.index(step.to_sym) <= form_steps.index(current_step.to_sym)
289
+ end
290
+
291
+ # Persist is used to update or create all of the models from the form object
292
+ # @note the create method and update method that this method use will have to manually implemented by the child class
293
+ # @note the create and update need to return a boolean based on their success or failure to udpate
294
+ # @return the output of the create or update method [Sybmbol]
295
+ def persist!
296
+ new_form ? create : update
297
+ end
298
+
299
+ # Create all of the models from the form object and their realations
300
+ # @note this should be done in an ActiveRecord transaction block
301
+ # @return returns true if the transaction was successfule and false if not[Boolean]
302
+ # EXAMPLE:
303
+ # def create
304
+ # created = false
305
+ # begin
306
+ # ActiveRecord::Base.transaction do
307
+ # car = Car.new(attributes_for(Car))
308
+ # car.parts = car_parts
309
+ # car.save!
310
+ # end
311
+ # created = true
312
+ # rescue StandardError => err
313
+ # return created
314
+ # end
315
+ # created
316
+ # end
317
+ def create
318
+ true
319
+ end
320
+
321
+ # Update all of the models from the form object and their realations
322
+ # @note this should be done in an ActiveRecord transaction block
323
+ # @return returns true if the transaction was successfule and false if not [Boolean]
324
+ # EXAMPLE:
325
+ # def update
326
+ # updated = false
327
+ # begin
328
+ # ActiveRecord::Base.transaction do
329
+ # car = Car.find(car_id)
330
+ # car.attributes = attributes_for(Car)
331
+ # car.parts = car_parts
332
+ # car.save!
333
+ # end
334
+ # updated = true
335
+ # rescue StandardError => err
336
+ # return updated
337
+ # end
338
+ # updated
339
+ # end
340
+ def update
341
+ true
342
+ end
343
+
344
+ private
345
+
346
+ # WARNING: Light meta programming
347
+ # Used to add attributes to the ATTRIBUTES array and also create an attr_accessor for those attributes
348
+ # This is used to add model attributes to the form object
349
+ # @param attribute name [Symbol]
350
+ # @return method name [Sybmbol]
351
+ def add_attribute(attribute_name)
352
+ ATTRIBUTES << attribute_name
353
+ self.class.send(:attr_accessor, attribute_name)
354
+ end
355
+
356
+ # Used on the last step of a form wizard
357
+ # @note this model will invalidate the form object is the persist! method does not return true
358
+ # @return if all models were saved than true will be returned [Boolean]
359
+ def models_persisted?
360
+ persist! ? true : errors.add(:associated_model, 'could not be properly save')
361
+ end
362
+
363
+ # Returns all types of models including, dynamic models, multi instance models, etc
364
+ # @return all models [Array]
365
+ def all_models
366
+ @all_models = (models + dynamic_models + multiple_instance_models).uniq
367
+ end
368
+
369
+ # This method is used to make sure the form object has an attr_accessor for all of the models that
370
+ # were provided. This method is used during the initialization of a new form object instance.
371
+ def init_attributes
372
+ # get all model attributes and prefix them
373
+ all_models.each do |hash|
374
+ prefix = hash[:prefix]
375
+ hash[:model].attributes.keys.each do |key|
376
+ ATTRIBUTES << "#{prefix}_#{key}".to_sym
377
+
378
+ # set attribute history
379
+ set_attribute_history(prefix, key, hash[:model])
380
+ end
381
+ end
382
+
383
+ # add extra attributes
384
+ init_extra_attributes
385
+
386
+ init_multiple_instance_attributes
387
+
388
+ # set attr_accessor
389
+ ATTRIBUTES.uniq.each do |attribute|
390
+ self.class.send(:attr_accessor, attribute)
391
+ end
392
+
393
+ # set any values from the class
394
+ all_models.each do |hash|
395
+ prefix = hash[:prefix] || model_prefix(hash[:model])
396
+ hash[:model].attributes.each do |key, value|
397
+ self.instance_variable_set("@#{prefix}_#{key}", value)
398
+ end
399
+ end
400
+ @models = []
401
+ @extra_attributes = []
402
+ end
403
+
404
+ # This method is used to make sure the form object has an attr_accessor for all of the multiple instance attributes
405
+ # This method is used during the initialization of a new form object instance.
406
+ def init_multiple_instance_attributes
407
+ multiple_instance_models.each do |model_hash|
408
+ add_attribute(model_hash[:attribute_name].to_sym)
409
+ attribute_lookup.merge!(
410
+ "#{model_hash[:attribute_name]}": { original_method: nil, model: instance_of_model(model_hash[:model]).class.name }
411
+ )
412
+
413
+ instances = model_hash[:instances].map { |x| ActiveSupport::HashWithIndifferentAccess.new(x.attributes) }
414
+ self.send("#{model_hash[:attribute_name].to_s}=", instances)
415
+ end
416
+ end
417
+
418
+ # This method is used to make sure the form object has an attr_accessor for all of the extra attributes
419
+ # This method is used during the initialization of a new form object instance.
420
+ def init_extra_attributes
421
+ @extra_attribute_keys = []
422
+ extra_attributes.each do |attr_object|
423
+ attr_object[:attributes].each do |x|
424
+ if attr_object[:prefix]
425
+ ATTRIBUTES << "#{attr_object[:prefix].to_s}_#{x.to_s}".to_sym
426
+ @extra_attribute_keys << "#{attr_object[:prefix].to_s}_#{x.to_s}".to_sym
427
+ else
428
+ ATTRIBUTES << x.to_sym
429
+ @extra_attribute_keys << x.to_sym
430
+ end
431
+
432
+ if attr_object[:model]
433
+ set_attribute_history(attr_object[:prefix], x.to_s, attr_object[:model])
434
+ end
435
+
436
+ # try to set value for extra atrributes
437
+ self.instance_variable_set("@#{@extra_attribute_keys.last}", attr_object[:model].try(x.to_s))
438
+ end
439
+ end
440
+ end
441
+
442
+ # Set attribute history updates the attrube_lookup object
443
+ # @param prefix is the attribute prefix [String] the prefix the form_object gave this attribute
444
+ # @param key is name of the original method from the model [String]
445
+ # @param model ActiveRecord class of the method [ActiveRecord]
446
+ def set_attribute_history(prefix=nil, key, model)
447
+ if prefix
448
+ attribute_lookup.merge!("#{prefix}_#{key}": { original_method: key, model: instance_of_model(model).class.name })
449
+ else
450
+ attribute_lookup.merge!("#{key}": { original_method: key, model: instance_of_model(model).class.name })
451
+ end
452
+ end
453
+
454
+ # This method is reponsible for taking an ActiveRecord class and turning it into snake cased prefix
455
+ # @param pluralize determines if the prefix is going to be plural or not [Boolean]
456
+ # @param model ActiveRecord class [ActiveRecord]
457
+ def model_prefix(model, pluralize: false)
458
+ if pluralize
459
+ instance_of_model(model).class.name.pluralize.gsub('::','_').underscore
460
+ else
461
+ instance_of_model(model).class.name.gsub('::','_').underscore
462
+ end
463
+ end
464
+
465
+ # Instance of model gives the DSL the flexibility to have an instance of an ActiveRecord class or the class it self.
466
+ # This method will take an argument of either an ActiveRecord class instance or class definiton and return the an instance
467
+ # @param model ActiveRecord class or instance [ActiveRecord]
468
+ # @return ActiveRecord model instance [ActiveRecord]
469
+ def instance_of_model(model)
470
+ model.respond_to?(:new) ? model.new : model
471
+ end
472
+
473
+ def class_for(model)
474
+ model.respond_to?(:new) ? model : model.class
475
+ end
476
+
477
+ def model_is_activerecord?(model)
478
+ return if model.nil?
479
+
480
+ class_ancestors = class_for(model).ancestors
481
+ unless class_ancestors.include?(ActiveRecord::Base) || class_ancestors.include?(ActiveRecord::Base)
482
+ raise ArgumentError, 'Model must be an ActiveRecord descendant'
483
+ end
484
+ end
485
+
486
+ # The attribute lookup method is a hash that has the form object attribute as a key and the history of that atrribute as the value
487
+ # EXAMPLE:
488
+ # {
489
+ # car_color: { original_method: 'color', model: 'Car' }
490
+ # }
491
+ #
492
+ # @return hash [Hash]
493
+ def attribute_lookup
494
+ @attribute_lookup ||= {}
495
+ end
496
+ end
497
+ end
@@ -0,0 +1,46 @@
1
+ module MultiModelWizard
2
+ class Config
3
+ # The form key is what is used a the key in the session cookies
4
+ # This can be changed in the intitializer file.
5
+ # This key is also what is used as part of the redis key value pair
6
+ # if redis is configured.
7
+ FORM_KEY = 'multi_model_wizard_form'.freeze
8
+
9
+ attr_accessor :store, :form_key
10
+
11
+ def initialize
12
+ @store = { location: :cookies, redis_instance: nil }
13
+ @form_key = FORM_KEY
14
+ end
15
+
16
+ # The configured redis instance. This is should be set in the initializer.
17
+ # A redis instance is only needed if you are going to use redis to store.
18
+ # Redis is great to use when you have a bigger/longer wizard form.
19
+ # Session cookies max size is 4k, so if the size is over that, consider
20
+ # switching to redis store
21
+ #
22
+ #
23
+ # Session cookies are still used even when using redis as the store location
24
+ # A key and a uuid is stored on the browser session cookie
25
+ # That uuid is used as the key in redis to retrieve the form data to the controller
26
+ def redis_instance
27
+ store[:redis_instance]
28
+ end
29
+
30
+ # Location tells the gem where to put your form data between form steps
31
+ # The default is session cookies in the browser
32
+ def location
33
+ store[:location]
34
+ end
35
+
36
+ # Logical methods to determine where the gem should store form data
37
+ def store_in_redis?
38
+ store[:location] == :redis
39
+ end
40
+
41
+ # Logical methods to determine where the gem should store form data
42
+ def store_in_cookies?
43
+ store[:location] =! :redis
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext/numeric/time'
5
+
6
+ module MultiModelWizard
7
+ module CookieStore
8
+ extend ActiveSupport::Concern
9
+
10
+ EXPIRATION = 1.hour
11
+
12
+ # This method is used to set the session cookie on the browser
13
+ # EXAMPLE:
14
+ # set_signed_cookie(key: 'multi_model_wizard_form', value: { hello: 'world' })
15
+ def set_signed_cookie(attributes)
16
+ cookies.signed[attributes[:key]&.to_sym] = {
17
+ value: attributes[:value],
18
+ expires: attributes[:expires] || EXPIRATION.from_now,
19
+ same_site: attributes[:same_site] || 'None',
20
+ secure: attributes[:secure] || true,
21
+ httponly: attributes[:httponly] || true
22
+ }
23
+ end
24
+
25
+ # This method is used to retrieve the session cookie from the browser
26
+ def get_signed_cookie(key)
27
+ cookies.signed[key.to_sym]
28
+ end
29
+
30
+ # This method is used to delete the session cookie from the browser
31
+ def delete_cookie(key)
32
+ cookies.delete(key.to_sym)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiModelWizard
4
+ module DynamicValidation
5
+ # Validates attributes using the original model and returns boolean for the given attributes
6
+ # @params attributes are the names of the form objects methods [Symbol]
7
+ # @params model_instance is the original model that the form object got the attributes from [ActiveRecord]
8
+ # @returns returns boolean if the model has no errors [Boolean]
9
+ def valid_attribute?(*attributes, model_instance:)
10
+ model_instance.errors.clear
11
+
12
+ attributes.flatten!
13
+ attributes = attributes.first if attributes.first.is_a?(Hash)
14
+
15
+ attributes.each do |attribute, validator_types|
16
+ validators = model_instance.class.validators_on(attribute)
17
+
18
+ if validator_types.present?
19
+ validator_types = Array(validator_types)
20
+ validators.select! { |validator| validator.kind.in?(validator_types) }
21
+ end
22
+
23
+ validators.each { |validator| validator.validate(model_instance) }
24
+ end
25
+
26
+ model_instance.errors.empty?
27
+ end
28
+
29
+ # Validates attributes using the original model and returns a boolean and a message for the given attributes
30
+ # @params attributes are the names of the form objects methods [Symbol]
31
+ # @params model_instance is the original model that the form object got the attributes from [ActiveRecord]
32
+ # @returns returns an object with the valid and messages attributes [OpenStruct]
33
+ def validate_attribute_with_message( *attributes, model_instance:)
34
+ model_instance.errors.clear
35
+
36
+ attributes.flatten!
37
+ attributes = attributes.first if attributes.first.is_a?(Hash)
38
+
39
+ attributes.each do |attribute, value|
40
+ validators = model_instance.class.ancestors.map { |x| x.try(:validators_on, attribute) }.compact.flatten
41
+
42
+ validators.each { |validator| validator.validate(model_instance) }
43
+ end
44
+
45
+ OpenStruct.new(valid: model_instance.errors.empty?, messages: model_instance.errors.full_messages.uniq )
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'multi_model_wizard/cookie_store'
4
+ require 'multi_model_wizard/config'
5
+
6
+ require 'active_support'
7
+ require 'active_support/core_ext/numeric/time'
8
+
9
+ module MultiModelWizard
10
+ module RedisCookieStore
11
+ include ::MultiModelWizard::CookieStore
12
+
13
+ # This method is used to set the form data in redis
14
+ # @note the key of this method is the congifured form key and the uuid for that form session
15
+ # EXAMPLE:
16
+ # set_redis_cache('multi_model_wizard:d5be032f-4863-44e7-87c8-0ec86c85263d', { hello: 'world' })
17
+ def set_redis_cache(key, data, expire: ::MultiModelWizard::CookieStore::EXPIRATION)
18
+ wizard_redis_instance.set(key, data, ex: expire)
19
+ end
20
+
21
+ # This method is used to delete the form data from redis
22
+ def clear_redis_cache(key)
23
+ wizard_redis_instance.del(key)
24
+ end
25
+
26
+ # This method is used to retrieve the form data from redis
27
+ def fetch_redis_cache(key)
28
+ wizard_redis_instance.get(key)
29
+ end
30
+
31
+ private
32
+
33
+ # Reference the redis instance that was passed in from the initializer
34
+ def wizard_redis_instance
35
+ ::MultiModelWizard.configuration.redis_instance
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiModelWizard
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Modules
4
+ require 'multi_model_wizard/redis_cookie_store'
5
+ require 'multi_model_wizard/dynamic_validation'
6
+ require 'multi_model_wizard/cookie_store'
7
+ require 'multi_model_wizard/version'
8
+ require 'multi_model_wizard/config'
9
+ require 'multi_model_wizard'
10
+ require 'form_object/base'
11
+
12
+ # Third party gems
13
+ require 'json'
14
+ require 'securerandom'
15
+ require 'active_support'
16
+
17
+ module MultiModelWizard
18
+ module Wizard
19
+ extend ActiveSupport::Concern
20
+
21
+ include ::MultiModelWizard::CookieStore
22
+ include ::MultiModelWizard::RedisCookieStore
23
+
24
+ attr_reader :form_id
25
+
26
+ # This gets the form data from the session cookie or redis depending on whats configured
27
+ def session_params
28
+ store_in_redis? ? redis_session_params : cookie_session_params
29
+ end
30
+
31
+ # This clears the form data from the session cookie or redis depending on whats configured
32
+ def clear_session_params
33
+ store_in_redis? ? clear_redis_session_params : clear_cookie_session_params
34
+ end
35
+
36
+ # This sets the form data in the session cookie or redis depending on whats configured
37
+ def set_session_params(value)
38
+ store_in_redis? ? set_redis_session_params(value) : set_cookie_session_params(value)
39
+ end
40
+
41
+ # Wizard form uuid will attempt to get the uuid from the browser session cookie
42
+ # If one is not there it will set a new session cookie with the uuid as teh value
43
+ def wizard_form_uuid
44
+ return get_signed_cookie(wizard_form_key) if get_signed_cookie(wizard_form_key).present?
45
+
46
+ @uuid ||= SecureRandom.uuid
47
+ set_signed_cookie(key: wizard_form_key, value: @uuid)
48
+ @uuid
49
+ end
50
+
51
+ # Reference the form key that was passed in from the initializer
52
+ def multi_model_wizard_form_key
53
+ ::MultiModelWizard.configuration.form_key
54
+ end
55
+
56
+ private
57
+
58
+ def wizard_form_key
59
+ if form_id
60
+ "#{multi_model_wizard_form_key}#{form_id}".to_sym
61
+ else
62
+ multi_model_wizard_form_key.to_sym
63
+ end
64
+ end
65
+
66
+ # Logical methods to determine where the gem should store form data
67
+ def store_in_redis?
68
+ ::MultiModelWizard.configuration.store_in_redis?
69
+ end
70
+
71
+ # This method is used to retrieve the form data from redis
72
+ def redis_session_params
73
+ JSON.parse(fetch_redis_cache("#{wizard_form_key.to_s}:#{wizard_form_uuid}"))
74
+ rescue TypeError
75
+ {}
76
+ end
77
+
78
+ def clear_redis_session_params
79
+ clear_redis_cache("#{wizard_form_key.to_s}:#{wizard_form_uuid}")
80
+ delete_cookie(wizard_form_key.to_sym)
81
+ end
82
+
83
+ def set_redis_session_params(value)
84
+ set_redis_cache(
85
+ "#{wizard_form_key.to_s}:#{wizard_form_uuid}",
86
+ value,
87
+ )
88
+ end
89
+
90
+ def cookie_session_params
91
+ get_signed_cookie(wizard_form_key.to_s)
92
+ end
93
+
94
+ def clear_cookie_session_params
95
+ delete_cookie(wizard_form_key)
96
+ end
97
+
98
+ def set_cookie_session_params(attributes)
99
+ set_signed_cookie(attributes.merge(key: wizard_form_key.to_s))
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'multi_model_wizard/dynamic_validation'
4
+ require_relative 'multi_model_wizard/redis_cookie_store'
5
+ require_relative 'multi_model_wizard/cookie_store'
6
+ require_relative 'multi_model_wizard/version'
7
+ require_relative 'multi_model_wizard/wizard'
8
+ require_relative 'multi_model_wizard/config'
9
+ require_relative 'form_object/base'
10
+
11
+
12
+ module MultiModelWizard
13
+ class << self
14
+ def configuration
15
+ @configuration ||= ::MultiModelWizard::Config.new
16
+ end
17
+
18
+ def configure
19
+ yield(configuration)
20
+ end
21
+
22
+ def version
23
+ ::MultiModelWizard::VERSION
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/multi_model_wizard/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'multi_model_wizard'
7
+ spec.version = MultiModelWizard::VERSION
8
+ spec.authors = ["micahbowie-pu"]
9
+ spec.authors = ["proctoru"]
10
+ spec.email = ["ruby-gems@meazurelearning.com "]
11
+
12
+ spec.summary = 'Creates a smart object for your wizards or forms. Create one form and form object that can update multiple models with ease.'
13
+ spec.description = 'MultiModelWizard is a way to create and update multiple ActiveRecord models using one form object.'
14
+ spec.homepage = 'https://github.com/ProctorU/multi_model_wizard'
15
+ spec.required_ruby_version = ">= 2.5.0"
16
+
17
+ spec.metadata["allowed_push_host"] = 'https://rubygems.org/'
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = spec.homepage
20
+ spec.metadata["changelog_uri"] = spec.homepage
21
+
22
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
+ `git ls-files -z`.split("\x0").reject do |f|
24
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
25
+ end
26
+ end
27
+ spec.bindir = "exe"
28
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
+ spec.require_paths = ["lib"]
30
+
31
+ spec.add_dependency 'activemodel', '>= 5.0'
32
+ spec.add_dependency 'activesupport', '>= 5.0'
33
+
34
+ spec.add_development_dependency 'activerecord', '>= 5.0'
35
+ spec.add_development_dependency 'sqlite3'
36
+ spec.add_development_dependency 'byebug'
37
+ end
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: multi_model_wizard
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - proctoru
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-02-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activemodel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activerecord
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '5.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '5.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: sqlite3
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: byebug
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description: MultiModelWizard is a way to create and update multiple ActiveRecord
84
+ models using one form object.
85
+ email:
86
+ - ruby-gems@meazurelearning.com 
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - ".rspec"
92
+ - CHANGELOG.md
93
+ - Gemfile
94
+ - README.md
95
+ - Rakefile
96
+ - lib/form_object/base.rb
97
+ - lib/multi_model_wizard.rb
98
+ - lib/multi_model_wizard/config.rb
99
+ - lib/multi_model_wizard/cookie_store.rb
100
+ - lib/multi_model_wizard/dynamic_validation.rb
101
+ - lib/multi_model_wizard/redis_cookie_store.rb
102
+ - lib/multi_model_wizard/version.rb
103
+ - lib/multi_model_wizard/wizard.rb
104
+ - multi_model_wizard.gemspec
105
+ homepage: https://github.com/ProctorU/multi_model_wizard
106
+ licenses: []
107
+ metadata:
108
+ allowed_push_host: https://rubygems.org/
109
+ homepage_uri: https://github.com/ProctorU/multi_model_wizard
110
+ source_code_uri: https://github.com/ProctorU/multi_model_wizard
111
+ changelog_uri: https://github.com/ProctorU/multi_model_wizard
112
+ post_install_message:
113
+ rdoc_options: []
114
+ require_paths:
115
+ - lib
116
+ required_ruby_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: 2.5.0
121
+ required_rubygems_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ requirements: []
127
+ rubygems_version: 3.0.9
128
+ signing_key:
129
+ specification_version: 4
130
+ summary: Creates a smart object for your wizards or forms. Create one form and form
131
+ object that can update multiple models with ease.
132
+ test_files: []