turbo_boost-commands 0.2.2 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +96 -29
  3. data/app/assets/builds/@turbo-boost/commands.js +1 -1
  4. data/app/assets/builds/@turbo-boost/commands.js.map +4 -4
  5. data/app/assets/builds/@turbo-boost/commands.metafile.json +1 -1
  6. data/app/controllers/concerns/turbo_boost/commands/controller.rb +1 -1
  7. data/app/javascript/elements.js +0 -1
  8. data/app/javascript/events.js +6 -3
  9. data/app/javascript/headers.js +2 -2
  10. data/app/javascript/index.js +20 -11
  11. data/app/javascript/invoker.js +2 -10
  12. data/app/javascript/lifecycle.js +3 -6
  13. data/app/javascript/logger.js +29 -2
  14. data/app/javascript/renderer.js +11 -5
  15. data/app/javascript/schema.js +2 -1
  16. data/app/javascript/state/index.js +47 -34
  17. data/app/javascript/state/observable.js +1 -1
  18. data/app/javascript/state/page.js +33 -0
  19. data/app/javascript/state/storage.js +11 -0
  20. data/app/javascript/turbo.js +0 -10
  21. data/app/javascript/version.js +1 -1
  22. data/lib/turbo_boost/commands/attribute_set.rb +8 -0
  23. data/lib/turbo_boost/commands/command.rb +8 -3
  24. data/lib/turbo_boost/commands/command_callbacks.rb +23 -6
  25. data/lib/turbo_boost/commands/command_validator.rb +44 -0
  26. data/lib/turbo_boost/commands/controller_pack.rb +10 -10
  27. data/lib/turbo_boost/commands/engine.rb +14 -10
  28. data/lib/turbo_boost/commands/errors.rb +15 -8
  29. data/lib/turbo_boost/commands/{middleware.rb → middlewares/entry_middleware.rb} +30 -21
  30. data/lib/turbo_boost/commands/middlewares/exit_middleware.rb +63 -0
  31. data/lib/turbo_boost/commands/patches/action_view_helpers_tag_helper_tag_builder_patch.rb +10 -2
  32. data/lib/turbo_boost/commands/responder.rb +28 -0
  33. data/lib/turbo_boost/commands/runner.rb +150 -186
  34. data/lib/turbo_boost/commands/sanitizer.rb +1 -1
  35. data/lib/turbo_boost/commands/state.rb +97 -47
  36. data/lib/turbo_boost/commands/state_store.rb +72 -0
  37. data/lib/turbo_boost/commands/token_validator.rb +51 -0
  38. data/lib/turbo_boost/commands/version.rb +1 -1
  39. metadata +29 -8
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class TurboBoost::Commands::Middleware
3
+ class TurboBoost::Commands::EntryMiddleware
4
4
  PATH = "/turbo-boost-command-invocation"
5
5
 
6
6
  def initialize(app)
@@ -35,6 +35,11 @@ class TurboBoost::Commands::Middleware
35
35
  false
36
36
  end
37
37
 
38
+ def convert_to_get_request?(driver)
39
+ return true if driver == "frame" || driver == "window"
40
+ false
41
+ end
42
+
38
43
  # Modifies the given POST request so Rails sees it as GET.
39
44
  #
40
45
  # The posted JSON body content holds the TurboBoost Command meta data.
@@ -42,19 +47,21 @@ class TurboBoost::Commands::Middleware
42
47
  #
43
48
  # @example POST payload for: /turbo-boost-command-invocation
44
49
  # {
45
- # "id" => "turbo-command-f824ded1-a86e-4a36-9442-ea2165a64569", # unique command invocation id
46
- # "name" => "IncrementCountCommand", # the command being invoked
47
- # "elementId" => nil, # the triggering element's dom id
48
- # "elementAttributes" => {"tag"=>"BUTTON", "checked"=>false, "disabled"=>false, "value"=>nil}, # the triggering element's attributes
49
- # "startedAt" => 1708213193567, # the time the command was invoked
50
- # "changedState" => {}, # the delta of optimistic state changes made on the client
51
- # "clientState" => { # the state as it was on the client
52
- # "command_token" => "IlU0dVVhNElFdkVCZVUi--a878d33d85ed9b9611c155ed1d7bb8785fb..."} # the command token used for forgery protection
50
+ # "csrfToken" => "..." # Rails' CSRF token
51
+ # "id" => "turbo-command-f824ded1-a86e-4a36-9442-ea2165a64569", # Unique ID for the command invocation
52
+ # "name" => "ExampleCommand#perform", # Name of command being invoked
53
+ # "elementId" => nil, # Triggering element's DOM id
54
+ # "elementAttributes" => {...}, # Triggering element's attributes
55
+ # "startedAt" => 1708213193567, # Time the command was invoked
56
+ # "elementCache" => {...}, # Cache of ALL tracked element attributes (optimistic changes)
57
+ # "state" => { # State ... TODO: HOPSOFT
58
+ # "page" => {...}, # - transient page state (element attributes, etc.)
59
+ # "signed" => "", # - signed state used for the last server render (untampered)
60
+ # "unsigned" => {...} # - state with optimistic changes from the client
53
61
  # },
