active_interaction-extras 1.1.0 → 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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +3 -2
  3. data/CHANGELOG.md +24 -0
  4. data/README.md +325 -0
  5. data/active_interaction-extras.gemspec +6 -5
  6. data/lib/active_interaction/extras/active_job.rb +36 -5
  7. data/lib/active_interaction/extras/add_to_batch.rb +13 -0
  8. data/lib/active_interaction/extras/after_initialize.rb +20 -0
  9. data/lib/active_interaction/extras/all.rb +16 -1
  10. data/lib/active_interaction/extras/all_runner.rb +91 -0
  11. data/lib/active_interaction/extras/append_inputs.rb +60 -0
  12. data/lib/active_interaction/extras/current.rb +23 -0
  13. data/lib/active_interaction/extras/file_blobs.rb +80 -0
  14. data/lib/active_interaction/extras/filter_alias.rb +28 -2
  15. data/lib/active_interaction/extras/filter_extensions.rb +8 -0
  16. data/lib/active_interaction/extras/filters/ar_relation_filter.rb +22 -0
  17. data/lib/active_interaction/extras/filters/uuid_filter.rb +1 -1
  18. data/lib/active_interaction/extras/form_for.rb +33 -0
  19. data/lib/active_interaction/extras/good_job.rb +45 -0
  20. data/lib/active_interaction/extras/include_errors.rb +10 -0
  21. data/lib/active_interaction/extras/initialize_with.rb +16 -0
  22. data/lib/active_interaction/extras/instance_runnable.rb +35 -0
  23. data/lib/active_interaction/extras/model_fields.rb +68 -33
  24. data/lib/active_interaction/extras/model_names.rb +20 -0
  25. data/lib/active_interaction/extras/named_callbacks.rb +54 -0
  26. data/lib/active_interaction/extras/nested_attributes.rb +126 -0
  27. data/lib/active_interaction/extras/strong_params.rb +48 -10
  28. data/lib/active_interaction/extras/transaction.rb +27 -5
  29. data/lib/active_interaction/extras/version.rb +1 -1
  30. data/lib/active_interaction/extras.rb +10 -23
  31. metadata +41 -15
@@ -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
@@ -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'
@@ -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,7 +3,7 @@
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)
@@ -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,38 +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
- fields.to_h do |field|
10
- [field, public_send(field)]
11
- end
12
- end
13
-
14
- # returns hash of only changed model fields and their values
15
- def changed_model_fields(model_name)
16
- model_fields(model_name).select do |field, _value|
17
- any_changed?(field)
18
- end
19
- end
20
-
21
- # returns hash of only given model fields and their values
22
- def given_model_fields(model_name)
23
- model_fields(model_name).select do |field, _value|
24
- inputs.given?(field)
25
- end
26
- end
5
+ include ActiveInteraction::Extras::AfterInitialize
27
6
 
28
7
  class Context < SimpleDelegator
29
8
  attr_accessor :from_model_name
30
9
  attr_accessor :model_field_cache
31
10
 
32
- def custom_filter_attribute(name, opts = {})
11
+ def custom_filter_attribute(name, opts = {}, &block)
33
12
  from_model_name = self.from_model_name
34
13
  model_field_cache[from_model_name] = model_field_cache[from_model_name] << name
35
14
 
36
- __getobj__.send __callee__, name, opts
15
+ __getobj__.send __callee__, name, opts, &block
37
16
  end
38
17
 
39
18
  alias interface custom_filter_attribute
@@ -56,25 +35,81 @@ module ActiveInteraction::Extras::ModelFields
56
35
  alias uuid custom_filter_attribute
57
36
  end
58
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
+
59
90
  # checks if value was given to the service and the value is different from
60
91
  # the one on the model
61
92
  def any_changed?(*fields)
62
93
  fields.any? do |field|
63
94
  model_field = self.class.model_field_cache_inverse[field]
64
- value_changed = true
65
-
66
95
  if model_field
67
- 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)
68
108
  end
69
-
70
- inputs.given?(field) && value_changed
71
109
  end
72
110
  end
73
111
 
74
- # overwritten to pre-populate model fields
75
- def initialize(...)
76
- super
77
-
112
+ def assign_model_values_to_filters
78
113
  self.class.filters.each do |name, filter|
79
114
  next if inputs.given?(name)
80
115
 
@@ -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