active_interaction-extras 1.0.4 → 2.0.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +4 -3
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +24 -0
  5. data/README.md +325 -0
  6. data/active_interaction-extras.gemspec +8 -5
  7. data/lib/active_interaction/extras/active_job.rb +36 -5
  8. data/lib/active_interaction/extras/add_to_batch.rb +13 -0
  9. data/lib/active_interaction/extras/after_initialize.rb +20 -0
  10. data/lib/active_interaction/extras/all.rb +16 -1
  11. data/lib/active_interaction/extras/all_runner.rb +91 -0
  12. data/lib/active_interaction/extras/append_inputs.rb +60 -0
  13. data/lib/active_interaction/extras/current.rb +23 -0
  14. data/lib/active_interaction/extras/file_blobs.rb +80 -0
  15. data/lib/active_interaction/extras/filter_alias.rb +28 -2
  16. data/lib/active_interaction/extras/filter_extensions/object_classes.rb +2 -0
  17. data/lib/active_interaction/extras/filter_extensions.rb +8 -0
  18. data/lib/active_interaction/extras/filters/anything_filter.rb +9 -2
  19. data/lib/active_interaction/extras/filters/ar_relation_filter.rb +22 -0
  20. data/lib/active_interaction/extras/filters/uuid_filter.rb +7 -2
  21. data/lib/active_interaction/extras/form_for.rb +33 -0
  22. data/lib/active_interaction/extras/good_job.rb +45 -0
  23. data/lib/active_interaction/extras/include_errors.rb +10 -0
  24. data/lib/active_interaction/extras/initialize_with.rb +16 -0
  25. data/lib/active_interaction/extras/instance_runnable.rb +35 -0
  26. data/lib/active_interaction/extras/model_fields.rb +75 -37
  27. data/lib/active_interaction/extras/model_names.rb +20 -0
  28. data/lib/active_interaction/extras/named_callbacks.rb +54 -0
  29. data/lib/active_interaction/extras/nested_attributes.rb +126 -0
  30. data/lib/active_interaction/extras/strong_params.rb +50 -10
  31. data/lib/active_interaction/extras/transaction.rb +27 -5
  32. data/lib/active_interaction/extras/version.rb +1 -1
  33. data/lib/active_interaction/extras.rb +10 -23
  34. metadata +49 -17
