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.
- checksums.yaml +4 -4
- data/README.md +122 -37
- data/app/assets/builds/@turbo-boost/commands.js +1 -1
- data/app/assets/builds/@turbo-boost/commands.js.map +4 -4
- data/app/assets/builds/@turbo-boost/commands.metafile.json +1 -1
- data/app/controllers/concerns/turbo_boost/commands/controller.rb +1 -1
- data/app/javascript/drivers/index.js +1 -1
- data/app/javascript/elements.js +0 -1
- data/app/javascript/events.js +6 -3
- data/app/javascript/headers.js +2 -2
- data/app/javascript/index.js +20 -11
- data/app/javascript/invoker.js +2 -10
- data/app/javascript/lifecycle.js +3 -6
- data/app/javascript/logger.js +29 -2
- data/app/javascript/renderer.js +11 -5
- data/app/javascript/schema.js +2 -1
- data/app/javascript/state/index.js +50 -33
- data/app/javascript/state/observable.js +1 -1
- data/app/javascript/state/page.js +34 -0
- data/app/javascript/state/storage.js +11 -0
- data/app/javascript/turbo.js +0 -10
- data/app/javascript/version.js +1 -1
- data/lib/turbo_boost/commands/attribute_set.rb +8 -0
- data/lib/turbo_boost/commands/command.rb +8 -3
- data/lib/turbo_boost/commands/command_callbacks.rb +23 -6
- data/lib/turbo_boost/commands/command_validator.rb +44 -0
- data/lib/turbo_boost/commands/controller_pack.rb +10 -10
- data/lib/turbo_boost/commands/engine.rb +14 -10
- data/lib/turbo_boost/commands/errors.rb +15 -8
- data/lib/turbo_boost/commands/{middleware.rb → middlewares/entry_middleware.rb} +30 -21
- data/lib/turbo_boost/commands/middlewares/exit_middleware.rb +63 -0
- data/lib/turbo_boost/commands/patches/action_view_helpers_tag_helper_tag_builder_patch.rb +10 -2
- data/lib/turbo_boost/commands/responder.rb +28 -0
- data/lib/turbo_boost/commands/runner.rb +150 -186
- data/lib/turbo_boost/commands/sanitizer.rb +1 -1
- data/lib/turbo_boost/commands/state.rb +97 -47
- data/lib/turbo_boost/commands/state_store.rb +72 -0
- data/lib/turbo_boost/commands/token_validator.rb +51 -0
- data/lib/turbo_boost/commands/version.rb +1 -1
- metadata +29 -8
@@ -1,7 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative "
|
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
|
22
|
-
@
|
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?("
|
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 ||=
|
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,
|
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
|
-
|
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
|
-
|
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
|
-
|
140
|
-
|
141
|
-
|
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
|
-
|
144
|
-
|
145
|
-
|
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
|
-
|
159
|
-
|
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
|
-
|
162
|
-
|
163
|
-
|
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
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
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
|
-
|
175
|
-
|
176
|
-
|
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["
|
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
|
209
|
-
TurboBoost::Commands
|
210
|
-
|
211
|
-
|
212
|
-
|
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
|
221
|
-
|
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
|
225
|
-
return
|
226
|
-
return
|
227
|
-
return
|
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
|
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
|
239
|
-
|
240
|
-
:ok
|
219
|
+
def supported_media_type?
|
220
|
+
SUPPORTED_MEDIA_TYPES[controller.request.format.to_s]
|
241
221
|
end
|
242
222
|
|
243
|
-
def
|
244
|
-
|
245
|
-
|
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
|
-
|
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
|
274
|
-
|
275
|
-
|
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
|
257
|
+
def respond_with_error
|
279
258
|
Rails.logger.error error.message
|
280
|
-
|
281
|
-
|
259
|
+
add_error_event
|
260
|
+
add_error_alert if alert_on_error?
|
282
261
|
end
|
283
262
|
|
284
|
-
def
|
285
|
-
|
263
|
+
def respond_with_success
|
264
|
+
add_success_event
|
265
|
+
add_turbo_streams
|
286
266
|
end
|
287
267
|
|
288
|
-
def
|
289
|
-
|
290
|
-
|
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
|
-
|
272
|
+
def add_state
|
273
|
+
add_content turbo_stream.invoke("TurboBoost.State.initialize", args: [state.to_json], camelize: false)
|
298
274
|
rescue => error
|
299
|
-
|
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
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
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
|
313
|
-
return unless
|
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
|
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
|
-
|
337
|
-
@appended_content ||= {}
|
323
|
+
add_content turbo_stream.invoke(:alert, args: [message.strip], delay: 100)
|
338
324
|
end
|
339
325
|
|
340
|
-
def
|
341
|
-
return unless
|
342
|
-
|
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
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
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
|
-
|
365
|
-
|
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
|
339
|
+
def add_content(content)
|
371
340
|
return unless command_performed?
|
341
|
+
return unless supported_media_type?
|
372
342
|
|
373
|
-
|
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
|
@@ -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
|
-
|
7
|
-
|
8
|
-
|
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
|
-
|
13
|
-
@
|
14
|
-
@
|
15
|
-
@
|
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
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
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
|
-
|
26
|
-
hash.to_h.each { |key, val| self[key] = val }
|
27
|
-
self
|
28
|
-
end
|
58
|
+
delegate :each, to: :current
|
29
59
|
|
30
|
-
|
31
|
-
|
60
|
+
# All state except unsigned (page + current).
|
61
|
+
# @return [HashWithIndifferentAccess]
|
62
|
+
def all
|
63
|
+
page.merge current
|
32
64
|
end
|
33
65
|
|
34
|
-
#
|
35
|
-
|
36
|
-
|
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
|
-
|
41
|
-
|
42
|
-
|
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
|
46
|
-
|
47
|
-
end
|
82
|
+
def tag_options(options = {})
|
83
|
+
return options unless options.is_a?(Hash)
|
48
84
|
|
49
|
-
|
50
|
-
|
51
|
-
end
|
85
|
+
options = options.deep_symbolize_keys
|
86
|
+
return options unless options.key?(:turbo_boost)
|
52
87
|
|
53
|
-
|
54
|
-
|
55
|
-
end
|
88
|
+
config = options.delete(:turbo_boost)
|
89
|
+
return options unless config.is_a?(Hash)
|
56
90
|
|
57
|
-
|
58
|
-
|
59
|
-
end
|
91
|
+
attributes = config[:remember]
|
92
|
+
return options if attributes.blank?
|
60
93
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
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
|
-
|
104
|
+
if options[:id].blank?
|
105
|
+
raise TurboBoost::Commands::StateError, "An `id` attribute is required for remembering state!"
|
106
|
+
end
|
67
107
|
|
68
|
-
|
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
|
-
|
71
|
-
store.instance_variable_get(:@data) || {}
|
121
|
+
options
|
72
122
|
end
|
73
123
|
end
|