wicked-pipeline 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.
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wicked
4
+ module Pipeline
5
+ class BaseStepsController < parent_controller.constantize
6
+ include Wicked::Wizard
7
+
8
+ before_action :_set_steps
9
+ before_action :setup_wizard
10
+
11
+ rescue_from Wicked::Wizard::InvalidStepError, with: :handle_invalid_step!
12
+
13
+ attr_reader :step_processor
14
+ helper_method :step_processor
15
+
16
+ def show
17
+ yield if block_given?
18
+
19
+ _set_processor_required!
20
+
21
+ if step == Wicked::FINISH_STEP && steps_metadata[steps.last][:valid] && steps_metadata[steps.last][:accessible]
22
+ jump_to(steps.last) if steps_metadata[steps.last][:blocking]
23
+ render_wizard(nil, {}, resource_url_params)
24
+ elsif _accessible_steps.include?(step_processor.step_name)
25
+ render_step(step_processor.step_name)
26
+ else
27
+ jump_to(_accessible_steps.last)
28
+ render_wizard(nil, {}, resource_url_params)
29
+ end
30
+ end
31
+
32
+ def update
33
+ params.require(:id)
34
+
35
+ yield if block_given?
36
+
37
+ _set_processor_required!
38
+
39
+ unless step_processor.resource.new_record?
40
+ render_wizard(step_processor, {}, resource_url_params)
41
+ return
42
+ end
43
+
44
+ process_resource!(step_processor)
45
+
46
+ if step_processor.saved?
47
+ render_wizard(nil, {}, resource_url_params)
48
+ else
49
+ render_step(step_processor.step_name)
50
+ end
51
+ end
52
+
53
+ protected
54
+
55
+ def resource_param_name
56
+ raise NotImplementedError, "resource_param_name must be implemented in subclasses of #{self.class.name}"
57
+ end
58
+
59
+ def resource_url_params
60
+ _set_processor_required!
61
+ {resource_param_name => step_processor.id}
62
+ end
63
+
64
+ def find_resource
65
+ raise NotImplementedError, "find_resource must be implemented in subclasses of #{self.class.name}"
66
+ end
67
+
68
+ def steps_pipeline
69
+ raise NotImplementedError, "steps_pipeline must be implemented in subclasses of #{self.class.name}"
70
+ end
71
+ helper_method :steps_pipeline
72
+
73
+ def step_classes
74
+ Array(steps_pipeline)
75
+ end
76
+
77
+ def step_class
78
+ return @step_class if @step_class
79
+
80
+ the_step =
81
+ case step
82
+ when Wicked::FIRST_STEP
83
+ steps.first
84
+ when Wicked::LAST_STEP
85
+ steps.last
86
+ when Wicked::FINISH_STEP
87
+ steps.last
88
+ else
89
+ step || steps.first
90
+ end
91
+
92
+ @step_class = _step_classes.fetch(the_step.to_s)
93
+ end
94
+
95
+ def step_path(step_name: nil, **options)
96
+ wizard_path(step_name.presence || step_class.step_name, options.reverse_merge(resource_url_params))
97
+ end
98
+ helper_method :step_path
99
+
100
+ def steps_metadata
101
+ return @steps_metadata if @steps_metadata
102
+
103
+ @steps_metadata = step_classes.each_with_index.with_object(ActiveSupport::HashWithIndifferentAccess.new) do |(klass, idx), obj|
104
+ processor = klass.new(_shallow_resource)
105
+
106
+ obj[processor.step_name] = {
107
+ url: step_path(step_name: processor.step_name),
108
+ active: (step.present? && processor.step_name.to_s == step.to_s) || (step.blank? && idx == 0)
109
+ }.merge(_steps_metadata[processor.step_name])
110
+ end
111
+ end
112
+ helper_method :steps_metadata
113
+
114
+ def current_step
115
+ return @current_step if @current_step
116
+
117
+ _set_processor_required!
118
+
119
+ @current_step = step_processor.step_name
120
+ end
121
+ helper_method :current_step
122
+
123
+ def step_i18n_scope
124
+ return @step_i18n_scope if @step_i18n_scope
125
+
126
+ _set_processor_required!
127
+
128
+ @step_i18n_scope = "#{controller_path}.#{step_processor.step_name}".tr("/", ".")
129
+ end
130
+ helper_method :step_i18n_scope
131
+
132
+ def handle_invalid_step!(exception)
133
+ if block_given?
134
+ yield
135
+ else
136
+ error_details = {
137
+ :step => params[:id],
138
+ resource_param_name => params[resource_param_name],
139
+ :controller => params[:controller],
140
+ :action => params[:action]
141
+ }
142
+
143
+ logger = ActiveSupport::TaggedLogging.new(Rails.logger)
144
+ logger.tagged("[#{self.class.name}]") do
145
+ logger.error("#{exception.class.name}: #{exception.message} -- details: #{error_details.to_json}")
146
+ end
147
+ end
148
+
149
+ redirect_to url_for({
150
+ :controller => params[:controller],
151
+ :action => params[:action],
152
+ resource_param_name => params[resource_param_name]
153
+ })
154
+ end
155
+
156
+ private
157
+
158
+ def _shallow_resource
159
+ @_shallow_resource ||= find_resource
160
+ end
161
+
162
+ def _set_processor_required!
163
+ raise "@step_processor must not be nil" if step_processor.nil?
164
+ end
165
+
166
+ def _step_classes
167
+ @_step_classes ||= step_classes.each_with_object(ActiveSupport::HashWithIndifferentAccess.new) do |klass, obj|
168
+ obj[klass.step_name] = klass
169
+ end
170
+ end
171
+
172
+ def _steps_metadata
173
+ return @_steps_metadata if @_steps_metadata
174
+
175
+ @_steps_metadata = steps_pipeline.metadata(_shallow_resource)
176
+ end
177
+
178
+ def _accessible_steps
179
+ @_accessible_steps ||= _steps_metadata.each_with_object([]) do |(step_name, step_metadata), obj|
180
+ break obj unless step_metadata[:accessible]
181
+
182
+ obj << step_name
183
+ end
184
+ end
185
+
186
+ def _set_steps
187
+ self.steps = _step_classes.keys
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wicked
4
+ module Pipeline
5
+ class BasePipeline
6
+ def steps
7
+ []
8
+ end
9
+
10
+ def valid?(resource)
11
+ steps.all? { |step| step.new(resource).valid? }
12
+ end
13
+
14
+ def blocked?(resource)
15
+ steps.any? { |step| step.new(resource).blocking? }
16
+ end
17
+
18
+ def first_step?(step)
19
+ steps.first == step
20
+ end
21
+
22
+ def last_step?(step)
23
+ steps.last == step
24
+ end
25
+
26
+ def find_step(the_step)
27
+ steps.find { |step| step == the_step || step.step_name == the_step }
28
+ end
29
+
30
+ def next_step(current_step)
31
+ step, step_index = steps.each_with_index.find { |step, _| step == current_step || step.step_name == current_step }
32
+
33
+ last_step?(step) || step_index.nil? ? nil : steps[step_index + 1]
34
+ end
35
+
36
+ def previous_step(current_step)
37
+ step, step_index = steps.each_with_index.find { |step, _| step == current_step || step.step_name == current_step }
38
+
39
+ first_step?(step) || step_index.nil? ? nil : steps[step_index - 1]
40
+ end
41
+
42
+ def next_step?(current_step, step)
43
+ !current_step.nil? && !step.nil? &&
44
+ next_step(current_step) == step
45
+ end
46
+
47
+ def previous_step?(current_step, step)
48
+ !current_step.nil? && !step.nil? &&
49
+ previous_step(current_step) == step
50
+ end
51
+
52
+ def metadata(resource)
53
+ steps.each_with_index.with_object(ActiveSupport::HashWithIndifferentAccess.new) do |(step, idx), obj|
54
+ processor = step.new(resource)
55
+ previous_step_metadata = obj.fetch(obj.keys[idx - 1], {})
56
+
57
+ obj[processor.step_name] = {
58
+ valid: processor.valid?,
59
+ blocking: processor.blocking?,
60
+ accessible: idx == 0 || (
61
+ !previous_step_metadata[:blocking] &&
62
+ previous_step_metadata[:accessible] &&
63
+ previous_step_metadata[:valid]
64
+ )
65
+ }
66
+ end
67
+ end
68
+
69
+ def to_ary
70
+ steps
71
+ end
72
+ alias_method :to_a, :to_ary
73
+
74
+ class << self
75
+ def steps
76
+ new.steps
77
+ end
78
+
79
+ def valid?(resource)
80
+ new.valid?(resource)
81
+ end
82
+
83
+ def blocked?(resource)
84
+ new.blocked?(resource)
85
+ end
86
+
87
+ def first_step?(step)
88
+ new.first_step?(step)
89
+ end
90
+
91
+ def last_step?(step)
92
+ new.last_step?(step)
93
+ end
94
+
95
+ def find_step(step)
96
+ new.find_step(step)
97
+ end
98
+
99
+ def next_step(current_step)
100
+ new.next_step(current_step)
101
+ end
102
+
103
+ def previous_step(current_step)
104
+ new.previous_step(current_step)
105
+ end
106
+
107
+ def next_step?(current_step, step)
108
+ new.next_step(current_step, step)
109
+ end
110
+
111
+ def previous_step?(current_step, step)
112
+ new.previous_step(current_step, step)
113
+ end
114
+
115
+ def metadata(resource)
116
+ new.metadata(resource)
117
+ end
118
+
119
+ def to_ary
120
+ new.to_ary
121
+ end
122
+ alias_method :to_a, :to_ary
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Wicked
6
+ module Pipeline
7
+ class BaseStep
8
+ include ActiveModel::Attributes
9
+ include ActiveModel::AttributeAssignment
10
+ include ActiveModel::Validations
11
+
12
+ # @return [ActiveRecord::Base] The record associated with the step
13
+ attr_reader :resource
14
+
15
+ # @param resource [ActiveRecord::Base] The record associated with the step
16
+ # @param params [Hash, ActionController::Parameters] Attribute changes to be applied to the resource
17
+ def initialize(resource, params = nil)
18
+ super()
19
+
20
+ @resource = resource
21
+
22
+ attributes.each_key do |key|
23
+ public_send("#{key}=", resource.public_send(key)) if resource.respond_to?(key)
24
+ end
25
+
26
+ unless params.nil?
27
+ parameters = _permit_params!(params)
28
+ self.attributes = parameters.select { |key, _| respond_to?("#{key}=") }
29
+ resource.attributes = parameters.select { |key, _| resource.respond_to?("#{key}=") }
30
+ end
31
+
32
+ regenerate_blocking_reasons!
33
+ end
34
+
35
+ # Saves the resource
36
+ #
37
+ # If the step is readonly the record will not
38
+ # be saved and the method will return +true+
39
+ #
40
+ # @return [Boolean]
41
+ def save(**options, &block)
42
+ return true if readonly?
43
+
44
+ cleanup_stale_data
45
+
46
+ valid? && resource.save(**options, &block)
47
+ end
48
+
49
+ # Check if both the step and the resource are valid
50
+ #
51
+ # Always returns +true+ if the step is readonly
52
+ #
53
+ # @return [Boolean]
54
+ def valid?
55
+ return true if readonly?
56
+
57
+ is_valid = super
58
+ resource.validate
59
+ resource.errors.merge!(errors)
60
+ regenerate_blocking_reasons!
61
+ is_valid && resource.errors.none?
62
+ end
63
+
64
+ # Checks if the resource exists in the database
65
+ #
66
+ # Always returns +true+ if the step is readonly
67
+ #
68
+ # @return [Boolean]
69
+ def persisted?
70
+ readonly? || resource.persisted?
71
+ end
72
+
73
+ # Checks that the step has been successfully saved and there are no unsaved changes
74
+ #
75
+ # Always returns +true+ if the step is readonly
76
+ #
77
+ # @return [Boolean]
78
+ def saved?
79
+ readonly? || (persisted? && resource.saved_changes? && !resource.has_changes_to_save?)
80
+ end
81
+
82
+ # Checks if the step is blocking subsequent steps in a pipeline. In other words,
83
+ # if a step is "blocking", all the subsequent steps in the pipeline will be marked
84
+ # as inaccessible.
85
+ #
86
+ # This can be used to short-circuit a pipeline.
87
+ #
88
+ # @return [Boolean]
89
+ def blocking?
90
+ false
91
+ end
92
+
93
+ # Check if the step is readonly
94
+ #
95
+ # @return [Boolean]
96
+ def readonly?
97
+ false
98
+ end
99
+
100
+ # Returns the reason why a step is blocking, or +nil+ if it is not blocking.
101
+ #
102
+ # This should be used to set an overall blocking reason.
103
+ # To set per-attribute blocking reasons use {#blocking_reasons}.
104
+ #
105
+ # @return [String, nil]
106
+ # @see #blocking?
107
+ # @see #blocking_reasons
108
+ def blocking_reason
109
+ nil
110
+ end
111
+
112
+ # Returns reasons for each attribute why a step is blocking.
113
+ #
114
+ # @note This method should not be overridden, it should just be used to access
115
+ # blocking reasons. To add a new reason, just add it to the hash. To reset all
116
+ # the reasons, override the {#regenerate_blocking_reasons!} method in subsclasses.
117
+ #
118
+ # @note Warning: Every time {#valid?} is called the blocking reasons will be regenereted.
119
+ #
120
+ # @return [Hash{String,Symbol => Array<String>}] hash of reasons for each attribute.
121
+ # @see #blocking?
122
+ def blocking_reasons
123
+ @blocking_reasons ||= ActiveSupport::HashWithIndifferentAccess.new { |h, k| h[k] = [] }
124
+ end
125
+
126
+ # Returns the String representation of the step object
127
+ #
128
+ # @example Step object without a namespace
129
+ # FinancialSituationnStep.to_s #=> "financial_situation"
130
+ #
131
+ # @example Nested step object
132
+ # Users::FinancialSituationnStep.to_s #=> "users/financial_situation"
133
+ #
134
+ # @return [String]
135
+ def self.to_s
136
+ @_to_s ||= name.to_s.underscore.sub(/_step\z/, "")
137
+ end
138
+
139
+ # Returns the step name of the step object
140
+ #
141
+ # @example Step object without a namespace
142
+ # FinancialSituationnStep.step_name #=> "financial_situation"
143
+ #
144
+ # @example Nested step object
145
+ # Users::FinancialSituationnStep.step_name #=> "financial_situation"
146
+ #
147
+ # @return [String]
148
+ def self.step_name
149
+ @_step_name ||= to_s.gsub(%r{(?:\w+/)*}, "")
150
+ end
151
+
152
+ # Returns the String representation of the step object
153
+ #
154
+ # @example Step object without a namespace
155
+ # FinancialSituationnStep.new(resource).to_s #=> "financial_situation"
156
+ #
157
+ # @example Nested step object
158
+ # Users::FinancialSituationnStep.new(resource).to_s #=> "users/financial_situation"
159
+ #
160
+ # @see .to_s
161
+ #
162
+ # @return [String]
163
+ def to_s
164
+ self.class.to_s
165
+ end
166
+
167
+ # Returns the step name of the step object
168
+ #
169
+ # @example Step object without a namespace
170
+ # FinancialSituationnStep.new(resource).step_name #=> "financial_situation"
171
+ #
172
+ # @example Nested step object
173
+ # Users::FinancialSituationnStep.new(resource).step_name #=> "financial_situation"
174
+ #
175
+ # @see .step_name
176
+ #
177
+ # @return [String]
178
+ def step_name
179
+ self.class.step_name
180
+ end
181
+
182
+ # Check whether two steps are the same
183
+ #
184
+ # It returns +true+ only if +other+ is the same step class then +self+ or if +self.to_s+ is equal to +other+.
185
+ # +other.step_name+.
186
+ #
187
+ # @param other [BaseStep, String, Symbol] the other step tested for equality
188
+ # @return [Boolean]
189
+ #
190
+ # @see .to_s
191
+ def self.==(other)
192
+ super || to_s == other.to_s
193
+ end
194
+
195
+ # (see .==)
196
+ def self.eql?(other)
197
+ self == other
198
+ end
199
+
200
+ # (see .==)
201
+ # @see .==
202
+ def self.equal?(other)
203
+ self == other
204
+ end
205
+
206
+ # (see .equal?)
207
+ def self.===(other)
208
+ equal?(other)
209
+ end
210
+
211
+ # (see .equal?)
212
+ def equal?(other)
213
+ self.class.equal?(other)
214
+ end
215
+
216
+ # (see .equal?)
217
+ def ===(other)
218
+ equal?(other)
219
+ end
220
+
221
+ # @return [Number, Symbol] The ID of the resource or :new if the the resource is a new record
222
+ def id
223
+ resource.id || :new
224
+ end
225
+
226
+ # Validate the associations of of the resource
227
+ #
228
+ # @param associations [Symbol]
229
+ # @param options [void] <b>Do not use!</b> It is here for compatibility reasons.
230
+ # @note Do not use the +options+ param, it is here for compatibility with
231
+ # the +ActiveRecord::Validations::AssociatedValidator+.
232
+ #
233
+ # @example
234
+ # class OwnershipStep < BaseStep
235
+ # validates_associated :beneficiaries
236
+ # end
237
+ def self.validates_associated(*associations, **options)
238
+ if options.present?
239
+ warn "WARN: calling .validates_associated with options from step objects is not supported."
240
+ end
241
+
242
+ associations.each do |association|
243
+ unless respond_to?(association)
244
+ delegate association, to: :resource
245
+ end
246
+
247
+ validate do
248
+ Array(public_send(association)).each do |associated_object|
249
+ unless associated_object.valid?
250
+ errors.add(association, :invalid)
251
+ break
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
257
+ private_class_method :validates_associated
258
+
259
+ def self.i18n_scope
260
+ @_i18n_scope ||= name.underscore.tr("/", ".").prepend("steps.")
261
+ end
262
+
263
+ delegate :i18n_scope, to: :class
264
+
265
+ protected
266
+
267
+ def permitted_params
268
+ raise NotImplementedError, "permitted_params must be implemented in subclasses of BaseStep"
269
+ end
270
+
271
+ # This method is meant to be overridden.
272
+ # Make sure to call #super before any custom code!
273
+ def regenerate_blocking_reasons!
274
+ blocking_reasons.clear
275
+ end
276
+
277
+ def stale_attributes
278
+ []
279
+ end
280
+
281
+ def cleanup_stale_data
282
+ unless stale_attributes.blank?
283
+ stale_attributes_params = Array(stale_attributes).zip([]).to_h
284
+ self.attributes = stale_attributes_params
285
+ resource.attributes = stale_attributes_params
286
+ end
287
+ end
288
+
289
+ private
290
+
291
+ def _permit_params!(params)
292
+ case params
293
+ when ActionController::Parameters
294
+ params.permit(*permitted_params)
295
+ when Hash
296
+ normalized_permitted_params = permitted_params.flat_map { |param| param.is_a?(Hash) ? param.keys : param }
297
+ params.slice(*normalized_permitted_params)
298
+ else
299
+ raise ArgumentError, "expected one of ActionController::Parameters, Hash but received #{params.class}"
300
+ end
301
+ end
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_controller"
4
+
5
+ module Wicked
6
+ module Pipeline
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace Wicked::Pipeline
9
+
10
+ def self.i18n_scope
11
+ @i18n_scope ||= name.split("::").tap(&:pop).map(&:underscore).join(".")
12
+ end
13
+
14
+ config.generators do |g|
15
+ g.helper false
16
+ end
17
+
18
+ initializer :i18n_load_path do |app|
19
+ ActiveSupport.on_load(:i18n) do
20
+ # Prepend the engine i18n load path so that translations can be overriden in the main app
21
+ engine_i18n_load_path = Dir[Engine.root.join("config", "locales", "**", "wicked", "pipeline", "**", "*.yml").to_s]
22
+ app.config.i18n.load_path = engine_i18n_load_path.concat(app.config.i18n.load_path)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wicked
4
+ module Pipeline
5
+ class ReadonlyStep < BaseStep
6
+ # @return true
7
+ def readonly?
8
+ true
9
+ end
10
+
11
+ # @return false
12
+ def blocking?
13
+ false
14
+ end
15
+
16
+ protected
17
+
18
+ def permitted_params
19
+ []
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wicked
4
+ module Pipeline
5
+ VERSION = "0.1.0"
6
+ end
7
+ end