render_turbo_stream 2.1.1 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,233 @@
1
+ module RenderTurboStream
2
+ module ControllerHelpers
3
+
4
+ def turbo_stream_save(
5
+ save_action,
6
+ 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
7
+ turbo_redirect_on_success_to: nil, # does a full page redirect (break out of all frames by turbo_power redirect)
8
+ object: nil, # object used in save_action, example: @customer
9
+ id: nil, # if nil: no partial is rendered
10
+ partial: nil, # example: 'customers/form' default: "#{controller_path}/#{id}"
11
+ action: 'replace', # options: append, prepend
12
+ 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}"
13
+ locals: {}, # locals used by the partial
14
+ streams_on_success: [
15
+ {
16
+ id: nil,
17
+ partial: 'form',
18
+ locals: {},
19
+ action: 'replace'
20
+ }
21
+ ], # additional partials that should be rendered if save_action succeeded
22
+ streams_on_error: [
23
+ {
24
+ id: nil,
25
+ partial: 'form',
26
+ locals: {},
27
+ action: 'replace'
28
+ }
29
+ ], # additional partials that should be rendered if save_action failed
30
+ add_flash_alerts: [], #=> array of strings
31
+ add_flash_notices: [], #=> array of strings
32
+ flashes_on_success: [], #=> array of strings
33
+ flashes_on_error: [] #=> array of strings
34
+ )
35
+
36
+ unless object
37
+ object = eval("@#{controller_name.classify.underscore}")
38
+ end
39
+
40
+ #== Streams / Partials
41
+
42
+ streams = (id ? [id: id, partial: partial, locals: locals, action: action] : [])
43
+
44
+ if save_action
45
+ response.status = 200
46
+ streams_on_success.each do |s|
47
+ if s.is_a?(Array)
48
+ streams.push(s)
49
+ elsif s.is_a?(Hash) && s[:id].present?
50
+ streams.push(s)
51
+ end
52
+ end
53
+ else
54
+ response.status = :unprocessable_entity
55
+ streams += streams_on_error.select { |s| s[:id].present? }
56
+ end
57
+
58
+ #== FLASHES
59
+
60
+ model_name = object.model_name.human
61
+ if save_action
62
+ flash_notices = if flashes_on_success.present?
63
+ flashes_on_success
64
+ elsif flash_action.to_s == 'create'
65
+ str = I18n.t(
66
+ 'activerecord.success.successfully_created',
67
+ default: '%<model_name>s successfully created'
68
+ )
69
+ [format(str, model_name: model_name)]
70
+ elsif flash_action.to_s == 'update'
71
+ str = I18n.t(
72
+ 'activerecord.success.successfully_updated',
73
+ default: '%<model_name>s successfully updated'
74
+ )
75
+ [format(str, model_name: model_name)]
76
+ else
77
+ str = I18n.t(
78
+ "activerecord.success.#{flash_action}",
79
+ default: '%<model_name>s successfully updated'
80
+ )
81
+ [format(str, model_name: model_name)]
82
+ end
83
+ flash_alerts = []
84
+ else
85
+ flash_alerts = if flashes_on_error.present?
86
+ flashes_on_error
87
+ elsif flash_action.to_s == 'create'
88
+ str = I18n.t(
89
+ 'activerecord.errors.messages.could_not_create',
90
+ default: '%<model_name>s could not be created'
91
+ )
92
+ [format(str, model_name: model_name)]
93
+ elsif flash_action.to_s == 'update'
94
+ str = I18n.t(
95
+ 'activerecord.errors.messages.could_not_update',
96
+ default: '%<model_name>s could not be updated'
97
+ )
98
+ [format(str, model_name: model_name)]
99
+ else
100
+ str = I18n.t(
101
+ "activerecord.errors.messages.#{flash_action}",
102
+ default: '%<model_name>s could not be updated'
103
+ )
104
+ [format(str, model_name: model_name)]
105
+ end
106
+ flash_notices = []
107
+ end
108
+
109
+ flash_notices += add_flash_notices.to_a
110
+ flash_alerts += add_flash_alerts.to_a
111
+ _flash_id = Rails.configuration.x.render_turbo_stream.flash_id
112
+ flash_id = (_flash_id ? _flash_id : "ERROR, MISSING CONFIG => config.x.render_turbo_stream.flash_id")
113
+ flash_partial = Rails.configuration.x.render_turbo_stream.flash_partial
114
+ flash_action = Rails.configuration.x.render_turbo_stream.flash_action
115
+ flash_notices.each do |notice|
116
+ next unless notice.present?
117
+ # inside the flash partial has to be a loop that handles all theese flashes
118
+ flash_stream = {
119
+ id: flash_id,
120
+ partial: flash_partial,
121
+ action: flash_action,
122
+ locals: { success: true, message: notice }
123
+ }
124
+ streams.push(flash_stream)
125
+ end
126
+ flash_alerts.each do |alert|
127
+ next unless alert.present?
128
+ # inside the flash partial has to be a loop that handles all theese flashes
129
+ flash_stream = {
130
+ id: flash_id,
131
+ partial: flash_partial,
132
+ action: flash_action,
133
+ locals: { success: false, message: alert }
134
+ }
135
+ streams.push(flash_stream)
136
+ end
137
+
138
+ #== render
139
+
140
+ if save_action && turbo_redirect_on_success_to.present?
141
+ response.status = 302
142
+ flash[:alert] = flash_alerts
143
+ flash[:notice] = flash_notices
144
+ Rails.logger.debug(" • Set flash[:alert] => #{flash_alerts}") if flash_alerts.present?
145
+ Rails.logger.debug(" • Set flash[:notice] => #{flash_notices}") if flash_notices.present?
146
+ render_turbo_stream([
147
+ [
148
+ :redirect_to,
149
+ turbo_redirect_on_success_to
150
+ ]
151
+ ])
152
+ elsif save_action && redirect_on_success_to.present?
153
+ response.status = 302
154
+ if Rails.configuration.x.render_turbo_stream.use_cable_for_turbo_stream_save && helpers.user_signed_in?
155
+ streams.each do |s|
156
+ next unless s.is_a?(Hash)
157
+ Rails.logger.debug(" • Send by Cable => «#{s}»")
158
+ render_to_me(
159
+ s[:id],
160
+ flash_action,
161
+ partial: s[:partial],
162
+ locals: s[:locals]
163
+ )
164
+ end
165
+
166
+ else
167
+ flash[:alert] = flash_alerts
168
+ flash[:notice] = flash_notices
169
+ Rails.logger.debug(" • Set flash[:alert] => #{flash_alerts}") if flash_alerts.present?
170
+ Rails.logger.debug(" • Set flash[:notice] => #{flash_notices}") if flash_notices.present?
171
+ end
172
+ redirect_to redirect_on_success_to
173
+
174
+ else
175
+ flash.now[:alert] = flash_alerts
176
+ flash.now[:notice] = flash_notices
177
+ render_turbo_stream(streams)
178
+ end
179
+ end
180
+
181
+ def render_turbo_stream(array)
182
+
183
+ ary = []
184
+ array.each do |pr|
185
+ if !pr.present?
186
+ Rails.logger.warn " WARNING render_turbo_stream: Empty element inside attributes: «#{array}»"
187
+ elsif pr.is_a?(Hash)
188
+ props = pr.symbolize_keys
189
+ raise "missing attribute :id in #{props}" if !props[:id].present?
190
+ part = (props[:partial].present? ? props[:partial] : props[:id]).gsub('-', '_')
191
+ partial = (part.to_s.include?('/') ? part : [controller_path, part].join('/'))
192
+ r = props
193
+ r[:action] = (props[:action].present? ? props[:action] : :replace)
194
+ r[:partial] = partial
195
+ r[:type] = 'stream-partial'
196
+ ary.push(r)
197
+ elsif pr.is_a?(Array)
198
+ raise "array has to contain at least one element: #{pr}" unless pr.first.present?
199
+ ary.push(pr)
200
+ else
201
+ raise "ERROR render_turbo_stream invalid type: Only hash or array allowed"
202
+ end
203
+ end
204
+
205
+ if request.format.to_sym == :turbo_stream
206
+ render template: 'render_turbo_stream', locals: { streams: ary }, layout: false, formats: :turbo_stream
207
+ else
208
+ Rails.logger.debug(" • Render Turbo Stream RENDERING AS HTML because request.format => #{request.format}")
209
+ render template: 'render_turbo_stream_request_test', locals: { streams: ary }, layout: false, formats: :html
210
+ end
211
+
212
+ end
213
+
214
+ def stream_partial(
215
+ id,
216
+ partial: nil, #=> default: id
217
+ action: :replace,
218
+ locals: {}
219
+ )
220
+ render_turbo_stream(
221
+ [
222
+ {
223
+ id: id,
224
+ partial: partial,
225
+ action: action,
226
+ locals: locals
227
+ }
228
+ ]
229
+ )
230
+ end
231
+
232
+ end
233
+ end
@@ -0,0 +1,49 @@
1
+ module RenderTurboStream
2
+ module Test
3
+ module Request
4
+ module ChannelHelpers
5
+
6
+ def assert_channel_to_me(user, target_id, action: nil, count: 1, &block)
7
+
8
+ channel = "authenticated-user-#{user.id}"
9
+
10
+ libs = RenderTurboStream::Test::Request::Libs
11
+
12
+ r = libs.select_responses(response, channel, target_id, action, type: :channel, &block)
13
+
14
+ assert(
15
+ r[:responses].length == count,
16
+ libs.assert_error_message(count, r[:responses].length, r[:log])
17
+ )
18
+ end
19
+
20
+ def assert_channel_to_authenticated_group(group, target_id, action: nil, count: 1, &block)
21
+
22
+ channel = "authenticated-group-#{group}"
23
+
24
+ libs = RenderTurboStream::Test::Request::Libs
25
+
26
+ r = libs.select_responses(response, channel, target_id, action, type: :channel, &block)
27
+
28
+ assert(
29
+ r[:responses].length == count,
30
+ libs.assert_error_message(count, r[:responses].length, r[:log])
31
+ )
32
+ end
33
+
34
+ def assert_channel_to_all(target_id, action: nil, count: 1, &block)
35
+
36
+ libs = RenderTurboStream::Test::Request::Libs
37
+
38
+ r = libs.select_responses(response, 'all', target_id, action, type: :channel, &block)
39
+
40
+ assert(
41
+ r[:responses].length == count,
42
+ libs.assert_error_message(count, r[:responses].length, r[:log])
43
+ )
44
+ end
45
+
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,84 @@
1
+ module RenderTurboStream
2
+ module Test
3
+ module Request
4
+ module Helpers
5
+
6
+ def assert_once_targeted(*target_ids)
7
+ responses = RenderTurboStream::Test::Request::Libs.all_turbo_responses(response)
8
+ id_counts = {}
9
+ responses.each do |r|
10
+ if r['target'].is_a?(Array)
11
+ r['target'].each do |t|
12
+ id = (t[0] == '#' ? t[1..-1] : t)
13
+ id_counts[t.to_s] ||= 0
14
+ id_counts[t.to_s] += 1
15
+ end
16
+ else
17
+ id = (r['target'][0] == '#' ? r['target'][1..-1] : r['target'])
18
+ id_counts[id] ||= 0
19
+ id_counts[id] += 1
20
+ end
21
+ end
22
+
23
+ assert(id_counts.keys.length == target_ids.length, "You checked for #{target_ids.length} but #{id_counts.keys.length} targeted: #{id_counts.keys.join(', ')}")
24
+ target_ids.each do |id|
25
+ assert(id_counts.key?(id), "The id #{id} is not within the targeted ids: #{id_counts.keys.join(', ')}")
26
+ expect(id_counts[id]).to eq(1)
27
+ assert(id_counts[id] == 1, "The id #{id} is targeted #{id_counts[id]} #{'time'.pluralize(id_counts[id])}")
28
+ end
29
+ end
30
+
31
+ def all_turbo_responses
32
+ RenderTurboStream::Test::Request::Libs.all_turbo_responses(response)
33
+ end
34
+
35
+ def turbo_targets
36
+ RenderTurboStream::Test::Request::Libs.turbo_targets(response)
37
+ end
38
+
39
+ def assert_response(target_id, type = nil, action: nil, count: 1, &block)
40
+
41
+ libs = RenderTurboStream::Test::Request::Libs
42
+
43
+ r = libs.select_responses(response, false, target_id, action, type: type, &block)
44
+
45
+ assert(
46
+ r[:responses].length == count,
47
+ libs.assert_error_message(count, r[:responses].length, r[:log])
48
+ )
49
+ end
50
+
51
+ # Returns the path to which turbo_stream.redirect_to would be applied.
52
+ def turbo_redirect_to
53
+ resps = RenderTurboStream::Test::Request::Libs.all_turbo_responses(response)
54
+ url = nil
55
+ resps.each do |r|
56
+ if r['type'] == 'stream-command'
57
+ if r['array'].first == 'redirect_to'
58
+ if url
59
+ url = 'ERROR: REDIRECT CALLED MORE THAN ONCE'
60
+ else
61
+ url = r['array'].second
62
+ end
63
+ end
64
+ end
65
+ end
66
+ url
67
+ end
68
+
69
+ def assert_stream_response(target_id, action: nil, count: 1, &block)
70
+
71
+ libs = RenderTurboStream::Test::Request::Libs
72
+
73
+ r = libs.select_responses(response, false, target_id, action, type: :stream, &block)
74
+
75
+ assert(
76
+ r[:responses].length == count,
77
+ libs.assert_error_message(count, r[:responses].length, r[:log])
78
+ )
79
+ end
80
+
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,155 @@
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
+ end
153
+ end
154
+ end
155
+ end
@@ -1,3 +1,3 @@
1
1
  module RenderTurboStream
2
- VERSION = "2.1.1"
2
+ VERSION = "3.0.0"
3
3
  end