54
- # "signedState" => "eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2R1VuaXZlcnNhbElEOjpFeH...", # the state as it was on the server at the time of the last command invocation
55
- # "driver" => "frame", # the driver used to invoke the command
56
- # "frameId" => "basic_command-turbo-frame", # the turbo-frame id (if applicable)
57
- # "src" => "/basic_command.turbo_stream" # the URL to present to Rails (turbo-frame src, window location, etc.)
62
+ # "driver" => "frame", # Driver used to invoke the command
63
+ # "frameId" => "...", # TurboFrame id (if applicable)
64
+ # "src" => "..." # URL to present to Rails (turbo-frame src, window location, etc.)
58
65
  # }
59
66
  #
60
67
  # @param request [Rack::Request] the request to modify
@@ -64,20 +71,22 @@ class TurboBoost::Commands::Middleware
64
71
 
65
72
  request.env.tap do |env|
66
73
  # Store the command params in the environment
67
- env["turbo_boost_command"] = params
68
-
69
- # Change the method from POST to GET
70
- env["REQUEST_METHOD"] = "GET"
74
+ env["turbo_boost_command_params"] = params
71
75
 
72
76
  # Update the URI, PATH_INFO, and QUERY_STRING
73
77
  env["REQUEST_URI"] = uri.to_s if env.key?("REQUEST_URI")
74
78
  env["PATH_INFO"] = uri.path
75
79
  env["QUERY_STRING"] = uri.query.to_s
76
80
 
77
- # Clear the body and related headers so the appears and behaves like a GET
78
- env["rack.input"] = StringIO.new
79
- env["CONTENT_LENGTH"] = "0"
80
- env.delete("CONTENT_TYPE")
81
+ # Change the method from POST to GET
82
+ if convert_to_get_request?(params["driver"])
83
+ env["REQUEST_METHOD"] = "GET"
84
+
85
+ # Clear the body and related headers so the appears and behaves like a GET
86
+ env["rack.input"] = StringIO.new
87
+ env["CONTENT_LENGTH"] = "0"
88
+ env.delete("CONTENT_TYPE")
89
+ end
81
90
  end
82
91
  rescue => error
83
92
  puts "#{self.class.name} failed to modify the request! #{error.message}"
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TurboBoost::Commands::ExitMiddleware
4
+ BODY_PATTERN = /<\/\s*body/io
5
+ TURBO_FRAME_PATTERN = /<\/\s*turbo-frame/io
6
+ TURBO_STREAM_PATTERN = /<\/\s*turbo-stream/io
7
+ TAIL_PATTERN = /\z/io
8
+
9
+ def initialize(app)
10
+ @app = app
11
+ end
12
+
13
+ def call(env)
14
+ @params = env["turbo_boost_command_params"]
15
+ response = @app.call(env)
16
+ return modify!(env, response) if modify?(env)
17
+ response
18
+ end
19
+
20
+ private
21
+
22
+ def modify?(env)
23
+ env["turbo_boost_command_responder"].is_a? TurboBoost::Commands::Responder
24
+ end
25
+
26
+ def modify!(env, response)
27
+ responder = env["turbo_boost_command_responder"]
28
+ status, headers, body = response
29
+ new_body = body_to_a(body).join
30
+
31
+ case response_type(new_body)
32
+ when :body
33
+ match = new_body.match(BODY_PATTERN).to_s
34
+ new_body.sub! BODY_PATTERN, [responder.body, match].join
35
+ when :frame
36
+ match = new_body.match(TURBO_FRAME_PATTERN).to_s
37
+ new_body.sub! TURBO_FRAME_PATTERN, [responder.body, match].join
38
+ else
39
+ new_body << responder.body
40
+ end
41
+
42
+ [status, headers.merge(responder.headers), [new_body]]
43
+ rescue => error
44
+ Rails.logger.error "TurboBoost::Commands::Runner failed to append to the response! #{error.message}"
45
+ [status, headers, body]
46
+ end
47
+
48
+ def body_to_a(body)
49
+ return [] if body.nil?
50
+ return body if body.is_a?(Array)
51
+ return [body] if body.is_a?(String)
52
+ return body.to_ary if body.respond_to?(:to_ary)
53
+ return body.each.to_a if body.respond_to?(:each)
54
+ [body.to_s]
55
+ end
56
+
57
+ def response_type(body)
58
+ return :body if body.match?(BODY_PATTERN)
59
+ return :frame if body.match?(TURBO_FRAME_PATTERN)
60
+ return :stream if body.match?(TURBO_STREAM_PATTERN)
61
+ :unknown
62
+ end
63
+ end
@@ -4,7 +4,15 @@ require_relative "../attribute_hydration"
4
4
 
