slocks 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 61ae8a8e25f7e6aec74c585aa238882b34821bb2a274315dfdc4ffa79b89edee
4
+ data.tar.gz: 6268cd40385da4328fb3b1523a4b3f21cc33d699300c74a6f2cc4422f74d884c
5
+ SHA512:
6
+ metadata.gz: 425ebb760ef81e84762c5f83e483cdf9e2b19615c4717efc3724e79803556a7f8dff3443abdcfab36647dea3ca86e761d22f6d6dc46ef1de261933f09fbc2a52
7
+ data.tar.gz: 4cb8df895f37394fef5375dbb8e0530315fe074408b129a55b5ec4e4bee854fe1c5e9923c6b2dbda2553cdc0502c95037162d701cdf6c267cfa580a1b3e68402
data/README.md ADDED
@@ -0,0 +1,386 @@
1
+ # Slocks
2
+
3
+ **Slocks** is a DSL & a Rails template handler for generating Slack [Block Kit](https://docs.slack.dev/block-kit/) surfaces.
4
+
5
+ No implied warranty. Solid chance it won't work.
6
+
7
+ ## Why Slocks?
8
+ I've lost too many hours of my life to
9
+ \*write\*→\*⌘C\*→\*⌘-Tab to Block Kit Builder\*→\*⌘V\*→"dammit"→\*⌘-Tab\*→\*rewrite\*→\*repeat\*.
10
+
11
+ No more.
12
+ ## Installation
13
+
14
+ Add to your Gemfile:
15
+
16
+ ```ruby
17
+ gem 'slocks'
18
+ ```
19
+
20
+ Then run:
21
+
22
+ ```bash
23
+ bundle install
24
+ ```
25
+
26
+ ## Quick Start
27
+
28
+ Create a template.
29
+
30
+ ```ruby
31
+ # app/views/notifications/order_shipped.slack_blocks
32
+
33
+ header "📦 Your order has shipped!"
34
+
35
+ section "*Order ##{@order.id}* is on its way", markdown: true
36
+
37
+ divider
38
+
39
+ section "Tracking:",
40
+ fields: [
41
+ mrkdwn_text("*Carrier:* #{@order.carrier}"),
42
+ mrkdwn_text("*Tracking:* `#{@order.tracking_number}`")
43
+ ]
44
+
45
+ actions [ button("Track Package", "track_#{@order.id}", style: "primary", url: @order.tracking_url) ]
46
+ ```
47
+
48
+ Render it in your controller or service:
49
+
50
+ ```ruby
51
+ class OrdersController < ApplicationController
52
+ def mark
53
+ @user = current_user
54
+
55
+ payload = render_to_string(
56
+ template: 'notifications/welcome',
57
+ formats: [:slack_message]
58
+ )
59
+
60
+ slack_client.chat_postMessage(
61
+ channel: @user.slack_id,
62
+ **JSON.parse(payload)
63
+ )
64
+ end
65
+ end
66
+ ```
67
+
68
+ ## Blocks Reference
69
+
70
+ ### Layout Blocks
71
+
72
+ #### Header
73
+ ```ruby
74
+ header "🎉 Great news, Baltimore!"
75
+ ```
76
+
77
+ #### Section
78
+ ```ruby
79
+ # simple text
80
+ section "hello world"
81
+
82
+ # mrkdwn text
83
+ section "to *bold*ly _italic_ize...", markdown: true
84
+
85
+ # fields
86
+ section "details:",
87
+ fields: [
88
+ mrkdwn_text("*computer:* over"),
89
+ mrkdwn_text("*virus:* VERY yes.")
90
+ ]
91
+
92
+ # accessorizing a little (images, buttons, etc)
93
+ section "click to continue",
94
+ accessory: button("click me, i dare you", "explode_action")
95
+ ```
96
+
97
+ #### Divider
98
+ ```ruby
99
+ divider
100
+ ```
101
+
102
+ #### Context
103
+ ```ruby
104
+ context [
105
+ mrkdwn_text("Last updated: #{time_ago_in_words(@updated_at)} ago"),
106
+ image_element("https://crouton.net/crouton.png", "icon")
107
+ ]
108
+ ```
109
+
110
+ #### Actions
111
+ (some these won't do anything unless you use them somewhere expecting accessories! it'd do you a lot of good to read Slack's Block Kit docs before you try anything...)
112
+
113
+ ```ruby
114
+ actions [
115
+ button("Approve", "approve", style: "primary"),
116
+ button("Reject", "reject", style: "danger"),
117
+ button("More Info", "info")
118
+ ]
119
+ ```
120
+
121
+ #### Image
122
+ ```ruby
123
+ image "https://example.com/photo.jpg", "Photo description",
124
+ title: "Amazing Photo"
125
+ ```
126
+
127
+ #### Input
128
+ ```ruby
129
+ input "Your name",
130
+ plain_text_input("name_input", placeholder: "Enter your name")
131
+
132
+ input "Choose an option",
133
+ select_menu("option_select",
134
+ placeholder: "Select one",
135
+ options: [
136
+ option("Option 1", "opt1"),
137
+ option("Option 2", "opt2")
138
+ ]
139
+ )
140
+ ```
141
+
142
+ #### File
143
+ ```ruby
144
+ file "F123ABC456", source: "remote"
145
+ ```
146
+
147
+ #### Video
148
+ ```ruby
149
+ video "How to use Slocks",
150
+ "https://example.com/video",
151
+ "https://example.com/thumbnail.jpg",
152
+ "https://example.com/video.mp4",
153
+ alt_text: "Tutorial video"
154
+ ```
155
+
156
+ ### Block Elements
157
+
158
+ #### Buttons
159
+ ```ruby
160
+ button("Click me", "action_id")
161
+ button("Primary", "action", style: "primary")
162
+ button("Danger", "delete", style: "danger")
163
+ button("Link", "link", url: "https://example.com")
164
+ ```
165
+
166
+ #### Select Menus
167
+ ```ruby
168
+ # Static select
169
+ select_menu("action_id",
170
+ placeholder: "Choose one",
171
+ options: [
172
+ option("Label 1", "value1"),
173
+ option("Label 2", "value2")
174
+ ]
175
+ )
176
+
177
+ # Multi-select
178
+ multi_select_menu("action_id",
179
+ placeholder: "Choose multiple",
180
+ options: [...]
181
+ )
182
+
183
+ # User select
184
+ users_select("user_action", placeholder: "Select a user")
185
+
186
+ # Channel select
187
+ channels_select("channel_action", placeholder: "Select a channel")
188
+
189
+ # Conversation select
190
+ conversations_select("convo_action", placeholder: "Select a conversation")
191
+ ```
192
+
193
+ #### Inputs
194
+ ```ruby
195
+ # Text input
196
+ plain_text_input("action_id",
197
+ placeholder: "Type here...",
198
+ multiline: true
199
+ )
200
+
201
+ # Email input
202
+ email_input("email_action", placeholder: "your@email.com")
203
+
204
+ # URL input
205
+ url_input("url_action", placeholder: "https://...")
206
+
207
+ # Number input
208
+ number_input("number_action",
209
+ is_decimal_allowed: true,
210
+ min_value: 0,
211
+ max_value: 100
212
+ )
213
+ ```
214
+
215
+ #### Date & Time Pickers
216
+ ```ruby
217
+ date_picker("date_action", placeholder: "Select a date")
218
+ time_picker("time_action", placeholder: "Select a time")
219
+ datetime_picker("datetime_action")
220
+ ```
221
+
222
+ #### Other Elements
223
+ ```ruby
224
+ # Checkboxes
225
+ checkboxes("check_action", [
226
+ option("Option 1", "val1"),
227
+ option("Option 2", "val2")
228
+ ])
229
+
230
+ # Radio buttons
231
+ radio_buttons("radio_action", [
232
+ option("Choice 1", "c1"),
233
+ option("Choice 2", "c2")
234
+ ])
235
+
236
+ # Overflow menu
237
+ overflow("overflow_action", [
238
+ option("Edit", "edit"),
239
+ option("Delete", "delete")
240
+ ])
241
+
242
+ # File input
243
+ file_input("file_action", filetypes: ["pdf", "doc"], max_files: 5)
244
+ ```
245
+
246
+ ### Composition Objects
247
+
248
+ #### Text Objects
249
+ ```ruby
250
+ mrkdwn_text("*Bold* and _italic_")
251
+ plain_text("Plain text", emoji: true)
252
+ ```
253
+
254
+ #### Option & Option Groups
255
+ ```ruby
256
+ option("Display text", "value")
257
+ option("With description", "val", description: "More details")
258
+
259
+ option_group("Group label", [
260
+ option("Opt 1", "v1"),
261
+ option("Opt 2", "v2")
262
+ ])
263
+ ```
264
+
265
+ #### Confirm Dialog
266
+ ```ruby
267
+ confirm_dialog(
268
+ title: "Are you sure?",
269
+ text: "This action cannot be undone",
270
+ confirm: "Yes, delete it",
271
+ deny: "Cancel",
272
+ style: "danger"
273
+ )
274
+ ```
275
+
276
+ ## Modals
277
+
278
+ Create a modal template at `app/views/modals/feedback.slack_modal`:
279
+
280
+ ```ruby
281
+ title "Send Feedback"
282
+ submit "Submit"
283
+ close "Cancel"
284
+ callback "feedback_modal"
285
+
286
+ section "How was your experience?", markdown: true
287
+
288
+ input "Rating",
289
+ select_menu("rating",
290
+ placeholder: "Choose rating",
291
+ options: [
292
+ option("⭐⭐⭐⭐⭐ Excellent", "5"),
293
+ option("⭐⭐⭐⭐ Good", "4"),
294
+ option("⭐⭐⭐ Okay", "3"),
295
+ option("⭐⭐ Poor", "2"),
296
+ option("⭐ Bad", "1")
297
+ ]
298
+ )
299
+
300
+ input "Comments",
301
+ plain_text_input("comments",
302
+ placeholder: "Tell us more...",
303
+ multiline: true
304
+ ),
305
+ optional: true
306
+ ```
307
+
308
+ Render it:
309
+
310
+ ```ruby
311
+ modal_json = render_to_string(
312
+ template: 'modals/feedback',
313
+ formats: [:slack_modal]
314
+ )
315
+
316
+ slack_client.views_open(
317
+ trigger_id: params[:trigger_id],
318
+ view: JSON.parse(modal_json)
319
+ )
320
+ ```
321
+
322
+ ## Rails Helpers
323
+
324
+ All Rails helpers work in templates:
325
+
326
+ ```ruby
327
+ section "Order summary:",
328
+ fields: [
329
+ mrkdwn_text("*Items:* #{pluralize(@order.items.count, 'item')}"),
330
+ mrkdwn_text("*Total:* #{number_to_currency(@order.total)}"),
331
+ mrkdwn_text("*Ordered:* #{time_ago_in_words(@order.created_at)} ago")
332
+ ]
333
+ ```
334
+
335
+ ## Partials
336
+
337
+ Render partials just like ERB:
338
+
339
+ ```ruby
340
+ # app/views/orders/_order.slack_blocks
341
+ section "*Order ##{@order.id}*",
342
+ fields: [
343
+ mrkdwn_text("*Status:* #{@order.status}"),
344
+ mrkdwn_text("*Total:* #{number_to_currency(@order.total)}")
345
+ ]
346
+
347
+ # app/views/orders/summary.slack_blocks
348
+ header "Your Orders"
349
+
350
+ render @orders # Renders _order.slack_blocks for each order
351
+ ```
352
+
353
+ ## Rich Text (Advanced)
354
+
355
+ For rich text blocks with complex formatting:
356
+
357
+ ```ruby
358
+ rich_text do
359
+ section do
360
+ text "Hello ", bold: true
361
+ text "world!", italic: true
362
+ emoji "wave"
363
+ link "https://example.com", text: "Click here"
364
+ end
365
+
366
+ list(style: "bullet") do
367
+ text "First item"
368
+ text "Second item"
369
+ end
370
+ end
371
+ ```
372
+
373
+ ## Contributing
374
+
375
+ Bug reports are welcome, but pull requests are much _much_ welcomer.
376
+
377
+ This repo lives on GitHub at https://github.com/24c02/slocks. Happy hacking!
378
+ ## License
379
+
380
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
381
+
382
+ ## Credits
383
+
384
+ built with <3️ by nora and various LLM-slop providers.
385
+
386
+ heavily inspired by Akira Matsuda's fantastic [JB](https://github.com/amatsuda/jb/) gem.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slocks
4
+ module TemplateRenderer
5
+ module JSONizer
6
+ def render_template(_view, template, *)
7
+ rendered_template = super
8
+ if template.respond_to?(:handler) && [
9
+ Slocks::TemplateHandler, Slocks::ModalTemplateHandler, Slocks::TemplateHandlerDispatcher
10
+ ].include?(template.handler)
11
+ rendered_template.instance_variable_set :@body,
12
+ rendered_template.body.to_json
13
+ end
14
+ rendered_template
15
+ end
16
+ end
17
+ end
18
+
19
+ class TemplateResult < SimpleDelegator
20
+ def to_s
21
+ __getobj__
22
+ end
23
+ end
24
+
25
+ module CollectionRendererExtension
26
+ private
27
+
28
+ def render_collection(_collection, _view, _path, template, _layout, _block)
29
+ obj = super
30
+ if template.respond_to?(:handler) && [Slocks::TemplateHandler, Slocks::ModalTemplateHandler, Slocks::TemplateHandlerDispatcher].include?(template.handler)
31
+ if obj.is_a?(ActionView::AbstractRenderer::RenderedCollection::EmptyCollection)
32
+ def obj.body = []
33
+ else
34
+ def obj.body = @rendered_templates.map(&:body)
35
+ end
36
+ end
37
+ obj
38
+ end
39
+ end
40
+
41
+ module PartialRendererExtension
42
+ private
43
+
44
+ def render_collection(_view, template)
45
+ obj = super
46
+ if template.respond_to?(:handler) && [Slocks::TemplateHandler, Slocks::ModalTemplateHandler, Slocks::TemplateHandlerDispatcher].include?(template.handler)
47
+ if obj.is_a?(ActionView::AbstractRenderer::RenderedCollection::EmptyCollection)
48
+ def obj.body = []
49
+ else
50
+ def obj.body = @rendered_templates.map(&:body)
51
+ end
52
+ end
53
+ obj
54
+ end
55
+ end
56
+ end
57
+
58
+ ::ActionView::TemplateRenderer.prepend ::Slocks::TemplateRenderer::JSONizer
59
+ begin
60
+ ::ActionView::CollectionRenderer.prepend ::Slocks::CollectionRendererExtension
61
+ rescue NameError
62
+ ::ActionView::PartialRenderer.prepend ::Slocks::PartialRendererExtension
63
+ end
@@ -0,0 +1,531 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slocks
4
+ class BlocksBuilder
5
+ attr_reader :blocks
6
+
7
+ def initialize(context)
8
+ @context = context
9
+ @blocks = []
10
+ end
11
+
12
+ def header(text)
13
+ @blocks << {
14
+ type: "header",
15
+ text: {
16
+ type: "plain_text",
17
+ text: text
18
+ }
19
+ }
20
+ end
21
+
22
+ def section(text = nil, accessory: nil, fields: nil, markdown: false)
23
+ block = { type: "section" }
24
+
25
+ if text
26
+ block[:text] = {
27
+ type: markdown ? "mrkdwn" : "plain_text",
28
+ text: text
29
+ }
30
+ end
31
+
32
+ block[:accessory] = accessory if accessory
33
+ block[:fields] = fields if fields
34
+
35
+ @blocks << block
36
+ end
37
+
38
+ def simple_section(text)
39
+ section(text, markdown: true)
40
+ end
41
+
42
+ def divider
43
+ @blocks << { type: "divider" }
44
+ end
45
+
46
+ def context(elements)
47
+ @blocks << {
48
+ type: "context",
49
+ elements: elements
50
+ }
51
+ end
52
+
53
+ def context_actions(elements, block_id: nil)
54
+ @blocks << {
55
+ type: "context_actions",
56
+ elements:,
57
+ block_id:
58
+ }.compact
59
+ end
60
+
61
+ def actions(elements)
62
+ @blocks << {
63
+ type: "actions",
64
+ elements: elements
65
+ }
66
+ end
67
+
68
+ def image(image_url, alt_text, title: nil)
69
+ block = {
70
+ type: "image",
71
+ image_url: image_url,
72
+ alt_text: alt_text
73
+ }
74
+ block[:title] = { type: "plain_text", text: title } if title
75
+ @blocks << block
76
+ end
77
+
78
+ def input(label, element, optional: false, hint: nil, block_id: nil, dispatch_action: nil)
79
+ block = {
80
+ type: "input",
81
+ label: { type: "plain_text", text: label },
82
+ element: element
83
+ }
84
+ block[:block_id] = block_id if block_id
85
+ block[:optional] = optional if optional
86
+ block[:hint] = { type: "plain_text", text: hint } if hint
87
+ block[:dispatch_action] = dispatch_action if dispatch_action
88
+ @blocks << block
89
+ end
90
+
91
+ def file(external_id, source: "remote", block_id: nil)
92
+ block = {
93
+ type: "file",
94
+ external_id: external_id,
95
+ source: source
96
+ }
97
+ block[:block_id] = block_id if block_id
98
+ @blocks << block
99
+ end
100
+
101
+ def video(title, title_url, thumbnail_url, video_url, alt_text:, description: nil, author_name: nil,
102
+ provider_name: nil, provider_icon_url: nil, block_id: nil)
103
+ block = {
104
+ type: "video",
105
+ title: { type: "plain_text", text: title },
106
+ title_url:,
107
+ thumbnail_url:,
108
+ video_url:,
109
+ alt_text:,
110
+ description: description ? { type: "plain_text", text: description } : nil,
111
+ author_name:,
112
+ provider_name:,
113
+ provider_icon_url:,
114
+ block_id:
115
+ }.compact
116
+ @blocks << block
117
+ end
118
+
119
+ def rich_text(&block)
120
+ builder = RichTextBuilder.new
121
+ builder.instance_eval(&block) if block_given?
122
+ @blocks << {
123
+ type: "rich_text",
124
+ elements: builder.elements
125
+ }
126
+ end
127
+
128
+ def image_element(image_url, alt_text)
129
+ {
130
+ type: "image",
131
+ image_url: image_url,
132
+ alt_text: alt_text
133
+ }
134
+ end
135
+
136
+ def mrkdwn_text(text)
137
+ {
138
+ type: "mrkdwn",
139
+ text: text
140
+ }
141
+ end
142
+
143
+ def plain_text(text, emoji: true)
144
+ {
145
+ type: "plain_text",
146
+ text: text,
147
+ emoji: emoji
148
+ }
149
+ end
150
+
151
+ def button(text, action_id, value: nil, url: nil, style: nil, confirm: nil, accessibility_label: nil)
152
+ {
153
+ type: "button",
154
+ text: { type: "plain_text", text: text },
155
+ action_id:,
156
+ value:,
157
+ url:,
158
+ style:,
159
+ confirm:,
160
+ accessibility_label:
161
+ }.compact
162
+ end
163
+
164
+ def checkboxes(action_id, options, initial_options: nil, confirm: nil, focus_on_load: nil)
165
+ {
166
+ type: "checkboxes",
167
+ action_id:,
168
+ options:,
169
+ initial_options:,
170
+ confirm:,
171
+ focus_on_load:
172
+ }.compact
173
+ end
174
+
175
+ def date_picker(action_id, placeholder: nil, initial_date: nil, confirm: nil, focus_on_load: nil)
176
+ {
177
+ type: "datepicker",
178
+ action_id:,
179
+ placeholder: placeholder ? { type: "plain_text", text: placeholder } : nil,
180
+ initial_date:,
181
+ confirm:,
182
+ focus_on_load:
183
+ }.compact
184
+ end
185
+
186
+ def datetime_picker(action_id, initial_date_time: nil, confirm: nil, focus_on_load: nil)
187
+ {
188
+ type: "datetimepicker",
189
+ action_id:,
190
+ initial_date_time:,
191
+ confirm:,
192
+ focus_on_load:
193
+ }.compact
194
+ end
195
+
196
+ def time_picker(action_id, placeholder: nil, initial_time: nil, confirm: nil, focus_on_load: nil, timezone: nil)
197
+ {
198
+ type: "timepicker",
199
+ action_id:,
200
+ placeholder: placeholder ? { type: "plain_text", text: placeholder } : nil,
201
+ initial_time:,
202
+ confirm:,
203
+ focus_on_load:,
204
+ timezone:
205
+ }.compact
206
+ end
207
+
208
+ def plain_text_input(action_id, placeholder: nil, initial_value: nil, multiline: nil, min_length: nil,
209
+ max_length: nil, dispatch_action_config: nil, focus_on_load: nil)
210
+ {
211
+ type: "plain_text_input",
212
+ action_id:,
213
+ placeholder: placeholder ? { type: "plain_text", text: placeholder } : nil,
214
+ initial_value:,
215
+ multiline:,
216
+ min_length:,
217
+ max_length:,
218
+ dispatch_action_config:,
219
+ focus_on_load:
220
+ }.compact
221
+ end
222
+
223
+ def email_input(action_id, placeholder: nil, initial_value: nil, dispatch_action_config: nil, focus_on_load: nil)
224
+ {
225
+ type: "email_text_input",
226
+ action_id:,
227
+ placeholder: placeholder ? { type: "plain_text", text: placeholder } : nil,
228
+ initial_value:,
229
+ dispatch_action_config:,
230
+ focus_on_load:
231
+ }.compact
232
+ end
233
+
234
+ def url_input(action_id, placeholder: nil, initial_value: nil, dispatch_action_config: nil, focus_on_load: nil)
235
+ {
236
+ type: "url_text_input",
237
+ action_id:,
238
+ placeholder: placeholder ? { type: "plain_text", text: placeholder } : nil,
239
+ initial_value:,
240
+ dispatch_action_config:,
241
+ focus_on_load:
242
+ }.compact
243
+ end
244
+
245
+ def number_input(action_id, is_decimal_allowed:, placeholder: nil, initial_value: nil, min_value: nil,
246
+ max_value: nil, dispatch_action_config: nil, focus_on_load: nil)
247
+ {
248
+ type: "number_input",
249
+ action_id:,
250
+ is_decimal_allowed:,
251
+ placeholder: placeholder ? { type: "plain_text", text: placeholder } : nil,
252
+ initial_value:,
253
+ min_value:,
254
+ max_value:,
255
+ dispatch_action_config:,
256
+ focus_on_load:
257
+ }.compact
258
+ end
259
+
260
+ def radio_buttons(action_id, options, initial_option: nil, confirm: nil, focus_on_load: nil)
261
+ {
262
+ type: "radio_buttons",
263
+ action_id:,
264
+ options:,
265
+ initial_option:,
266
+ confirm:,
267
+ focus_on_load:
268
+ }.compact
269
+ end
270
+
271
+ def select_menu(action_id, placeholder:, options: nil, option_groups: nil, initial_option: nil, confirm: nil,
272
+ focus_on_load: nil)
273
+ {
274
+ type: "static_select",
275
+ action_id:,
276
+ placeholder: { type: "plain_text", text: placeholder },
277
+ options:,
278
+ option_groups:,
279
+ initial_option:,
280
+ confirm:,
281
+ focus_on_load:
282
+ }.compact
283
+ end
284
+
285
+ def multi_select_menu(action_id, placeholder:, options: nil, option_groups: nil, initial_options: nil,
286
+ max_selected_items: nil, confirm: nil, focus_on_load: nil)
287
+ {
288
+ type: "multi_static_select",
289
+ action_id:,
290
+ placeholder: { type: "plain_text", text: placeholder },
291
+ options:,
292
+ option_groups:,
293
+ initial_options:,
294
+ max_selected_items:,
295
+ confirm:,
296
+ focus_on_load:
297
+ }.compact
298
+ end
299
+
300
+ def users_select(action_id, placeholder:, initial_user: nil, confirm: nil, focus_on_load: nil)
301
+ {
302
+ type: "users_select",
303
+ action_id:,
304
+ placeholder: { type: "plain_text", text: placeholder },
305
+ initial_user:,
306
+ confirm:,
307
+ focus_on_load:
308
+ }.compact
309
+ end
310
+
311
+ def multi_users_select(action_id, placeholder:, initial_users: nil, max_selected_items: nil, confirm: nil,
312
+ focus_on_load: nil)
313
+ {
314
+ type: "multi_users_select",
315
+ action_id:,
316
+ placeholder: { type: "plain_text", text: placeholder },
317
+ initial_users:,
318
+ max_selected_items:,
319
+ confirm:,
320
+ focus_on_load:
321
+ }.compact
322
+ end
323
+
324
+ def conversations_select(action_id, placeholder:, initial_conversation: nil, default_to_current_conversation: nil,
325
+ filter: nil, confirm: nil, focus_on_load: nil, response_url_enabled: nil)
326
+ {
327
+ type: "conversations_select",
328
+ action_id:,
329
+ placeholder: { type: "plain_text", text: placeholder },
330
+ initial_conversation:,
331
+ default_to_current_conversation:,
332
+ filter:,
333
+ confirm:,
334
+ focus_on_load:,
335
+ response_url_enabled:
336
+ }.compact
337
+ end
338
+
339
+ def multi_conversations_select(action_id, placeholder:, initial_conversations: nil, max_selected_items: nil,
340
+ default_to_current_conversation: nil, filter: nil, confirm: nil, focus_on_load: nil)
341
+ {
342
+ type: "multi_conversations_select",
343
+ action_id:,
344
+ placeholder: { type: "plain_text", text: placeholder },
345
+ initial_conversations:,
346
+ max_selected_items:,
347
+ default_to_current_conversation:,
348
+ filter:,
349
+ confirm:,
350
+ focus_on_load:
351
+ }.compact
352
+ end
353
+
354
+ def channels_select(action_id, placeholder:, initial_channel: nil, confirm: nil, focus_on_load: nil,
355
+ response_url_enabled: nil)
356
+ {
357
+ type: "channels_select",
358
+ action_id:,
359
+ placeholder: { type: "plain_text", text: placeholder },
360
+ initial_channel:,
361
+ confirm:,
362
+ focus_on_load:,
363
+ response_url_enabled:
364
+ }.compact
365
+ end
366
+
367
+ def multi_channels_select(action_id, placeholder:, initial_channels: nil, max_selected_items: nil, confirm: nil,
368
+ focus_on_load: nil)
369
+ {
370
+ type: "multi_channels_select",
371
+ action_id:,
372
+ placeholder: { type: "plain_text", text: placeholder },
373
+ initial_channels:,
374
+ max_selected_items:,
375
+ confirm:,
376
+ focus_on_load:
377
+ }.compact
378
+ end
379
+
380
+ def overflow(action_id, options, confirm: nil)
381
+ {
382
+ type: "overflow",
383
+ action_id:,
384
+ options:,
385
+ confirm:
386
+ }.compact
387
+ end
388
+
389
+ def file_input(action_id, filetypes: nil, max_files: nil)
390
+ {
391
+ type: "file_input",
392
+ action_id:,
393
+ filetypes:,
394
+ max_files:
395
+ }.compact
396
+ end
397
+
398
+ def rich_text_input(action_id, placeholder: nil, initial_value: nil, dispatch_action_config: nil,
399
+ focus_on_load: nil)
400
+ {
401
+ type: "rich_text_input",
402
+ action_id:,
403
+ placeholder: placeholder ? { type: "plain_text", text: placeholder } : nil,
404
+ initial_value:,
405
+ dispatch_action_config:,
406
+ focus_on_load:
407
+ }.compact
408
+ end
409
+
410
+ def workflow_button(text, workflow:, style: nil, accessibility_label: nil)
411
+ {
412
+ type: "workflow_button",
413
+ text: { type: "plain_text", text: text },
414
+ workflow:,
415
+ style:,
416
+ accessibility_label:
417
+ }.compact
418
+ end
419
+
420
+ def feedback_buttons(positive_text, positive_value, negative_text, negative_value, action_id: nil,
421
+ positive_accessibility_label: nil, negative_accessibility_label: nil)
422
+ {
423
+ type: "feedback_buttons",
424
+ action_id:,
425
+ positive_button: {
426
+ text: { type: "plain_text", text: positive_text },
427
+ value: positive_value,
428
+ accessibility_label: positive_accessibility_label
429
+ }.compact,
430
+ negative_button: {
431
+ text: { type: "plain_text", text: negative_text },
432
+ value: negative_value,
433
+ accessibility_label: negative_accessibility_label
434
+ }.compact
435
+ }.compact
436
+ end
437
+
438
+ def icon_button(icon, text, action_id, value: nil, confirm: nil, accessibility_label: nil,
439
+ visible_to_user_ids: nil)
440
+ {
441
+ type: "icon_button",
442
+ icon:,
443
+ text: { type: "plain_text", text: text },
444
+ action_id:,
445
+ value:,
446
+ confirm:,
447
+ accessibility_label:,
448
+ visible_to_user_ids:
449
+ }.compact
450
+ end
451
+
452
+ def option(text, value, description: nil, url: nil)
453
+ opt = {
454
+ text: { type: "plain_text", text: text },
455
+ value: value
456
+ }
457
+ opt[:description] = { type: "plain_text", text: description } if description
458
+ opt[:url] = url if url
459
+ opt
460
+ end
461
+
462
+ def option_group(label, options)
463
+ {
464
+ label: { type: "plain_text", text: label },
465
+ options: options
466
+ }
467
+ end
468
+
469
+ def confirm_dialog(title:, text:, confirm:, deny:, style: nil)
470
+ dialog = {
471
+ title: { type: "plain_text", text: title },
472
+ text: { type: "mrkdwn", text: text },
473
+ confirm: { type: "plain_text", text: confirm },
474
+ deny: { type: "plain_text", text: deny }
475
+ }
476
+ dialog[:style] = style if style
477
+ dialog
478
+ end
479
+
480
+ def dispatch_action_config(*trigger_actions_on)
481
+ {
482
+ trigger_actions_on: trigger_actions_on
483
+ }
484
+ end
485
+
486
+ def filter(include: nil, exclude_external_shared_channels: nil, exclude_bot_users: nil)
487
+ f = {}
488
+ f[:include] = include if include
489
+ f[:exclude_external_shared_channels] = exclude_external_shared_channels if exclude_external_shared_channels
490
+ f[:exclude_bot_users] = exclude_bot_users if exclude_bot_users
491
+ f
492
+ end
493
+
494
+ def render(partial, locals: {})
495
+ if partial.respond_to?(:to_ary)
496
+ partial.flat_map do |item|
497
+ render_partial_for_item(item, locals)
498
+ end
499
+ else
500
+ render_partial_for_item(partial, locals)
501
+ end
502
+ end
503
+
504
+ def method_missing(method, *args, **kwargs, &block)
505
+ if @context.respond_to?(method)
506
+ @context.public_send(method, *args, **kwargs, &block)
507
+ else
508
+ super
509
+ end
510
+ end
511
+
512
+ def respond_to_missing?(method, include_private = false)
513
+ @context.respond_to?(method, include_private) || super
514
+ end
515
+
516
+ private
517
+
518
+ def render_partial_for_item(item, locals)
519
+ partial_name = item.class.name.underscore
520
+ partial_path = "#{partial_name.pluralize}/#{partial_name}"
521
+
522
+ result = @context.render(
523
+ partial: partial_path,
524
+ locals: locals.merge(partial_name.to_sym => item),
525
+ formats: [:slack_blocks]
526
+ )
527
+
528
+ JSON.parse(result)["blocks"] || []
529
+ end
530
+ end
531
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slocks
4
+ class ModalBuilder < BlocksBuilder
5
+ attr_reader :modal_title, :submit_text, :close_text, :callback_id
6
+
7
+ def title(text)
8
+ @modal_title = text
9
+ end
10
+
11
+ def submit(text)
12
+ @submit_text = text
13
+ end
14
+
15
+ def close(text)
16
+ @close_text = text
17
+ end
18
+
19
+ def callback(id)
20
+ @callback_id = id
21
+ end
22
+
23
+ def to_h
24
+ modal = {
25
+ type: "modal",
26
+ title: { type: "plain_text", text: @modal_title },
27
+ blocks: @blocks,
28
+ callback_id: @callback_id
29
+ }.compact
30
+ modal[:submit] = { type: "plain_text", text: @submit_text } if @submit_text
31
+ modal[:close] = { type: "plain_text", text: @close_text } if @close_text
32
+ modal
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+
5
+ module Slocks
6
+ class Railtie < ::Rails::Railtie
7
+ initializer "slocks.register_template_handler" do
8
+ ActiveSupport.on_load(:action_view) do
9
+ require "slocks/action_view_monkeys"
10
+ require "slocks/template_handler"
11
+
12
+ Mime::Type.register "application/json", :slack_message
13
+ Mime::Type.register "application/json", :slack_modal
14
+
15
+ ActionView::Template.register_template_handler(:slocks, Slocks::TemplateHandlerDispatcher)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slocks
4
+ class RichTextBuilder
5
+ attr_reader :elements
6
+
7
+ def initialize
8
+ @elements = []
9
+ end
10
+
11
+ def section(&block)
12
+ section_builder = RichTextSectionBuilder.new
13
+ section_builder.instance_eval(&block) if block_given?
14
+ @elements << {
15
+ type: "rich_text_section",
16
+ elements: section_builder.elements
17
+ }
18
+ end
19
+
20
+ def list(style:, &block)
21
+ list_builder = RichTextSectionBuilder.new
22
+ list_builder.instance_eval(&block) if block_given?
23
+ @elements << {
24
+ type: "rich_text_list",
25
+ style: style,
26
+ elements: list_builder.elements
27
+ }
28
+ end
29
+
30
+ def preformatted(&block)
31
+ section_builder = RichTextSectionBuilder.new
32
+ section_builder.instance_eval(&block) if block_given?
33
+ @elements << {
34
+ type: "rich_text_preformatted",
35
+ elements: section_builder.elements
36
+ }
37
+ end
38
+
39
+ def quote(&block)
40
+ section_builder = RichTextSectionBuilder.new
41
+ section_builder.instance_eval(&block) if block_given?
42
+ @elements << {
43
+ type: "rich_text_quote",
44
+ elements: section_builder.elements
45
+ }
46
+ end
47
+ end
48
+
49
+ class RichTextSectionBuilder
50
+ attr_reader :elements
51
+
52
+ def initialize
53
+ @elements = []
54
+ end
55
+
56
+ def text(text, bold: nil, italic: nil, strike: nil, code: nil)
57
+ element = { type: "text", text: text }
58
+ style = {}
59
+ style[:bold] = true if bold
60
+ style[:italic] = true if italic
61
+ style[:strike] = true if strike
62
+ style[:code] = true if code
63
+ element[:style] = style unless style.empty?
64
+ @elements << element
65
+ end
66
+
67
+ def link(url, text: nil, unsafe: nil, style: nil)
68
+ element = { type: "link", url: url }
69
+ element[:text] = text if text
70
+ element[:unsafe] = unsafe if unsafe
71
+ element[:style] = style if style
72
+ @elements << element
73
+ end
74
+
75
+ def emoji(name)
76
+ @elements << { type: "emoji", name: name }
77
+ end
78
+
79
+ def channel(channel_id)
80
+ @elements << { type: "channel", channel_id: channel_id }
81
+ end
82
+
83
+ def user(user_id)
84
+ @elements << { type: "user", user_id: user_id }
85
+ end
86
+
87
+ def usergroup(usergroup_id)
88
+ @elements << { type: "usergroup", usergroup_id: usergroup_id }
89
+ end
90
+
91
+ def date(timestamp, format:, fallback: nil, link: nil)
92
+ element = { type: "date", timestamp: timestamp, format: format }
93
+ element[:fallback] = fallback if fallback
94
+ element[:link] = link if link
95
+ @elements << element
96
+ end
97
+
98
+ def broadcast(range:)
99
+ @elements << { type: "broadcast", range: range }
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/class/attribute"
4
+
5
+ module Slocks
6
+ class TemplateHandlerDispatcher
7
+ def self.call(template, source = nil)
8
+ case template.format&.to_sym
9
+ when :slack_modal
10
+ ModalTemplateHandler.call(template, source)
11
+ else
12
+ BlocksTemplateHandler.call(template, source)
13
+ end
14
+ end
15
+
16
+ def self.default_format = :slack_message
17
+ def self.handles_encoding? = true
18
+
19
+ def self.translate_location(spot, backtrace_location, source)
20
+ BlocksTemplateHandler.translate_location(spot, backtrace_location, source)
21
+ end
22
+ end
23
+
24
+ class BlocksTemplateHandler
25
+ class << self
26
+ def default_format = :slack_message
27
+
28
+ def preamble = <<~EOP
29
+
30
+
31
+ @__slocks_builder ||= Slocks::BlocksBuilder.new(self)
32
+ def method_missing(m, *a, **k, &b)
33
+ @__slocks_builder.respond_to?(m) ? @__slocks_builder.public_send(m, *a, **k, &b) : super
34
+ end
35
+ EOP
36
+
37
+ def call(template, source = nil)
38
+ source ||= template.source
39
+ <<~EOS
40
+ #{preamble}
41
+ #{source}
42
+ { blocks: @__slocks_builder.blocks }
43
+ EOS
44
+ end
45
+
46
+ def translate_location(spot, _backtrace_location, _source)
47
+ # eat your heart out rails-core
48
+ # this sucks but seems to work
49
+ plen = preamble.lines.size + 2
50
+ spot[:first_lineno] -= plen
51
+ spot[:last_lineno] -= plen
52
+ spot[:script_lines] = spot[:script_lines][plen..-4]
53
+ spot
54
+ end
55
+
56
+ def handles_encoding? = true
57
+ end
58
+ end
59
+
60
+ class ModalTemplateHandler < BlocksTemplateHandler
61
+ class << self
62
+ def default_format = :slack_modal
63
+
64
+ def call(template, source = nil)
65
+ source ||= template.source
66
+ <<~EOS
67
+ @__slocks_modal_builder ||= Slocks::ModalBuilder.new(self)
68
+ def method_missing(m, *a, **k, &b);
69
+ @__slocks_modal_builder.respond_to?(m) ? @__slocks_modal_builder.public_send(m, *a, **k, &b) : super;
70
+ end
71
+ #{source}
72
+ @__slocks_modal_builder.to_h
73
+ EOS
74
+ end
75
+ end
76
+ end
77
+
78
+ TemplateHandler = BlocksTemplateHandler
79
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slocks
4
+ VERSION = "0.1.0"
5
+ end
data/lib/slocks.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "slocks/version"
4
+ require_relative "slocks/rich_text_builder"
5
+ require_relative "slocks/blocks_builder"
6
+ require_relative "slocks/modal_builder"
7
+ require_relative "slocks/template_handler"
8
+ require_relative "slocks/railtie" if defined?(Rails::Railtie)
9
+
10
+ module Slocks
11
+ class Error < StandardError; end
12
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: slocks
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - 24c02
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionview
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '6.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '6.0'
40
+ description: A Rails template handler that provides a clean DSL for building Slack
41
+ Block Kit blocks and modals
42
+ email:
43
+ - slocks@noras.email
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - README.md
49
+ - Rakefile
50
+ - lib/slocks.rb
51
+ - lib/slocks/action_view_monkeys.rb
52
+ - lib/slocks/blocks_builder.rb
53
+ - lib/slocks/modal_builder.rb
54
+ - lib/slocks/railtie.rb
55
+ - lib/slocks/rich_text_builder.rb
56
+ - lib/slocks/template_handler.rb
57
+ - lib/slocks/version.rb
58
+ homepage: https://github.com/24c02/slocks
59
+ licenses: []
60
+ metadata:
61
+ homepage_uri: https://github.com/24c02/slocks
62
+ source_code_uri: https://github.com/24c02/slocks
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: 3.2.0
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.6.9
78
+ specification_version: 4
79
+ summary: Rails template handler for Slack Block Kit
80
+ test_files: []