turbo_boost-commands 0.2.2 → 0.3.1

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +122 -37
  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/drivers/index.js +1 -1
  8. data/app/javascript/elements.js +0 -1
  9. data/app/javascript/events.js +6 -3
  10. data/app/javascript/headers.js +2 -2
  11. data/app/javascript/index.js +20 -11
  12. data/app/javascript/invoker.js +2 -10
  13. data/app/javascript/lifecycle.js +3 -6
  14. data/app/javascript/logger.js +29 -2
  15. data/app/javascript/renderer.js +11 -5
  16. data/app/javascript/schema.js +2 -1
  17. data/app/javascript/state/index.js +50 -33
  18. data/app/javascript/state/observable.js +1 -1
  19. data/app/javascript/state/page.js +34 -0
  20. data/app/javascript/state/storage.js +11 -0
  21. data/app/javascript/turbo.js +0 -10
  22. data/app/javascript/version.js +1 -1
  23. data/lib/turbo_boost/commands/attribute_set.rb +8 -0
  24. data/lib/turbo_boost/commands/command.rb +8 -3
  25. data/lib/turbo_boost/commands/command_callbacks.rb +23 -6
  26. data/lib/turbo_boost/commands/command_validator.rb +44 -0
  27. data/lib/turbo_boost/commands/controller_pack.rb +10 -10
  28. data/lib/turbo_boost/commands/engine.rb +14 -10
  29. data/lib/turbo_boost/commands/errors.rb +15 -8
  30. data/lib/turbo_boost/commands/{middleware.rb → middlewares/entry_middleware.rb} +30 -21
  31. data/lib/turbo_boost/commands/middlewares/exit_middleware.rb +63 -0
  32. data/lib/turbo_boost/commands/patches/action_view_helpers_tag_helper_tag_builder_patch.rb +10 -2
  33. data/lib/turbo_boost/commands/responder.rb +28 -0
  34. data/lib/turbo_boost/commands/runner.rb +150 -186
  35. data/lib/turbo_boost/commands/sanitizer.rb +1 -1
  36. data/lib/turbo_boost/commands/state.rb +97 -47
  37. data/lib/turbo_boost/commands/state_store.rb +72 -0
  38. data/lib/turbo_boost/commands/token_validator.rb +51 -0
  39. data/lib/turbo_boost/commands/version.rb +1 -1
  40. metadata +29 -8
@@ -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
@@ -7,7 +7,7 @@ class TurboBoost::Commands::Sanitizer
7
7
  attr_reader :scrubber
8
8
 
9
9
  def sanitize(value)
10
- super(value, scrubber: scrubber)
10
+ super(value.to_s, scrubber: scrubber)
11
11
  end
12
12
 
13
13
  private
@@ -1,73 +1,123 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest/md5"
4
+ require_relative "state_store"
5
+
6
+ # Class that encapsulates all the various forms of state.
7
+ #
8
+ # 1. `page` - Client-side transient page state used for rendering remembered element attributes
9
+ # 2. `now` - Server-side state for the current render only (discarded after rendering)
10
+ # 3. `signed` - Server-side state that persists across renders (state that was used for the last server-side render)
11
+ # 4. `unsigned` - Client-side state (optimistic client-side changes)
12
+ # 5. `current` - Combined server-side state (signed + now)
13
+ # 6. `all` - All state except unsigned (signed + now + page)
14
+ #
3
15
  class TurboBoost::Commands::State
4
16
  include Enumerable
5
17
 
6
- class << self
7
- def from_sgid_param(sgid)
8
- new URI::UID.from_sgid(sgid, for: name)&.decode
9
- end
10
- end
18
+ def initialize(payload = {})
19
+ payload = payload.respond_to?(:to_unsafe_h) ? payload.to_unsafe_h : payload.to_h
20
+ payload = payload.with_indifferent_access
11
21
 
12
- def initialize(store = nil, provisional: false)
13
- @store = store || ActiveSupport::Cache::MemoryStore.new(expires_in: 1.day, size: 16.kilobytes)
14
- @store.cleanup
15
- @provisional = provisional
22
+ @now = {}.with_indifferent_access
23
+ @page = payload.fetch(:page, {}).with_indifferent_access
24
+ @signed = TurboBoost::Commands::StateStore.new(payload.fetch(:signed, {}))
25
+ @unsigned = payload.fetch(:unsigned, {}).with_indifferent_access
16
26
  end
17
27
 