5
5
  module TurboBoost::Commands::Patches::ActionViewHelpersTagHelperTagBuilderPatch
6
6
  def tag_options(options, ...)
7
- dehydrated_options = TurboBoost::Commands::AttributeHydration.dehydrate(options)
8
- super(dehydrated_options, ...)
7
+ options = turbo_boost&.state&.tag_options(options) || options
8
+ options = TurboBoost::Commands::AttributeHydration.dehydrate(options)
9
+ super(options, ...)
10
+ end
11
+
12
+ private
13
+
14
+ def turbo_boost
15
+ return nil unless @view_context.respond_to?(:turbo_boost)
16
+ @view_context.turbo_boost
9
17
  end
10
18
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TurboBoost::Commands::Responder
4
+ attr_accessor :headers
5
+
6
+ def initialize
7
+ @headers = HashWithIndifferentAccess.new
8
+ @body = Set.new
9
+ end
10
+
11
+ def body
12
+ @body.join.html_safe
13
+ end
14
+
15
+ def add_header(key, value)
16
+ headers[key.to_s.downcase] = value.to_s
17
+ end
18
+
19
+ def add_content(content)
20
+ @body << sanitizer.sanitize(content) + "\n"
21
+ end
22
+
23
+ private
24
+
25
+ def sanitizer
26
+ TurboBoost::Commands::Sanitizer.instance
27
+ end
28
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "sanitizer"
3
+ require_relative "responder"
4
4
  require_relative "state"
5
+ require_relative "command_validator"
6
+ require_relative "token_validator"
5
7
 
6
8
  class TurboBoost::Commands::Runner
7
9
  RESPONSE_HEADER = "TurboBoost-Command"
@@ -12,56 +14,24 @@ class TurboBoost::Commands::Runner
12
14
  "text/vnd.turbo-stream.html" => true
13
15
  }.freeze
14
16
 
15
- attr_reader :controller
17
+ attr_reader :controller, :error, :responder
16
18
 
17
19
  def initialize(controller)
18
20
  @controller = controller
21
+ @responder = TurboBoost::Commands::Responder.new
19
22
  end
20
23
 
21
- def command_state
22
- @command_state ||= begin
23
- sgid = command_params[:signed_state]
24
- value = TurboBoost::Commands::State.from_sgid_param(sgid) if sgid
25
- value || TurboBoost::Commands::State.new
26
- end
24
+ def state
25
+ @state ||= TurboBoost::Commands::State.new(command_params.fetch(:state, {}))
27
26
  end
28
27
 
29
28
  def command_requested?
30
- controller.request.env.key?("turbo_boost_command") || controller.params.key?("turbo_boost_command")
31
- end
32
-
33
- def command_valid?
34
- return false unless command_requested?
35
-
36
- # validate class
37
- unless command_instance.is_a?(TurboBoost::Commands::Command)
38
- raise TurboBoost::Commands::InvalidClassError,
39
- "`#{command_class_name}` is not a subclass of `TurboBoost::Commands::Command`!"
40
- end
41
-
42
- # validate method
43
- ancestors = command_class.ancestors[0..command_class.ancestors.index(TurboBoost::Commands::Command) - 1]
44
- unless ancestors.any? { |a| a.public_instance_methods(false).any? command_method_name.to_sym }
45
- raise TurboBoost::Commands::InvalidMethodError,
46
- "`#{command_class_name}` does not define the public method `#{command_method_name}`!"
47
- end
48
-
49
- # validate csrf token
50
- unless valid_command_token?
51
- raise TurboBoost::Commands::InvalidTokenError,
52
- "Token mismatch! The token: #{client_command_token}` does not match the expected value of `#{server_command_token}`."
53
- end
54
-
55
- true
29
+ controller.request.env.key?("turbo_boost_command_params") || controller.params.key?("turbo_boost_command")
56
30
  end