@@ -0,0 +1,60 @@
1
+ module ActiveInteraction::Extras::AppendInputs
2
+ extend ActiveSupport::Concern
3
+
4
+ module InputsRefinement
5
+ refine ActiveInteraction::Inputs do
6
+ # Based on ActiveInteraction::Inputs#initialize
7
+ def append(raw_inputs, overwrite: false)
8
+ new_normalized_inputs = normalize(raw_inputs)
9
+
10
+ keys = new_normalized_inputs.keys.map(&:to_sym)
11
+ keys = keys.reject { given?(_1) } if !overwrite
12
+
13
+ new_inputs = @base.class.filters
14
+ .slice(*keys)
15
+ .each_with_object({}) do |(name, filter), inputs|
16
+ inputs[name] = filter.process(new_normalized_inputs[name], @base)
17
+
18
+ yield(name, inputs[name]) if block_given?
19
+ end
20
+
21
+ @normalized_inputs = @normalized_inputs.merge(new_normalized_inputs)
22
+ @inputs = @inputs.merge(new_inputs)
23
+ @to_h = nil
24
+ end
25
+ end
26
+ end
27
+
28
+ using InputsRefinement
29
+
30
+ def default_inputs(*args)
31
+ inputs.append(*args) do |name, input|
32
+ public_send("#{name}=", input.value)
33
+ end
34
+ end
35
+
36
+ # def default_input(name, &block)
37
+ # if !inputs.given?(name)
38
+ # default_inputs(name: block.call)
39
+ # end
40
+ # end
41
+
42
+ class_methods do
43
+ def default_inputs(&block)
44
+ # prepend: true - so it runs after ActiveInteraction::Extras::ModelFields.assign_model_values_to_filters
45
+ # callback order is inverse, so that's why it's confusing
46
+ after_initialize prepend: true do
47
+ hash = block.call
48
+ default_inputs(hash)
49
+ end
50
+ end
51
+
52
+ def default_input(name, &block)
53
+ # prepend: true - so it runs after ActiveInteraction::Extras::ModelFields.assign_model_values_to_filters
54
+ # callback order is inverse, so that's why it's confusing
55
+ after_initialize prepend: true do
56
+ default_inputs(name => instance_exec(&block))
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,23 @@
1
+ module ActiveInteraction::Extras::Current
2
+ extend ActiveSupport::Concern
3
+
4
+ class CurrentContext < ActiveSupport::CurrentAttributes
5
+ attribute :job
6
+ attribute :batch_event
7
+ attribute :batch
8
+
9
+ def batch
10
+ super || job&.batch
11
+ end
12
+ end
13
+
14
+ def current
15
+ self.class.current
16
+ end
17
+
18
+ class_methods do
19
+ def current
20
+ CurrentContext
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,80 @@
1
+ module ActiveInteraction::Extras::FileBlobs
2
+ extend ActiveSupport::Concern
3
+
4
+ # Returns the blobs for a given array field.
5
+ #
6
+ # @example has_one_attached :file
7
+ #
8
+ # # doc/_form.html.slim
9
+ # = f.hidden_field :file, value: nil
10
+ #
11
+ # - form.blobs_for(:file).each do |blob|
12
+ # div data-controller="remove"
13
+ # = f.hidden_field :file, value: blob.signed_id
14
+ # - if blob.representable?
15
+ # = image_tag url_for(blob.representation(resize_to_fit: [300, 80])), height: 80
16
+ # button type="button" data-action="click->remove#remove"
17
+ # 'Remove
18
+ #
19
+ # = f.input :new_file, as: :file
20
+ #
21
+ # # doc/form.rb
22
+ # class Doc::Form < ActiveInteraction::Base
23
+ # object :doc
24
+ #
25
+ # model_fields(:doc) do
26
+ # anything :file
27
+ # end
28
+ #
29
+ # anything :new_file, permit: true, default: nil
30
+ #
31
+ # def execute
32
+ # save_model!(:doc)
33
+ #
34
+ # if inputs.given?(:new_file)
35
+ # doc.file = new_file
36
+ # doc.save!
37
+ # end
38
+ # end
39
+ # end
40
+ #
41
+ # @example has_many_attached :files
42
+ #
43
+ # # doc/_form.html.slim
44
+ # = f.file_field :files, multiple: true, direct_upload: true
45
+ #
46
+ # # doc/form.rb
47
+ # class Doc::Form < ActiveInteraction::Base
48
+ # object :doc
49
+ #
50
+ # model_fields(:doc) do
51
+ # array :files
52
+ # end
53
+ #
54
+ # def execute
55
+ # save_model!(:files)
56
+ # end
57
+ # end
58
+ #
59
+ def blobs_for(array_field)
60
+ # If there is a new upload field, prioritize it
61
+ new_upload = "new_#{array_field}"
62
+ if respond_to?(new_upload) && public_send(new_upload).present?
63
+ array_field = new_upload
64
+ end
65
+
66
+ list = public_send(array_field)
67
+ list = Array.wrap(list)
68
+
69
+ list.compact_blank.map do |file|
70
+ case file
71
+ when ActiveStorage::Blob
72
+ file
73
+ when ActiveStorage::Attachment, ActiveStorage::Attached::One
74
+ file.blob
75
+ when String
76
+ ActiveStorage::Blob.find_signed(file)
77
+ end
78
+ end.compact
79
+ end
80
+ end
@@ -1,16 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Add :as option, which is a read alias to filter
4
- # hash :params, as: :account_attributes
4
+ # hash :account, as: :account_attributes
5
5
  #
6
6
  module ActiveInteraction::Extras::FilterAlias
7
7
  extend ActiveSupport::Concern
8
8
 
9
+ # concern :InputsExtension do
10
+ # def normalize(inputs)
11
+ # @base.class.filters.each do |name, filter|
12
+ # alias_name = filter.options[:as]
13
+ #
14
+ # if alias_name.nil?
15
+ # next
16
+ #
17
+ # elsif inputs.key?(alias_name.to_sym)
18
+ # inputs[name.to_sym] = inputs[alias_name.to_sym]
19
+ #
20
+ # elsif inputs.key?(alias_name.to_s)
21
+ # inputs[name.to_s] = inputs[alias_name.to_s]
22
+ # end
23
+ # end
24
+ #
25
+ # super
26
+ # end
27
+ # end
28
+ #
29
+ # included do
30
+ # ActiveInteraction::Inputs.prepend InputsExtension
31
+ # end
32
+
9
33
  class_methods do
10
34
  def initialize_filter(filter)
11
35
  super.tap do
12
36
  if filter.options[:as]
13
- alias_method filter.options[:as], filter.name
37
+ name = filter.options[:as]
38
+ alias_method name, filter.name
39
+ # alias_method "#{name}=", "#{filter.name}="
14
40
  end
15
41
  end
16
42
  end
