render_turbo_stream 2.1.2 → 3.0.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.
@@ -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