57
31
 
58
32
  def command_params
59
- return ActionController::Parameters.new unless command_requested?
60
- @command_params ||= begin
61
- payload = parsed_command_params.transform_keys(&:underscore)
62
- payload["element_attributes"]&.deep_transform_keys!(&:underscore)
63
- ActionController::Parameters.new(payload).permit!
64
- end
33
+ return ActionController::Parameters.new.permit! unless command_requested?
34
+ @command_params ||= ActionController::Parameters.new(parsed_command_params).permit!
65
35
  end
66
36
 
67
37
  def command_name
@@ -87,17 +57,28 @@ class TurboBoost::Commands::Runner
87
57
  end
88
58
 
89
59
  def command_instance
90
- @command_instance ||= command_class&.new(controller, command_state, command_params).tap do |instance|
60
+ @command_instance ||= command_class&.new(controller, state, command_params).tap do |instance|
91
61
  instance&.add_observer self, :handle_command_event
92
62
  end
93
63
  end
94
64
 
65
+ def command_valid?
66
+ return false unless command_requested?
67
+
68
+ validator = TurboBoost::Commands::CommandValidator.new(command_instance, command_method_name)
69
+ raise_on_invalid_command? ? validator.validate! : validator.valid?
70
+
71
+ validator = TurboBoost::Commands::TokenValidator.new(command_instance, command_method_name)
72
+ raise_on_invalid_command? ? validator.validate! : validator.valid?
73
+ end
74
+
95
75
  def command_aborted?
96
76
  !!command_instance&.aborted?
97
77
  end
98
78
 
99
79
  def command_errored?
100
- !!command_instance&.errored?
80
+ return false if command_aborted?
81
+ command_instance&.errored? || error.present?
101
82
  end
102
83
 
103
84
  def command_performing?
@@ -105,7 +86,7 @@ class TurboBoost::Commands::Runner
105
86
  end
106
87
 
107
88
  def command_performed?
108
- !!command_instance&.performed?
89
+ command_instance&.performed? || command_errored?
109
90
  end
110
91
 
111
92
  def command_succeeded?
@@ -132,48 +113,51 @@ class TurboBoost::Commands::Runner
132
113
  return if command_performing?
133
114
  return if command_performed?
134
115
 
135
- command_instance.resolve_state command_params[:changed_state]
136
116
  command_instance.perform_with_callbacks command_method_name
117
+ rescue => error
118
+ prevent_controller_action error: error if command_requested?
137
119
  end
138
120
 
139
- def prevent_controller_action(error: nil)
140
- return if controller_action_prevented?
141
- @controller_action_prevented = true
121
+ # Always runs after the controller action has been performed
122
+ def flush
123
+ return unless command_requested? # not a command request
142
124
 
143
- case error
144
- when nil
145
- render_response status: response_status
146
- append_success_to_response
147
- when TurboBoost::Commands::AbortError
148
- render_response status: error.http_status_code, status_header: error.message
149
- append_streams_to_response_body
150
- when TurboBoost::Commands::PerformError
151
- render_response status: error.http_status_code, status_header: error.message
152
- append_error_to_response error
153
- else
154
- render_response status: :internal_server_error, status_header: error.message
155
- append_error_to_response error
156
- end
125
+ # global response components
126
+ add_header
127
+ add_state
157
128
 
158
- append_command_state_to_response_body
159
- end
129
+ # mutually exclusive responses
130
+ respond_with_abort if command_aborted?
131
+ respond_with_error if command_errored?
132
+ respond_with_success if command_succeeded?
160
133
 
161
- def update_response
162
- return if @update_response_performed
163
- @update_response_performed = true
134
+ # store the responder for use in → TurboBoost::Commands::ExitMiddleware
135
+ controller.request.env["turbo_boost_command_responder"] = responder
136
+ rescue => error
137
+ Rails.logger.error "TurboBoost::Commands::Runner failed to update the response! #{error.message}"
138
+ end
164
139
 
