render_turbo_stream 2.1.2 → 3.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,241 @@
1
+ module RenderTurboStream
2
+ module ControllerHelpers
3
+
4
+ # Handles translated flash messages as defined in translations and configs.
5
+ # If :redirect_on_success_to and channel set up and use_channel_for_turbo_stream_save are configured, sends flash message by channel_to_me.
6
+ # you can add more stream actions to the same response
7
+
8
+ def turbo_stream_save(
9
+ save_action,
10
+ redirect_on_success_to: nil, # does a regular redirect. Works if you are inside a turbo_frame and just want to redirect inside that frame BUT CANNOT STREAM OTHERS ACTIONS ON THE SAME RESPONSE https://github.com/rails/rails/issues/48056
11
+ turbo_redirect_on_success_to: nil, # does a full page redirect (break out of all frames by turbo_power redirect)
12
+ object: nil, # object used in save_action, example: @customer
13
+ id: nil, # if nil: no partial is rendered
14
+ partial: nil, # example: 'customers/form' default: "#{controller_path}/#{id}"
15
+ action: 'replace', # options: append, prepend
16
+ flash_action: action_name, # options: 'update', 'create', otherwise you have to declare a translation in config/locales like "activerecord.success.#{flash_action}" and "activerecord.errors.#{flash_action}"
17
+ locals: {}, # locals used by the partial
18
+ streams_on_success: [
19
+ {
20
+ id: nil,
21
+ partial: 'form',
22
+ locals: {},
23
+ action: 'replace'
24
+ }
25
+ ], # additional partials that should be rendered if save_action succeeded
26
+ streams_on_error: [
27
+ {
28
+ id: nil,
29
+ partial: 'form',
30
+ locals: {},
31
+ action: 'replace'
32
+ }
33
+ ], # additional partials that should be rendered if save_action failed
34
+ add_flash_alerts: [], #=> array of strings
35
+ add_flash_notices: [], #=> array of strings
36
+ flashes_on_success: [], #=> array of strings
37
+ flashes_on_error: [] #=> array of strings
38
+ )
39
+
40
+ unless object
41
+ object = eval("@#{controller_name.classify.underscore}")
42
+ end
43
+
44
+ #== Streams / Partials
45
+
46
+ streams = (id ? [id: id, partial: partial, locals: locals, action: action] : [])
47
+
48
+ if save_action
49
+ response.status = 200
50
+ streams_on_success.each do |s|
51
+ if s.is_a?(Array)
52
+ streams.push(s)
53
+ elsif s.is_a?(Hash) && s[:id].present?
54
+ streams.push(s)
55
+ end
56
+ end
57
+ else
58
+ response.status = :unprocessable_entity
59
+ streams += streams_on_error.select { |s| s[:id].present? }
60
+ end
61
+
62
+ #== FLASHES
63
+
64
+ model_name = object.model_name.human
65
+ if save_action
66
+ flash_notices = if flashes_on_success.present?
67
+ flashes_on_success
68
+ elsif flash_action.to_s == 'create'
69
+ str = I18n.t(
70
+ 'activerecord.success.successfully_created',
71
+ default: '%<model_name>s successfully created'
72
+ )
73
+ [format(str, model_name: model_name)]
74
+ elsif flash_action.to_s == 'update'
75
+ str = I18n.t(
76
+ 'activerecord.success.successfully_updated',
77
+ default: '%<model_name>s successfully updated'
78
+ )
79
+ [format(str, model_name: model_name)]
80
+ else
81
+ str = I18n.t(
82
+ "activerecord.success.#{flash_action}",
83
+ default: '%<model_name>s successfully updated'
84
+ )
85
+ [format(str, model_name: model_name)]
86
+ end
87
+ flash_alerts = []
88
+ else
89
+ flash_alerts = if flashes_on_error.present?
90
+ flashes_on_error
91
+ elsif flash_action.to_s == 'create'
92
+ str = I18n.t(
93
+ 'activerecord.errors.messages.could_not_create',
94
+ default: '%<model_name>s could not be created'
95
+ )
96
+ [format(str, model_name: model_name)]
97
+ elsif flash_action.to_s == 'update'
98
+ str = I18n.t(
99
+ 'activerecord.errors.messages.could_not_update',
100
+ default: '%<model_name>s could not be updated'
101
+ )
102
+ [format(str, model_name: model_name)]
103
+ else
104
+ str = I18n.t(
105
+ "activerecord.errors.messages.#{flash_action}",
106
+ default: '%<model_name>s could not be updated'
107
+ )
108
+ [format(str, model_name: model_name)]
109
+ end
110
+ flash_notices = []
111
+ end
112
+
113
+ flash_notices += add_flash_notices.to_a
114
+ flash_alerts += add_flash_alerts.to_a
115
+ _flash_id = Rails.configuration.x.render_turbo_stream.flash_id
116
+ flash_id = (_flash_id ? _flash_id : "ERROR, MISSING CONFIG => config.x.render_turbo_stream.flash_id")
117
+ flash_partial = Rails.configuration.x.render_turbo_stream.flash_partial
118
+ flash_action = Rails.configuration.x.render_turbo_stream.flash_action
119
+ flash_notices.each do |notice|
120
+ next unless notice.present?
121
+ # inside the flash partial has to be a loop that handles all theese flashes
122
+ flash_stream = {
123
+ id: flash_id,
124
+ partial: flash_partial,
125
+ action: flash_action,
126
+ locals: { success: true, message: notice }
127
+ }
128
+ streams.push(flash_stream)
129
+ end
130
+ flash_alerts.each do |alert|
131
+ next unless alert.present?
132
+ # inside the flash partial has to be a loop that handles all theese flashes
133
+ flash_stream = {
134
+ id: flash_id,
135
+ partial: flash_partial,
136
+ action: flash_action,
137
+ locals: { success: false, message: alert }
138
+ }
139
+ streams.push(flash_stream)
140
+ end
141
+
142
+ #== render
143
+
144
+ if save_action && turbo_redirect_on_success_to.present?
145
+ response.status = 302
146
+ flash[:alert] = flash_alerts
147
+ flash[:notice] = flash_notices
148
+ Rails.logger.debug(" • Set flash[:alert] => #{flash_alerts}") if flash_alerts.present?
149
+ Rails.logger.debug(" • Set flash[:notice] => #{flash_notices}") if flash_notices.present?
150
+ render_turbo_stream([
151
+ [
152
+ :redirect_to,
153
+ turbo_redirect_on_success_to
154
+ ]
155
+ ])
156
+ elsif save_action && redirect_on_success_to.present?
157
+ response.status = 302
158
+ if Rails.configuration.x.render_turbo_stream.use_channel_for_turbo_stream_save && helpers.user_signed_in?
159
+ streams.each do |s|
160
+ next unless s.is_a?(Hash)
161
+ Rails.logger.debug(" • Send by Cable => «#{s}»")
162
+ render_to_me(
163
+ s[:id],
164
+ flash_action,
165
+ partial: s[:partial],
166
+ locals: s[:locals]
167
+ )
168
+ end
169
+
170
+ else
171
+ flash[:alert] = flash_alerts
172
+ flash[:notice] = flash_notices
173
+ Rails.logger.debug(" • Set flash[:alert] => #{flash_alerts}") if flash_alerts.present?
174
+ Rails.logger.debug(" • Set flash[:notice] => #{flash_notices}") if flash_notices.present?
175
+ end
176
+ redirect_to redirect_on_success_to
177
+
178
+ else
179
+ flash.now[:alert] = flash_alerts
180
+ flash.now[:notice] = flash_notices
181
+ render_turbo_stream(streams)
182
+ end
183
+ end
184
+
185
+
186
+ # renders a array of partials to send by turbo-stream and / or actions like turbo_power gem includes, to turbo_stream
187
+ def render_turbo_stream(array)
188
+
189
+ ary = []
190
+ array.each do |pr|
191
+ if !pr.present?
192
+ Rails.logger.warn " WARNING render_turbo_stream: Empty element inside attributes: «#{array}»"
193
+ elsif pr.is_a?(Hash)
194
+ props = pr.symbolize_keys
195
+ raise "missing attribute :id in #{props}" if !props[:id].present?
196
+ part = (props[:partial].present? ? props[:partial] : props[:id]).gsub('-', '_')
197
+ partial = (part.to_s.include?('/') ? part : [controller_path, part].join('/'))
198
+ r = props
199
+ r[:action] = (props[:action].present? ? props[:action] : :replace)
200
+ r[:partial] = partial
201
+ r[:type] = 'stream-partial'
202
+ ary.push(r)
203
+ elsif pr.is_a?(Array)
204
+ raise "array has to contain at least one element: #{pr}" unless pr.first.present?
205
+ ary.push(pr)
206
+ else
207
+ raise "ERROR render_turbo_stream invalid type: Only hash or array allowed"
208
+ end
209
+ end
210
+
211
+ if request.format.to_sym == :turbo_stream
212
+ render template: 'render_turbo_stream', locals: { streams: ary }, layout: false, formats: :turbo_stream
213
+ else
214
+ Rails.logger.debug(" • Render Turbo Stream RENDERING AS HTML because request.format => #{request.format}")
215
+ render template: 'render_turbo_stream_request_test', locals: { streams: ary }, layout: false, formats: :html
216
+ end
217
+
218
+ end
219
+
220
+ # renders a partial to turbo_stream
221
+
222
+ def stream_partial(
223
+ id,
224
+ partial: nil, #=> default: id
225
+ action: :replace,
226
+ locals: {}
227
+ )
228
+ render_turbo_stream(
229
+ [
230
+ {
231
+ id: id,
232
+ partial: partial,
233
+ action: action,
234
+ locals: locals
235
+ }
236
+ ]
237
+ )
238
+ end
239
+
240
+ end
241
+ end
@@ -0,0 +1,55 @@
1
+ module RenderTurboStream
2
+ module Test
3
+ module Request
4
+ module ChannelHelpers
5
+
6
+ # Assert a action by turbo streams channel to the current_user
7
+
8
+ def assert_channel_to_me(user, target_id, action: nil, count: 1, &block)
9
+
10
+ channel = "authenticated-user-#{user.id}"
11
+
12
+ libs = RenderTurboStream::Test::Request::Libs
13
+
14
+ r = libs.select_responses(response, channel, target_id, action, type: :channel, &block)
15
+
16
+ assert(
17
+ r[:responses].length == count,
18
+ libs.assert_error_message(count, r[:responses].length, r[:log])
19
+ )
20
+ end
21
+
22
+ # Assert a action by turbo streams channel to a group of authenticated users
23
+
24
+ def assert_channel_to_authenticated_group(group, target_id, action: nil, count: 1, &block)
25
+
26
+ channel = "authenticated-group-#{group}"
27
+
28
+ libs = RenderTurboStream::Test::Request::Libs
29
+
30
+ r = libs.select_responses(response, channel, target_id, action, type: :channel, &block)
31
+
32
+ assert(
33
+ r[:responses].length == count,
34
+ libs.assert_error_message(count, r[:responses].length, r[:log])
35
+ )
36
+ end
37
+
38
+ # Assert a action by turbo streams channel to all visitors of the page
39
+
40
+ def assert_channel_to_all(target_id, action: nil, count: 1, &block)
41
+
42
+ libs = RenderTurboStream::Test::Request::Libs
43
+
44
+ r = libs.select_responses(response, 'all', target_id, action, type: :channel, &block)
45
+
46
+ assert(
47
+ r[:responses].length == count,
48
+ libs.assert_error_message(count, r[:responses].length, r[:log])
49
+ )
50
+ end
51
+
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,78 @@
1
+ module RenderTurboStream
2
+ module Test
3
+ module Request
4
+ module Helpers
5
+
6
+ # Assert that each of given target_ids is targeted exactly once
7
+
8
+ def assert_once_targeted(*target_ids)
9
+ responses = RenderTurboStream::Test::Request::Libs.all_turbo_responses(response)
10
+ id_counts = {}
11
+ responses.each do |r|
12
+ if r['target'].is_a?(Array)
13
+ r['target'].each do |t|
14
+ id = (t[0] == '#' ? t[1..-1] : t)
15
+ id_counts[t.to_s] ||= 0
16
+ id_counts[t.to_s] += 1
17
+ end
18
+ else
19
+ id = (r['target'][0] == '#' ? r['target'][1..-1] : r['target'])
20
+ id_counts[id] ||= 0
21
+ id_counts[id] += 1
22
+ end
23
+ end
24
+
25
+ assert(id_counts.keys.length == target_ids.length, "You checked for #{target_ids.length} but #{id_counts.keys.length} targeted: #{id_counts.keys.join(', ')}")
26
+ target_ids.each do |id|
27
+ assert(id_counts.key?(id), "The id #{id} is not within the targeted ids: #{id_counts.keys.join(', ')}")
28
+ expect(id_counts[id]).to eq(1)
29
+ assert(id_counts[id] == 1, "The id #{id} is targeted #{id_counts[id]} #{'time'.pluralize(id_counts[id])}")
30
+ end
31
+ end
32
+
33
+ # Helper for the developer for writing tests: Array with all attributes of all turbo-stream and turbo-channel actions that runned on the last request
34
+
35
+ def all_turbo_responses
36
+ RenderTurboStream::Test::Request::Libs.all_turbo_responses(response)
37
+ end
38
+
39
+ # Returns Array of all target_ids that arre affected at least once on the last response
40
+ def turbo_targets
41
+ RenderTurboStream::Test::Request::Libs.turbo_targets(response)
42
+ end
43
+
44
+ # Returns the path sent to turbo_stream.redirect_to.
45
+ def turbo_redirect_to
46
+ resps = RenderTurboStream::Test::Request::Libs.all_turbo_responses(response)
47
+ url = nil
48
+ resps.each do |r|
49
+ if r['type'] == 'stream-command'
50
+ if r['array'].first == 'redirect_to'
51
+ if url
52
+ url = 'ERROR: REDIRECT CALLED MORE THAN ONCE'
53
+ else
54
+ url = r['array'].second
55
+ end
56
+ end
57
+ end
58
+ end
59
+ url
60
+ end
61
+
62
+ # assert one or more specific actions to a target_id
63
+ def assert_stream_response(target_id, action: nil, count: 1, type: :stream, &block)
64
+
65
+ libs = RenderTurboStream::Test::Request::Libs
66
+
67
+ r = libs.select_responses(response, false, target_id, action, type: type, &block)
68
+
69
+ assert(
70
+ r[:responses].length == count,
71
+ libs.assert_error_message(count, r[:responses].length, r[:log])
72
+ )
73
+ end
74
+
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,156 @@
1
+ module RenderTurboStream
2
+ module Test
3
+ module Request
4
+ class Libs
5
+ def self.all_turbo_responses(response)
6
+ e = Nokogiri::HTML(response.body).search('#rendered-partials').first
7
+ res = (e.present? ? JSON.parse(e.inner_html) : [])
8
+ response.headers.each do |k, v|
9
+ next unless k.match(/^test-turbo-channel-[\d]+$/)
10
+ h = JSON.parse(v)
11
+ res.push(h)
12
+ end
13
+ res
14
+ end
15
+
16
+ def self.select_responses(response, channel, target_id, action, type: nil, prohibit_multiple_to_same_target: [:replace], &block)
17
+ all = all_turbo_responses(response)
18
+ res = { log: [], responses: [] }
19
+
20
+ if !target_id.present?
21
+ res[:log].push("No target «##{target_id}» provided!")
22
+ elsif prohibit_multiple_to_same_target.present?
23
+ has_prohibited_action = false
24
+ c = all.select do |e|
25
+ if prohibit_multiple_to_same_target.include?(e['action']&.to_sym)
26
+ has_prohibited_action = true
27
+ end
28
+ e['target'] == "##{target_id}"
29
+ end.length
30
+ if c >= 2 && has_prohibited_action
31
+ res[:log].push("Target «##{target_id}» is targeted #{c} #{'time'.pluralize(c)} which is prohibited for the actions #{prohibit_multiple_to_same_target.join(', ')}")
32
+ return res
33
+ end
34
+ end
35
+
36
+ id_matched = false
37
+
38
+ responses = all.select do |a|
39
+ types = [a['type']]
40
+ if ['channel-partial', 'channel-template'].include?(a['type'])
41
+ types.push('channel')
42
+ elsif ['stream-partial'].include?(a['type'])
43
+ types.push('stream')
44
+ elsif ['stream-command'].include?(a['type'])
45
+ types.push('command')
46
+ types.push('stream')
47
+ elsif ['channel-command'].include?(a['type'])
48
+ types.push('command')
49
+ types.push('channel')
50
+ end
51
+
52
+ if target_id.present? && a['target'] == "##{target_id}"
53
+ id_matched = true
54
+ end
55
+
56
+ if target_id.present? && a['target'] != "##{target_id}"
57
+ false
58
+ elsif type.present? && !types.include?(type.to_s)
59
+ res[:log].push("Target «##{target_id}»: Types #{types.join(', ')} not matching with given type «#{type}»")
60
+ false
61
+ elsif channel.present? && a['channel'] != channel.to_s
62
+ res[:log].push("Target «##{target_id}»: Channel #{a['channel']} not matching with given channel «#{channel}»")
63
+ false
64
+ elsif action.present? && a['action'] != action.to_s
65
+ res[:log].push("Target «##{target_id}»: Action #{a['action']} not matching with given action «#{action}»")
66
+ false
67
+ elsif block_given?
68
+ if types.include?('command')
69
+ args = a['array'][1..-1]
70
+ if yield(args)
71
+ true
72
+ else
73
+ res[:log].push("Given block not matching Arguments => «#{args}»")
74
+ false
75
+ end
76
+ else
77
+ nok = Nokogiri::HTML(a['html_response'])
78
+ if yield(nok).present?
79
+ true
80
+ else
81
+ res[:log].push("Target «##{target_id}»: Given block not matching (checked by .present?)")
82
+ false
83
+ end
84
+ end
85
+ else
86
+ true
87
+ end
88
+ end
89
+
90
+ unless id_matched
91
+ res[:log].push("Target «##{target_id}» not found")
92
+ end
93
+
94
+ res[:responses] = responses
95
+ res
96
+ end
97
+
98
+ def self.turbo_targets(response)
99
+ responses = all_turbo_responses(response)
100
+ targets = []
101
+ responses.each do |r|
102
+ if r['target'].is_a?(Array)
103
+ r['target'].each do |t|
104
+ targets.push(t) unless targets.include?(t)
105
+ end
106
+ else
107
+ targets.push(r['target']) unless targets.include?(r['target'])
108
+ end
109
+ end
110
+
111
+ targets
112
+ end
113
+
114
+ def self.assert_error_message(expected, received, log)
115
+ [
116
+ "Expected #{expected} #{'response'.pluralize(expected)} but found #{received}.",
117
+ (log.present? ? "Messages => «#{log.join(', ')}»" : nil)
118
+ ].compact.join(' ')
119
+ end
120
+
121
+ def self.first_arg_is_html_id(method)
122
+ config = Rails.configuration.x.render_turbo_stream.first_argument_is_html_id
123
+ default = [
124
+ :graft,
125
+ :morph,
126
+ :inner_html,
127
+ :insert_adjacent_text,
128
+ :outer_html,
129
+ :text_content,
130
+ :add_css_class,
131
+ :remove_attribute,
132
+ :remove_css_class,
133
+ :set_attribute,
134
+ :set_dataset_attribute,
135
+ :set_property,
136
+ :set_style,
137
+ :set_styles,
138
+ :set_value,
139
+ :dispatch_event,
140
+ :reset_form,
141
+ :clear_storage,
142
+ :scroll_into_view,
143
+ :set_focus,
144
+ :turbo_frame_reload,
145
+ :turbo_frame_set_src,
146
+ :replace,
147
+ :append,
148
+ :prepend
149
+ ]
150
+ (config.present? ? config : default).map { |m| m.to_sym }.include?(method.to_sym)
151
+ end
152
+
153
+ end
154
+ end
155
+ end
156
+ end
@@ -1,3 +1,3 @@
1
1
  module RenderTurboStream
2
- VERSION = "2.1.2"
2
+ VERSION = "3.0.1"
3
3
  end