18
- delegate :to_json, to: :to_h
19
- delegate_missing_to :store
20
-
21
- def dig(*keys)
22
- to_h.with_indifferent_access.dig(*keys)
28
+ # Client-side transient page state used for rendering remembered element attributes
29
+ # @return [HashWithIndifferentAccess]
30
+ attr_reader :page
31
+
32
+ # Server-side state for the current render only (similar to flash.now)
33
+ # @note Discarded after rendering
34
+ # @return [HashWithIndifferentAccess]
35
+ attr_reader :now
36
+
37
+ # Server-side state that persists across renders
38
+ # This is the state that was used for the last server-side render (untampered by the client)
39
+ # @return [TurboBoost::Commands::StateStore]
40
+ attr_reader :signed
41
+
42
+ # @note Most state will interactions work with the signed state, so we delegate missing methods to it.
43
+ delegate_missing_to :signed
44
+
45
+ # Client-side state (optimistic client-side changes)
46
+ # @note There is a hook on Command instances to resolve state `Command#resolve_state`,
47
+ # where Command authors can determine how to properly handle optimistic client-side state.
48
+ # @return [HashWithIndifferentAccess]
49
+ attr_reader :unsigned
50
+ alias_method :optimistic, :unsigned
51
+
52
+ # Combined server-side state (signed + now)
53
+ # @return [HashWithIndifferentAccess]
54
+ def current
55
+ signed.to_h.merge now
23
56
  end
24
57
 
25
- def merge!(hash = {})
26
- hash.to_h.each { |key, val| self[key] = val }
27
- self
28
- end
58
+ delegate :each, to: :current
29
59
 
30
- def each
31
- data.keys.each { |key| yield(key, self[key]) }
60
+ # All state except unsigned (page + current).
61
+ # @return [HashWithIndifferentAccess]
62
+ def all
63
+ page.merge current
32
64
  end
33
65
 
34
- # Provisional state is for the current request/response and is exposed as `State#now`
35
- # Standard state is preserved across multiple requests
36
- def provisional?
37
- !!@provisional
66
+ # Returns a cache key representing "all" state
67
+ def cache_key
68
+ "TurboBoost::Commands::State/#{Digest::MD5.base64digest(all.to_s)}"
38
69
  end
39
70
 
40
- def now
41
- return nil if provisional? # provisional state cannot hold child provisional state
42
- @now ||= self.class.new(provisional: true)
71
+ # A JSON representation of state that can be sent to the client
72
+ #
73
+ # Includes the following keys:
74
+ # * `signed` - The signed state (String)
75
+ # * `unsigned` - The unsigned state (Hash)
76
+ #
77
+ # @return [String]
78
+ def to_json
79
+ {signed: signed.to_sgid_param, unsigned: signed.to_h}.to_json(camelize: false)
43
80
  end
44
81
 
45
- def cache_key
46
- "TurboBoost::Commands::State/#{Digest::SHA2.base64digest(to_json)}"
47
- end
82
+ def tag_options(options = {})
83
+ return options unless options.is_a?(Hash)
48
84
 
49
- def read(...)
50
- now&.read(...) || store.read(...)
51
- end
85
+ options = options.deep_symbolize_keys
86
+ return options unless options.key?(:turbo_boost)
52
87
 
53
- def [](...)
54
- read(...)
55
- end
88
+ config = options.delete(:turbo_boost)
89
+ return options unless config.is_a?(Hash)
56
90
 
57
- def []=(...)
58
- write(...)
59
- end
91
+ attributes = config[:remember]
92
+ return options if attributes.blank?
60
93
 
61
- def to_sgid_param
62
- store.cleanup
63
- URI::UID.build(store, include_blank: false).to_sgid_param for: self.class.name, expires_in: 1.week
64
- end
94
+ attributes = begin
95
+ attributes.is_a?(Array) ? attributes : JSON.parse(attributes.to_s)
96
+ rescue
97
+ raise TurboBoost::Commands::StateError,
98
+ "Invalid `turbo_boost` options! `attributes` must be an Array of attributes to remember!"
99
+ end
100
+ attributes ||= []
101
+ attributes.map!(&:to_s).uniq!
102
+ return options if attributes.blank?
65
103
 
66
- private
104
+ if options[:id].blank?
105
+ raise TurboBoost::Commands::StateError, "An `id` attribute is required for remembering state!"
106
+ end
67
107
 
68
- attr_reader :store
108
+ options[:aria] ||= {}
109
+ options[:data] ||= {}
110
+ options[:data][:turbo_boost_state_attributes] = attributes.to_json
111
+
112
+ attributes.each do |name|
113
+ value = page.dig(options[:id], name)
114
+ case name
115
+ in String if name.start_with?("aria-") then options[:aria][name.delete_prefix("aria-").to_sym] = value
116
+ in String if name.start_with?("data-") then options[:data][name.delete_prefix("data-").to_sym] = value
117
+ else options[name.to_sym] = value
118
+ end
119
+ end
69
120
 
70
- def data
71
- store.instance_variable_get(:@data) || {}
121
+ options
72
122
  end
73
123
  end