140
+ def prevent_controller_action(error: nil)
165
141
  return if controller_action_prevented?
142
+ @controller_action_prevented = true
143
+ @error = error
166
144
 
167
- append_command_state_to_response_body
168
- append_to_response_headers if command_performed?
169
- append_success_to_response if command_succeeded?
170
- rescue => error
171
- Rails.logger.error "TurboBoost::Commands::Runner failed to update the response! #{error.message}"
145
+ status = case error
146
+ when nil then response_status
147
+ when TurboBoost::Commands::AbortError, TurboBoost::Commands::PerformError then error.http_status_code
148
+ else :internal_server_error
149
+ end
150
+
151
+ flush
152
+ render_response status: status
172
153
  end
173
154
 
174
- def render_response(html: "", status: nil, status_header: nil)
175
- controller.render html: html, layout: false, status: status || response_status # unless controller.performed?
176
- append_to_response_headers status_header
155
+ # Renders the response body instead of the controller action.
156
+ # Invoked by: `prevent_controller_action`
157
+ # NOTE: Halts the Rails controller callback chain
158
+ def render_response(html: "", status: nil)
159
+ return if controller.performed?
160
+ controller.render html: html, layout: false, status: status || response_status
177
161
  end
178
162
 
179
163
  def turbo_stream
@@ -199,53 +183,46 @@ class TurboBoost::Commands::Runner
199
183
 
200
184
  def parsed_command_params
201
185
  @parsed_command_params ||= begin
202
- params = controller.request.env["turbo_boost_command"]
203
- params ||= JSON.parse(controller.params["turbo_boost_command"])
204
- params || {}
186
+ params = controller.request.env["turbo_boost_command_params"]
187
+ params ||= controller.request.env["turbo_boost_command_params"] = JSON.parse(controller.params["turbo_boost_command"])
188
+ params.deep_transform_keys!(&:underscore)
189
+ params
205
190
  end
206
191
  end
207
192
 
208
- def content_sanitizer
209
- TurboBoost::Commands::Sanitizer.instance
210
- end
211
-
212
- def new_command_token
213
- @new_command_token ||= SecureRandom.alphanumeric(13)
214
- end
215
-
216
- def client_command_token
217
- command_params.dig(:client_state, :command_token)
193
+ def alert_on_abort?
194
+ return false unless TurboBoost::Commands.config.alert_on_abort
195
+ return true if TurboBoost::Commands.config.alert_on_abort == true
196
+ return true if TurboBoost::Commands.config.alert_on_abort.to_s == Rails.env.to_s
197
+ false
218
198
  end
219
199
 
220
- def server_command_token
221
- command_state[:command_token]
200
+ def alert_on_error?
201
+ return false unless TurboBoost::Commands.config.alert_on_error
202
+ return true if TurboBoost::Commands.config.alert_on_error == true
203
+ return true if TurboBoost::Commands.config.alert_on_error.to_s == Rails.env.to_s
204
+ false
222
205
  end
223
206
 
224
- def valid_command_token?
225
- return true unless Rails.configuration.turbo_boost_commands.protect_from_forgery
226
- return false unless client_command_token.present?
227
- return false unless server_command_token.present?
228
- server_command_token == message_verifier.verify(client_command_token, purpose: controller.request.session&.id)
229
- rescue ActiveSupport::MessageVerifier::InvalidSignature
207
+ def raise_on_invalid_command?
208
+ return false unless TurboBoost::Commands.config.raise_on_invalid_command
209
+ return true if TurboBoost::Commands.config.raise_on_invalid_command == true
210
+ return true if TurboBoost::Commands.config.raise_on_invalid_command.to_s == Rails.env.to_s
230
211
  false
231
212
  end
232
213
 
233
- def should_redirect?
214
+ def redirect?
234
215
  return false if controller.request.get?
235
216
  controller.request.accepts.include? Mime::Type.lookup_by_extension(:turbo_stream)
236
217
  end
237
218
 
238
- def response_status
239
- return :multiple_choices if should_redirect?
240
- :ok
219
+ def supported_media_type?
220
+ SUPPORTED_MEDIA_TYPES[controller.request.format.to_s]
241
221
  end
242
222
 