@@ -29,6 +29,8 @@ module ActiveInteraction::Extras::FilterExtensions::ObjectClasses
29
29
 
30
30
  def matches?(value)
31
31
  if polymorphic?
32
+ return false if value == nil
33
+
32
34
  class_list.any? { |klass| value.class <= klass }
33
35
  else
34
36
  super
@@ -1,2 +1,10 @@
1
+ module ActiveInteraction
2
+ module Extras
3
+ module FilterExtensions
4
+
5
+ end
6
+ end
7
+ end
8
+
1
9
  require 'active_interaction/extras/filter_extensions/hash_auto_strip'
2
10
  require 'active_interaction/extras/filter_extensions/object_classes'
@@ -3,7 +3,14 @@
3
3
  class ActiveInteraction::Extras::Filters::AnythingFilter < ActiveInteraction::Filter
4
4
  register :anything
5
5
 
6
- def matches?(_object)
7
- true
6
+ def matches?(value)
7
+ if value == nil && default?
8
+ # as per v5 there is no way to know if value of `nil` is given by caller or
9
+ # is a default value when it's missing. We want to maintain standard default
10
+ # behaviour when value is `nil`. Returning `false` will trigger default value
11
+ false
12
+ else
13
+ true
14
+ end
8
15
  end
9
16
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveInteraction::Extras::Filters::ArRelationFilter < ActiveInteraction::ObjectFilter
4
+ register :ar_relation
5
+
6
+ def initialize(...)
7
+ super
8
+ @options.reverse_merge!(class: ActiveRecord::Relation)
9
+ end
10
+
11
+ def matches?(value)
12
+ # value == nil triggers active record loading
13
+ if value.nil? && default?
14
+ # as per v5 there is no way to know if value of `nil` is given by caller or
15
+ # is a default value when it's missing. We want to maintain standard default
16
+ # behaviour when value is `nil`. Returning `false` will trigger default value
17
+ false
18
+ else
19
+ true
20
+ end
21
+ end
22
+ end
@@ -3,13 +3,18 @@
3
3
  class ActiveInteraction::Extras::Filters::UUIDFilter < ActiveInteraction::StringFilter
4
4
  register :uuid
5
5
 