243
- def response_type
244
- body = (controller.response_body.try(:join) || controller.response_body.to_s).strip
245
- return :body if body.match?(/<\/\s*body/io)
246
- return :frame if body.match?(/<\/\s*turbo-frame/io)
247
- return :stream if body.match?(/<\/\s*turbo-stream/io)
248
- :unknown
223
+ def response_status
224
+ return :multiple_choices if redirect?
225
+ :ok
249
226
  end
250
227
 
251
228
  # Indicates if a TurboStream template exists for the current action.
@@ -255,14 +232,15 @@ class TurboBoost::Commands::Runner
255
232
  controller.lookup_context.exists? controller.action_name, controller.lookup_context.prefixes, formats: [:turbo_boost, :turbo_stream]
256
233
  end
257
234
 
258
- def rendering_strategy
235
+ # Commands support the following redering strategies on the client.
236
+ # 1. Replace: The entire page (head, body) is replaced with the new content via morph
237
+ # 2. Append: The new content is appended to the body
238
+ def client_render_strategy
259
239
  # Use the replace strategy if the follow things are true:
260
240
  #
261
241
  # 1. The command was triggered by the WINDOW driver
262
242
  # 2. After the command finishes, normal Rails mechanics resume (i.e. prevent_controller_action was not called)
263
243
  # 3. There is NO TurboStream template for the current action (i.e. example.turbo_boost.erb, example.turbo_frame.erb)
264
- #
265
- # TODO: Revisit the "Replace" strategy after morph ships with Turbo 8
266
244
  if command_params[:driver] == "window" && controller_action_allowed?
267
245
  return "Replace" unless turbo_stream_template_exists?
268
246
  end
@@ -270,112 +248,98 @@ class TurboBoost::Commands::Runner
270
248
  "Append"
271
249
  end
272
250
 
273
- def append_success_to_response
274
- append_success_event_to_response_body
275
- append_streams_to_response_body
251
+ def respond_with_abort
252
+ Rails.logger.debug error.message
253
+ add_abort_event
254
+ add_error_alert if alert_on_abort?
276
255
  end
277
256
 
278
- def append_error_to_response(error)
257
+ def respond_with_error
279
258
  Rails.logger.error error.message
280
- append_error_event_to_response_body error.message
281
- append_error_alert_to_response_body error.message
259
+ add_error_event
260
+ add_error_alert if alert_on_error?
282
261
  end
283
262
 
284
- def append_streams_to_response_body
285
- command_instance.turbo_streams.each { |stream| append_to_response_body stream }
263
+ def respond_with_success
264
+ add_success_event
265
+ add_turbo_streams
286
266
  end
287
267
 
288
- def append_command_state_to_response_body
289
- # use the masked token for the client state
290
- command_state[:command_token] = message_verifier.generate(new_command_token, purpose: controller.request.session&.id)
291
- client_state = command_state.to_json
292
-
293
- # use the unmasked token for the signed (server) state
294
- command_state[:command_token] = new_command_token
295
- signed_state = command_state.to_sgid_param
268
+ def add_turbo_streams
269
+ command_instance.turbo_streams.each { |stream| add_content stream }
270
+ end
296
271
 
297
- append_to_response_body turbo_stream.invoke("TurboBoost.State.initialize", args: [client_state, signed_state], camelize: false)
272
+ def add_state
273
+ add_content turbo_stream.invoke("TurboBoost.State.initialize", args: [state.to_json], camelize: false)
298
274
  rescue => error
299
- Rails.logger.error "TurboBoost::Commands::Runner failed to append the Command state to the response! #{error.message}"
275
+ message = "TurboBoost::Commands::Runner failed to append the Command state to the response! #{error.message}"
276
+ Rails.logger.error message
300
277
  end
301
278
 
302
- def append_success_event_to_response_body
303
- args = ["turbo-boost:command:success", {bubbles: true, cancelable: false, detail: parsed_command_params}]
304
- event = if command_instance&.element.try(:id).present?
305
- turbo_stream.invoke :dispatch_event, args: args, selector: "##{command_instance.element.id}"
306
- else
307
- turbo_stream.invoke :dispatch_event, args: args
308
- end
309
- append_to_response_body event
279
+ def add_event(name, detail = {})
280
+ options = {
281
+ args: [name, {
282
+ bubbles: true,
283
+ cancelable: false,
284
+ detail: command_params.to_unsafe_hash.except(:state)
285
+ .merge(detail).deep_transform_keys! { |key| key.to_s.camelize(:lower).to_sym }
286
+ }]
287
+ }
288
+
289
+ options[:selector] = "##{command_instance.element.id}" if command_instance&.element&.id
290
+ add_content turbo_stream.invoke(:dispatch_event, **options)
291
+ end
292
+
293
+ def add_success_event
294
+ return unless command_succeeded?
295
+ add_event "turbo-boost:command:success"
310
296
  end
311
297
 
312
- def append_error_alert_to_response_body(message)
313
- return unless Rails.env.development?
298
+ def add_abort_event
299
+ return unless error && command_aborted?
300
+ add_event "turbo-boost:command:abort", message: error.message
301
+ end
302
+
303
+ def add_error_event
304
+ return unless error
305
+ add_event "turbo-boost:command:server-error", message: error.message
306
+ end
307
+
308
+ def add_error_alert
309
+ return unless error
310
+
314
311
  message = <<~MSG
315
- #{message}
312
+ #{error.message}
313
+
314
+ ---
316
315
 
317
- See the HTTP header: `TurboBoost-Command-Status`
316
+ See the HTTP header: `TurboBoost-Command`
318
317
 
319
318
  Also check the JavaScript console if `TurboBoost.Commands.logger.level` has been set.
320
319
 
321
320
  Finally, check server logs for additional info.
322
321
  MSG
323
- append_to_response_body turbo_stream.invoke(:alert, args: [message])
324
- end
325
-
326
- def append_error_event_to_response_body(message)
327
- args = ["turbo-boost:command:server-error", {bubbles: true, cancelable: false, detail: parsed_command_params.merge(error: message)}]
328
- event = if command_instance&.element.try(:id).present?
329
- turbo_stream.invoke :dispatch_event, args: args, selector: "##{command_instance.element.id}"
330
- else
331
- turbo_stream.invoke :dispatch_event, args: args
332
- end
333
- append_to_response_body event
334
- end
335
322
 
336
- def appended_content
337
- @appended_content ||= {}
323
+ add_content turbo_stream.invoke(:alert, args: [message.strip], delay: 100)
338
324
  end
339
325
 
340
- def append_to_response_body(content)
341
- return unless SUPPORTED_MEDIA_TYPES[controller.response.media_type]
342
- sanitized_content = content_sanitizer.sanitize(content.to_s).html_safe
343
- return if sanitized_content.blank?
344
-
345
- return if appended_content[sanitized_content]
346
- appended_content[sanitized_content] = true
347
-
348
- html = case response_type
349
- when :body
350
- match = controller.response.body.match(/<\/\s*body/io).to_s
351
- controller.response.body.sub match, [sanitized_content, match].join
352
- when :frame
353
- match = controller.response.body.match(/<\/\s*turbo-frame/io).to_s
354
- controller.response.body.sub match, [sanitized_content, match].join
355
- else
356
- [controller.response.body, sanitized_content].join
357
- end
326
+ def add_header
327
+ return unless command_requested?
328
+ return unless supported_media_type?
358
329
 
359
- controller.response.body = html
360
- rescue => error
361
- Rails.logger.error "TurboBoost::Commands::Runner failed to append to the response! #{error.message}"
362
- end
330
+ command_status = "Abort" if command_aborted?
331
+ command_status = "Error" if command_errored?
332
+ command_status = "OK" if command_succeeded?
333
+ command_status ||= "Unknown"
363
334
 
364
- # Writes new header... will not overwrite existing header
365
- def append_response_header(key, value)
366
- return if controller.response.get_header key.to_s
367
- controller.response.set_header key.to_s, value.to_s
335
+ values = [command_name, command_status, client_render_strategy]
336
+ responder.add_header RESPONSE_HEADER, values.join(", ")
368
337
  end
369
338
 
370
- def append_to_response_headers(status = nil)
339
+ def add_content(content)
371
340
  return unless command_performed?
341
+ return unless supported_media_type?
372
342
 
373
- values = [
374
- status || "#{controller.response.status} #{TurboBoost::Commands::HTTP_STATUS_CODES[controller.response.status]}".delete(","),
375
- rendering_strategy,
376
- command_name
377
- ]
378
-
379
- append_response_header RESPONSE_HEADER, values.join(", ")
343
+ responder.add_content content
380
344
  end
381
345
  end