6
- REGEX = /^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.freeze
6
+ REGEX = /^[0-9A-F]{8}-[0-9A-F]{4}-[457][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.freeze
7
7
 
8
8
  def matches?(value)
9
9
  super && REGEX.match?(value)
10
10
  end
11
11
 
12
12
  def convert(value)
13
- super&.presence
13
+ value, error = super
14
+ if error
15
+ [value, error]
16
+ else
17
+ [value&.presence, nil]
18
+ end
14
19
  end
15
20
  end
@@ -0,0 +1,33 @@
1
+ module ActiveInteraction::Extras::FormFor
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ # Changes route key so url helpers work
6
+ #
7
+ # @example
8
+ # class User::Form < ActiveInteraction::Base
9
+ # object :user
10
+ # form_for :user
11
+ # end
12
+ #
13
+ # url_for(form) #=> "/users/1"
14
+ def form_for(field_name)
15
+ delegate :persisted?, :id, :to_param, to: field_name
16
+
17
+ if filters.key?(field_name)
18
+ klass = filters.fetch(field_name).send(:klass)
19
+ model_name.route_key = klass.model_name.route_key
20
+ model_name.singular_route_key = klass.model_name.singular_route_key
21
+ end
22
+
23
+ # TODO: Routes reload is broken in Dev
24
+ # Resolve form object route to object
25
+ # e.g. resolve "User::Form" { |form| form.user }
26
+ # Rails.application.config.after_routes_loaded do |app|
27
+ # app.routes.add_polymorphic_mapping(name, {}) do |form|
28
+ # form.send(field_name)
29
+ # end
30
+ # end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,45 @@
1
+ require 'active_job'
2
+
3
+ module ActiveInteraction::Extras::GoodJob
4
+ extend ActiveSupport::Concern
5
+ include ActiveInteraction::Extras::ActiveJob
6
+
7
+ module BatchJobPerform
8
+ def perform(batch, options)
9
+ ActiveInteraction::Extras::Current::CurrentContext
10
+ .set(batch:, batch_event: options[:event]) do
11
+ super(batch.properties)
12
+ end
13
+ end
14
+ end
15
+
16
+ # Note: Should include GoodJob::ActiveJobExtensions::Concurrency before this
17
+ module Perform
18
+ extend ActiveSupport::Concern
19
+ include ActiveInteraction::Extras::ActiveJob::Perform
20
+
21
+ def _good_job_default_concurrency_key
22
+ Digest::MD5.base64digest([self.class.name, arguments].to_param)
23
+ end
24
+ end
25
+
26
+ class_methods do
27
+ def define_job_class(klass)
28
+ super
29
+
30
+ unless const_defined?(:BatchJob, false)
31
+ const_set(:BatchJob, Class.new(job_class) do
32
+ include BatchJobPerform
33
+ end)
34
+ end
35
+ end
36
+
37
+ def batch_job(job_params)
38
+ batch = GoodJob::Batch.new
39
+ batch.description = self.name
40
+ batch.on_finish = self::BatchJob.name
41
+ batch.properties = job_params
42
+ batch
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,10 @@
1
+ module ActiveInteraction::Extras::IncludeErrors
2
+ extend ActiveSupport::Concern
3
+
4
+ include ActiveInteraction::Extras::Halt
5
+
6
+ def include_errors!(model, **mapping)
7
+ errors.merge! model.errors, **mapping
8
+ halt_if_errors!
9
+ end
10
+ end
@@ -0,0 +1,16 @@
1
+ module ActiveInteraction::Extras::InitializeWith
2
+ extend ActiveSupport::Concern
3
+
4
+ include ActiveInteraction::Extras::AfterInitialize
5
+
6
+ class_methods do
7
+ def initialize_with(&block)
8
+ after_initialize do
9
+ hash = instance_exec(&block)
10
+ hash&.each do |filter_name, value|
11
+ public_send "#{filter_name}=", value if !inputs.given?(filter_name)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,35 @@
1
+ # Allows Interaction.new.run
2
+ # Useful for controllers, so single object could be instantiated for new, create, edit, update
3
+ # @example
4
+ # def form
5
+ # @form ||= Form.new(record:, params:)
6
+ # end
7
+ #
8
+ # def create
9
+ # if form.save
10
+ # ...
11
+ # else
12
+ # ...
13
+ # end
14
+ # end
15
+ #
16
+ module ActiveInteraction::Extras::InstanceRunnable
17
+ extend ActiveSupport::Concern
18
+
19
+ def save
20
+ run
21
+ valid?
22
+ end
23
+
24
+ def save!
25
+ run!
26
+ end
27
+
28
+ def run
29
+ if @_executed
30
+ raise("Service already executed")
31
+ end
32
+ @_executed = true
33
+ super
34
+ end
35
+ end
@@ -2,36 +2,17 @@ require "active_support/core_ext/object/with_options"
2
2
 
3
3
  module ActiveInteraction::Extras::ModelFields
4
4
  extend ActiveSupport::Concern
5
-
6
- # returns hash of all model fields and their values
7
- def model_fields(model_name)
8
- fields = self.class.model_field_cache[model_name]
9
- inputs.slice(*fields)
10
- end
11
-
12
- # returns hash of only changed model fields and their values
13
- def changed_model_fields(model_name)
14
- model_fields(model_name).select do |field, _value|
15
- any_changed?(field)
16
- end
17
- end
18
-
19
- # returns hash of only given model fields and their values
20
- def given_model_fields(model_name)
21
- model_fields(model_name).select do |field, _value|
22
- given?(field)
23
- end
24
- end
5
+ include ActiveInteraction::Extras::AfterInitialize
25
6
 
26
7
  class Context < SimpleDelegator
27
8
  attr_accessor :from_model_name
28
9
  attr_accessor :model_field_cache
29
10
 
30
- def custom_filter_attribute(name, opts = {})
11
+ def custom_filter_attribute(name, opts = {}, &block)
31
12
  from_model_name = self.from_model_name
32
13
  model_field_cache[from_model_name] = model_field_cache[from_model_name] << name
33
14
 
34
- __getobj__.send __callee__, name, opts
15
+ __getobj__.send __callee__, name, opts, &block
35
16
  end
36
17
 
37
18
  alias interface custom_filter_attribute
@@ -54,33 +35,90 @@ module ActiveInteraction::Extras::ModelFields
54
35
  alias uuid custom_filter_attribute
55
36
  end
56
37
 
38
+ included do
39
+ after_initialize :assign_model_values_to_filters
40
+ end
41
+
42
+ # returns hash of all model fields and their values
43
+ def model_fields(model_name)
44
+ fields = self.class.model_field_cache[model_name]
45
+ fields.to_h do |field|
46
+ [field, public_send(field)]
47
+ end
48
+ end
49
+
50
+ # returns hash of only changed model fields and their values
51
+ def changed_model_fields(model_name)
52
+ model_fields(model_name).select do |field, _value|
53
+ any_changed?(field)
54
+ end
55
+ end
56
+
57
+ def assign_model_fields(model_name)
58
+ model = public_send(model_name)
59
+ model.assign_attributes(model_fields(model_name))
60
+ model
61
+ end
62
+
63
+ def save_model!(model_name)
64
+ model = assign_model_fields(model_name)
65
+ model.save
66
+ include_errors! model
67
+ model
68
+ end
69
+
70
+ def assign_model_changes(model_name)
71
+ model = public_send(model_name)
72
+ model.assign_attributes(changed_model_fields(model_name))
73
+ model
74
+ end
75
+
76
+ def save_model_changes!(model_name)
77
+ model = assign_model_changes(model_name)
78
+ model.save
79
+ include_errors! model
80
+ model
81
+ end
82
+
83
+ # returns hash of only given model fields and their values
84
+ def given_model_fields(model_name)
85
+ model_fields(model_name).select do |field, _value|
86
+ inputs.given?(field)
87
+ end
88
+ end
89
+
57
90
  # checks if value was given to the service and the value is different from
58
91
  # the one on the model
59
92
  def any_changed?(*fields)
60
93
  fields.any? do |field|
61
94
  model_field = self.class.model_field_cache_inverse[field]
62
- value_changed = true
63
-
64
95
  if model_field
65
- value_changed = send(model_field).send(field) != send(field)
96
+ model = send(model_field)
97
+ if model.respond_to?(:new_record?) && model.new_record?
98
+ true
99
+ elsif inputs.given?(field)
100
+ # NOTE: this causes bug when form attribute was manually changed, but `inputs.given?` is false
101
+ # change won't be detected
102
+ model.send(field) != send(field)
103
+ else
104
+ false
105
+ end
106
+ else
107
+ inputs.given?(field)
66
108
  end
67
-
68
- given?(field) && value_changed
69
109
  end
70
110
  end
71
111
 
72
- # overwritten to pre-populate model fields
73
- def populate_filters_and_inputs(_inputs)
74
- super.tap do
75
- self.class.filters.each do |name, filter|
76
- next if given?(name)
112
+ def assign_model_values_to_filters
113
+ self.class.filters.each do |name, filter|
114
+ next if inputs.given?(name)
77
115
 
78
- model_field = self.class.model_field_cache_inverse[name]
79
- next if model_field.nil?
116
+ model_field = self.class.model_field_cache_inverse[name]
117
+ next if model_field.nil?
80
118
 
81
- value = public_send(model_field)&.public_send(name)
82
- public_send("#{name}=", filter.clean(value, self))
83
- end
119
+ value = public_send(model_field)&.public_send(name)
120
+ input = filter.process(value, self)
121
+ public_send("#{name}=", input.value)
84
122
  end
85
123
  end
86
124
 
@@ -0,0 +1,20 @@
1
+ module ActiveInteraction::Extras::ModelNames
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ def inherited(subclass)
6
+ super.tap { subclass.set_model_naming }
7
+ end
8
+
9
+ def set_model_naming
10
+ str = name.deconstantize.underscore
11
+ model_name.route_key = str.pluralize.to_sym
12
+ model_name.singular_route_key = str.singularize.to_sym
13
+ end
14
+
15
+ def singular_resource_route_key!
16
+ str = name.deconstantize.underscore
17
+ model_name.route_key = str.singularize.to_sym
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,54 @@
1
+ module ActiveInteraction::Extras::NamedCallbacks
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ def before_filter(...)
6
+ set_callback(:filter, :before, ...)
7
+ end
8
+
9
+ def after_filter(...)
10
+ set_callback(:filter, :after, ...)
11
+ end
12
+
13
+ def around_filter(...)
14
+ set_callback(:filter, :around, ...)
15
+ end
16
+
17
+
18
+ def before_validate(...)
19
+ set_callback(:validate, :before, ...)
20
+ end
21
+
22
+ def after_validate(...)
23
+ set_callback(:validate, :after, ...)
24
+ end
25
+
26
+ def around_validate(...)
27
+ set_callback(:validate, :around, ...)
28
+ end
29
+
30
+ def before_validation(...)
31
+ before_validate(...)
32
+ end
33
+
34
+ def after_validation(...)
35
+ after_validate(...)
36
+ end
37
+
38
+ def around_validation(...)
39
+ around_validate(...)
40
+ end
41
+
42
+ def before_execute(...)
43
+ set_callback(:execute, :before, ...)
44
+ end
45
+
46
+ def after_execute(...)
47
+ set_callback(:execute, :after, ...)
48
+ end
49
+
50
+ def around_execute(...)
51
+ set_callback(:execute, :around, ...)
52
+ end
53
+ end
54
+ end