block-kit 1.0.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.
Files changed (120) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +5 -0
  3. data/CODE_OF_CONDUCT.md +132 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +183 -0
  6. data/Rakefile +8 -0
  7. data/lib/block_kit/base.rb +190 -0
  8. data/lib/block_kit/blocks.rb +50 -0
  9. data/lib/block_kit/composition/confirmation_dialog.rb +48 -0
  10. data/lib/block_kit/composition/conversation_filter.rb +42 -0
  11. data/lib/block_kit/composition/dispatch_action_config.rb +33 -0
  12. data/lib/block_kit/composition/input_parameter.rb +19 -0
  13. data/lib/block_kit/composition/mrkdwn.rb +15 -0
  14. data/lib/block_kit/composition/option.rb +41 -0
  15. data/lib/block_kit/composition/option_group.rb +27 -0
  16. data/lib/block_kit/composition/overflow_option.rb +21 -0
  17. data/lib/block_kit/composition/plain_text.rb +15 -0
  18. data/lib/block_kit/composition/slack_file.rb +36 -0
  19. data/lib/block_kit/composition/text.rb +29 -0
  20. data/lib/block_kit/composition/trigger.rb +33 -0
  21. data/lib/block_kit/composition/workflow.rb +19 -0
  22. data/lib/block_kit/composition.rb +19 -0
  23. data/lib/block_kit/concerns/confirmable.rb +21 -0
  24. data/lib/block_kit/concerns/conversation_selection.rb +26 -0
  25. data/lib/block_kit/concerns/dispatchable.rb +24 -0
  26. data/lib/block_kit/concerns/dsl_generation.rb +76 -0
  27. data/lib/block_kit/concerns/external.rb +19 -0
  28. data/lib/block_kit/concerns/focusable_on_load.rb +17 -0
  29. data/lib/block_kit/concerns/has_initial_option.rb +23 -0
  30. data/lib/block_kit/concerns/has_initial_options.rb +23 -0
  31. data/lib/block_kit/concerns/has_option_groups.rb +37 -0
  32. data/lib/block_kit/concerns/has_options.rb +28 -0
  33. data/lib/block_kit/concerns/has_placeholder.rb +21 -0
  34. data/lib/block_kit/concerns/has_rich_text_elements.rb +83 -0
  35. data/lib/block_kit/concerns/plain_text_emoji_assignment.rb +39 -0
  36. data/lib/block_kit/concerns.rb +18 -0
  37. data/lib/block_kit/elements/base.rb +23 -0
  38. data/lib/block_kit/elements/base_button.rb +45 -0
  39. data/lib/block_kit/elements/button.rb +27 -0
  40. data/lib/block_kit/elements/channels_select.rb +22 -0
  41. data/lib/block_kit/elements/checkboxes.rb +14 -0
  42. data/lib/block_kit/elements/conversations_select.rb +24 -0
  43. data/lib/block_kit/elements/date_picker.rb +21 -0
  44. data/lib/block_kit/elements/datetime_picker.rb +21 -0
  45. data/lib/block_kit/elements/email_text_input.rb +24 -0
  46. data/lib/block_kit/elements/external_select.rb +21 -0
  47. data/lib/block_kit/elements/file_input.rb +33 -0
  48. data/lib/block_kit/elements/image.rb +53 -0
  49. data/lib/block_kit/elements/multi_channels_select.rb +31 -0
  50. data/lib/block_kit/elements/multi_conversations_select.rb +33 -0
  51. data/lib/block_kit/elements/multi_external_select.rb +21 -0
  52. data/lib/block_kit/elements/multi_select.rb +21 -0
  53. data/lib/block_kit/elements/multi_static_select.rb +12 -0
  54. data/lib/block_kit/elements/multi_users_select.rb +31 -0
  55. data/lib/block_kit/elements/number_input.rb +52 -0
  56. data/lib/block_kit/elements/overflow.rb +23 -0
  57. data/lib/block_kit/elements/plain_text_input.rb +66 -0
  58. data/lib/block_kit/elements/radio_buttons.rb +14 -0
  59. data/lib/block_kit/elements/rich_text_input.rb +26 -0
  60. data/lib/block_kit/elements/select.rb +18 -0
  61. data/lib/block_kit/elements/static_select.rb +12 -0
  62. data/lib/block_kit/elements/time_picker.rb +43 -0
  63. data/lib/block_kit/elements/url_text_input.rb +24 -0
  64. data/lib/block_kit/elements/users_select.rb +17 -0
  65. data/lib/block_kit/elements/workflow_button.rb +18 -0
  66. data/lib/block_kit/elements.rb +36 -0
  67. data/lib/block_kit/fixers/associated.rb +27 -0
  68. data/lib/block_kit/fixers/base.rb +30 -0
  69. data/lib/block_kit/fixers/null_value.rb +42 -0
  70. data/lib/block_kit/fixers/truncate.rb +35 -0
  71. data/lib/block_kit/fixers.rb +11 -0
  72. data/lib/block_kit/layout/actions.rb +84 -0
  73. data/lib/block_kit/layout/base.rb +23 -0
  74. data/lib/block_kit/layout/context.rb +48 -0
  75. data/lib/block_kit/layout/divider.rb +9 -0
  76. data/lib/block_kit/layout/file.rb +19 -0
  77. data/lib/block_kit/layout/header.rb +22 -0
  78. data/lib/block_kit/layout/image.rb +61 -0
  79. data/lib/block_kit/layout/input.rb +119 -0
  80. data/lib/block_kit/layout/markdown.rb +19 -0
  81. data/lib/block_kit/layout/rich_text/elements/broadcast.rb +22 -0
  82. data/lib/block_kit/layout/rich_text/elements/channel.rb +21 -0
  83. data/lib/block_kit/layout/rich_text/elements/color.rb +20 -0
  84. data/lib/block_kit/layout/rich_text/elements/date.rb +34 -0
  85. data/lib/block_kit/layout/rich_text/elements/emoji.rb +27 -0
  86. data/lib/block_kit/layout/rich_text/elements/link.rb +28 -0
  87. data/lib/block_kit/layout/rich_text/elements/mention_style.rb +27 -0
  88. data/lib/block_kit/layout/rich_text/elements/text.rb +23 -0
  89. data/lib/block_kit/layout/rich_text/elements/text_style.rb +23 -0
  90. data/lib/block_kit/layout/rich_text/elements/user.rb +21 -0
  91. data/lib/block_kit/layout/rich_text/elements/usergroup.rb +21 -0
  92. data/lib/block_kit/layout/rich_text/elements.rb +35 -0
  93. data/lib/block_kit/layout/rich_text/list.rb +41 -0
  94. data/lib/block_kit/layout/rich_text/preformatted.rb +19 -0
  95. data/lib/block_kit/layout/rich_text/quote.rb +19 -0
  96. data/lib/block_kit/layout/rich_text/section.rb +11 -0
  97. data/lib/block_kit/layout/rich_text.rb +53 -0
  98. data/lib/block_kit/layout/section.rb +152 -0
  99. data/lib/block_kit/layout/video.rb +71 -0
  100. data/lib/block_kit/layout.rb +35 -0
  101. data/lib/block_kit/surfaces/base.rb +173 -0
  102. data/lib/block_kit/surfaces/home.rb +40 -0
  103. data/lib/block_kit/surfaces/message.rb +140 -0
  104. data/lib/block_kit/surfaces/modal.rb +74 -0
  105. data/lib/block_kit/surfaces.rb +11 -0
  106. data/lib/block_kit/typed_array.rb +114 -0
  107. data/lib/block_kit/typed_set.rb +78 -0
  108. data/lib/block_kit/types/array.rb +45 -0
  109. data/lib/block_kit/types/blocks.rb +37 -0
  110. data/lib/block_kit/types/generic.rb +45 -0
  111. data/lib/block_kit/types/option.rb +48 -0
  112. data/lib/block_kit/types/set.rb +35 -0
  113. data/lib/block_kit/types/text.rb +80 -0
  114. data/lib/block_kit/types.rb +17 -0
  115. data/lib/block_kit/validators/array_inclusion_validator.rb +55 -0
  116. data/lib/block_kit/validators/associated_validator.rb +61 -0
  117. data/lib/block_kit/validators.rb +8 -0
  118. data/lib/block_kit/version.rb +5 -0
  119. data/lib/block_kit.rb +38 -0
  120. metadata +190 -0
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockKit
4
+ module Layout
5
+ class RichText < Base
6
+ self.type = :rich_text
7
+
8
+ autoload :Elements, "block_kit/layout/rich_text/elements"
9
+ autoload :List, "block_kit/layout/rich_text/list"
10
+ autoload :Preformatted, "block_kit/layout/rich_text/preformatted"
11
+ autoload :Quote, "block_kit/layout/rich_text/quote"
12
+ autoload :Section, "block_kit/layout/rich_text/section"
13
+
14
+ SUPPORTED_ELEMENTS = [
15
+ RichText::List,
16
+ RichText::Preformatted,
17
+ RichText::Quote,
18
+ RichText::Section
19
+ ].freeze
20
+
21
+ attribute :elements, Types::Array.of(Types::Blocks.new(*SUPPORTED_ELEMENTS))
22
+ validates :elements, presence: true, "block_kit/validators/associated": true
23
+ fixes :elements, associated: true
24
+
25
+ dsl_method :elements, as: :list, type: RichText::List
26
+ dsl_method :elements, as: :preformatted, type: RichText::Preformatted
27
+ dsl_method :elements, as: :quote, type: RichText::Quote
28
+ dsl_method :elements, as: :section, type: RichText::Section
29
+
30
+ alias_method :rich_text_list, :list
31
+ alias_method :rich_text_preformatted, :preformatted
32
+ alias_method :rich_text_quote, :quote
33
+ alias_method :rich_text_section, :section
34
+
35
+ def initialize(attributes = {})
36
+ attributes = attributes.with_indifferent_access
37
+ attributes[:elements] ||= []
38
+
39
+ super
40
+ end
41
+
42
+ def append(element)
43
+ elements << element
44
+
45
+ self
46
+ end
47
+
48
+ def as_json(*)
49
+ super.merge(elements: elements&.map(&:as_json)).compact
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockKit
4
+ module Layout
5
+ class Section < Base
6
+ self.type = :section
7
+
8
+ MAX_TEXT_LENGTH = 3000
9
+ MAX_FIELDS = 10
10
+ MAX_FIELD_TEXT_LENGTH = 2000
11
+ SUPPORTED_ELEMENTS = [
12
+ Elements::Button,
13
+ Elements::ChannelsSelect,
14
+ Elements::Checkboxes,
15
+ Elements::ConversationsSelect,
16
+ Elements::DatePicker,
17
+ Elements::ExternalSelect,
18
+ Elements::Image,
19
+ Elements::MultiChannelsSelect,
20
+ Elements::MultiConversationsSelect,
21
+ Elements::MultiExternalSelect,
22
+ Elements::MultiStaticSelect,
23
+ Elements::MultiUsersSelect,
24
+ Elements::Overflow,
25
+ Elements::RadioButtons,
26
+ Elements::StaticSelect,
27
+ Elements::TimePicker,
28
+ Elements::UsersSelect,
29
+ Elements::WorkflowButton
30
+ ].freeze
31
+
32
+ attribute :text, Types::Generic.of_type(Composition::Text)
33
+ attribute :fields, Types::Array.of(Composition::Text)
34
+ attribute :accessory, Types::Blocks.new(*SUPPORTED_ELEMENTS)
35
+ attribute :expand, :boolean
36
+
37
+ validates :text, length: {maximum: MAX_TEXT_LENGTH}, presence: {allow_nil: true}
38
+ fixes :text, truncate: {maximum: MAX_TEXT_LENGTH}
39
+
40
+ validates :fields, length: {maximum: MAX_FIELDS, message: "is too long (maximum is %{count} fields)"}, presence: {allow_nil: true}
41
+ fixes :fields, truncate: {maximum: MAX_FIELDS}
42
+ fix :truncate_long_fields
43
+ fix :remove_blank_fields
44
+
45
+ validates :accessory, "block_kit/validators/associated": true
46
+ fixes :accessory, associated: true
47
+
48
+ validate :has_text_or_fields
49
+ validate :fields_are_valid
50
+
51
+ def expand?
52
+ !!expand
53
+ end
54
+
55
+ dsl_method :text, as: :mrkdwn, type: Composition::Mrkdwn, required_fields: [:text], yields: false
56
+ dsl_method :text, as: :plain_text, type: Composition::PlainText, required_fields: [:text], yields: false
57
+ dsl_method :fields, as: :mrkdwn_field, type: Composition::Mrkdwn, required_fields: [:text], yields: false
58
+ dsl_method :fields, as: :plain_text_field, type: Composition::PlainText, required_fields: [:text], yields: false
59
+ dsl_method :accessory, as: :button, type: Elements::Button, required_fields: [:text]
60
+ dsl_method :accessory, as: :channels_select, type: Elements::ChannelsSelect
61
+ dsl_method :accessory, as: :checkboxes, type: Elements::Checkboxes
62
+ dsl_method :accessory, as: :conversations_select, type: Elements::ConversationsSelect
63
+ dsl_method :accessory, as: :datepicker, type: Elements::DatePicker
64
+ dsl_method :accessory, as: :external_select, type: Elements::ExternalSelect
65
+ dsl_method :accessory, as: :multi_channels_select, type: Elements::MultiChannelsSelect
66
+ dsl_method :accessory, as: :multi_conversations_select, type: Elements::MultiConversationsSelect
67
+ dsl_method :accessory, as: :multi_external_select, type: Elements::MultiExternalSelect
68
+ dsl_method :accessory, as: :multi_static_select, type: Elements::MultiStaticSelect, mutually_exclusive_fields: [:options, :option_groups]
69
+ dsl_method :accessory, as: :multi_users_select, type: Elements::MultiUsersSelect
70
+ dsl_method :accessory, as: :overflow, type: Elements::Overflow
71
+ dsl_method :accessory, as: :radio_buttons, type: Elements::RadioButtons
72
+ dsl_method :accessory, as: :static_select, type: Elements::StaticSelect, mutually_exclusive_fields: [:options, :option_groups]
73
+ dsl_method :accessory, as: :timepicker, type: Elements::TimePicker
74
+ dsl_method :accessory, as: :users_select, type: Elements::UsersSelect
75
+ dsl_method :accessory, as: :workflow_button, type: Elements::WorkflowButton, required_fields: [:text]
76
+
77
+ alias_method :channel_select, :channels_select
78
+ alias_method :conversation_select, :conversations_select
79
+ alias_method :date_picker, :datepicker
80
+ alias_method :multi_channel_select, :multi_channels_select
81
+ alias_method :multi_conversation_select, :multi_conversations_select
82
+ alias_method :multi_user_select, :multi_users_select
83
+ alias_method :overflow_menu, :overflow
84
+ alias_method :time_picker, :timepicker
85
+ alias_method :user_select, :users_select
86
+
87
+ def field(text:, type: :mrkdwn, verbatim: nil, emoji: nil)
88
+ case type.to_sym
89
+ when :mrkdwn
90
+ mrkdwn_field(text: text, verbatim: verbatim)
91
+ when :plain_text
92
+ plain_text_field(text: text, emoji: emoji)
93
+ else
94
+ raise ArgumentError, "Invalid field type: #{type} (must be mrkdwn or plain_text)"
95
+ end
96
+ end
97
+
98
+ def image(alt_text:, image_url: nil, slack_file: nil)
99
+ if (image_url.nil? && slack_file.nil?) || (image_url && slack_file)
100
+ raise ArgumentError, "Must provide either image_url or slack_file, but not both."
101
+ end
102
+
103
+ self.accessory = Elements::Image.new(alt_text: alt_text, image_url: image_url, slack_file: slack_file)
104
+
105
+ self
106
+ end
107
+
108
+ def as_json(*)
109
+ super.merge(
110
+ text: text&.as_json,
111
+ fields: fields&.map(&:as_json),
112
+ accessory: accessory&.as_json,
113
+ expand: expand
114
+ ).compact
115
+ end
116
+
117
+ private
118
+
119
+ # Pure ActiveModel validations get a little too complicated with this combo, as
120
+ # we need to not only make sure at least one of these two is present, but that
121
+ # neither is _blank_. It's easier to just do this in a custom validation and
122
+ # keep the individual validations focused on presence while allowing nil.
123
+ def has_text_or_fields
124
+ if text.blank? && fields.blank?
125
+ errors.add(:base, "must have either text or fields")
126
+ end
127
+ end
128
+
129
+ def fields_are_valid
130
+ fields&.each_with_index do |field, i|
131
+ if field.length > MAX_FIELD_TEXT_LENGTH
132
+ errors.add("fields[#{i}]", "is invalid: text is too long (maximum is #{MAX_FIELD_TEXT_LENGTH} characters)")
133
+ elsif field.blank?
134
+ errors.add("fields[#{i}]", "is invalid: text can't be blank")
135
+ end
136
+ end
137
+ end
138
+
139
+ def truncate_long_fields
140
+ Array(fields).select { |field| field.length > MAX_FIELD_TEXT_LENGTH }.each do |field|
141
+ field.text = field.text.truncate(MAX_FIELD_TEXT_LENGTH)
142
+ end
143
+ end
144
+
145
+ def remove_blank_fields
146
+ Array(fields).select(&:blank?).each do |field|
147
+ fields.delete(field)
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module BlockKit
6
+ module Layout
7
+ class Video < Base
8
+ self.type = :video
9
+
10
+ MAX_ALT_TEXT_LENGTH = 2000
11
+ MAX_AUTHOR_NAME_LENGTH = 50
12
+ MAX_DESCRIPTION_LENGTH = 200
13
+ MAX_URL_LENGTH = 3000
14
+ MAX_PROVIDER_NAME_LENGTH = 50
15
+ MAX_TITLE_LENGTH = 200
16
+
17
+ attribute :alt_text, :string
18
+ attribute :author_name, :string
19
+ plain_text_attribute :description
20
+ attribute :provider_icon_url, :string
21
+ attribute :provider_name, :string
22
+ plain_text_attribute :title
23
+ attribute :title_url, :string
24
+ attribute :thumbnail_url, :string
25
+ attribute :video_url, :string
26
+
27
+ include Concerns::PlainTextEmojiAssignment.new(:description, :title)
28
+
29
+ validates :alt_text, presence: true, length: {maximum: MAX_ALT_TEXT_LENGTH}
30
+ fixes :alt_text, truncate: {maximum: MAX_ALT_TEXT_LENGTH}
31
+
32
+ validates :author_name, presence: true, length: {maximum: MAX_AUTHOR_NAME_LENGTH}, allow_nil: true
33
+ fixes :author_name, truncate: {maximum: MAX_AUTHOR_NAME_LENGTH}, null_value: {error_types: [:blank]}
34
+
35
+ validates :description, presence: true, length: {maximum: MAX_DESCRIPTION_LENGTH}, allow_nil: true
36
+ fixes :description, truncate: {maximum: MAX_DESCRIPTION_LENGTH}, null_value: {error_types: [:blank]}
37
+
38
+ validates :provider_icon_url, presence: true, length: {maximum: MAX_URL_LENGTH}, format: {with: URI::DEFAULT_PARSER.make_regexp(%w[https http]), message: "is not a valid URI"}, allow_nil: true
39
+ fixes :provider_icon_url, truncate: {maximum: MAX_URL_LENGTH, dangerous: true, omission: ""}, null_value: {error_types: [:blank]}
40
+
41
+ validates :provider_name, presence: true, length: {maximum: MAX_PROVIDER_NAME_LENGTH}, allow_nil: true
42
+ fixes :provider_name, truncate: {maximum: MAX_PROVIDER_NAME_LENGTH}, null_value: {error_types: [:blank]}
43
+
44
+ validates :title, presence: true, length: {maximum: MAX_TITLE_LENGTH}
45
+ fixes :title, truncate: {maximum: MAX_TITLE_LENGTH}
46
+
47
+ validates :title_url, presence: true, length: {maximum: MAX_URL_LENGTH}, format: {with: URI::DEFAULT_PARSER.make_regexp(%w[https]), message: "is not a valid HTTPS URI"}, allow_nil: true
48
+ fixes :title_url, truncate: {maximum: MAX_URL_LENGTH, dangerous: true, omission: ""}, null_value: {error_types: [:blank]}
49
+
50
+ validates :thumbnail_url, presence: true, length: {maximum: MAX_URL_LENGTH}, format: {with: URI::DEFAULT_PARSER.make_regexp(%w[https http]), message: "is not a valid URI"}
51
+ fixes :thumbnail_url, truncate: {maximum: MAX_URL_LENGTH, dangerous: true, omission: ""}
52
+
53
+ validates :video_url, presence: true, length: {maximum: MAX_URL_LENGTH}, format: {with: URI::DEFAULT_PARSER.make_regexp(%w[https]), message: "is not a valid HTTPS URI"}
54
+ fixes :video_url, truncate: {maximum: MAX_URL_LENGTH, dangerous: true, omission: ""}
55
+
56
+ def as_json(*)
57
+ super.merge(
58
+ alt_text: alt_text,
59
+ author_name: author_name,
60
+ description: description&.as_json,
61
+ provider_icon_url: provider_icon_url,
62
+ provider_name: provider_name,
63
+ title: title&.as_json,
64
+ title_url: title_url,
65
+ thumbnail_url: thumbnail_url,
66
+ video_url: video_url
67
+ ).compact
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockKit
4
+ module Layout
5
+ autoload :Base, "block_kit/layout/base"
6
+
7
+ autoload :Actions, "block_kit/layout/actions"
8
+ autoload :Context, "block_kit/layout/context"
9
+ autoload :Divider, "block_kit/layout/divider"
10
+ autoload :File, "block_kit/layout/file"
11
+ autoload :Header, "block_kit/layout/header"
12
+ autoload :Image, "block_kit/layout/image"
13
+ autoload :Input, "block_kit/layout/input"
14
+ autoload :Markdown, "block_kit/layout/markdown"
15
+ autoload :RichText, "block_kit/layout/rich_text"
16
+ autoload :Section, "block_kit/layout/section"
17
+ autoload :Video, "block_kit/layout/video"
18
+
19
+ def self.all
20
+ @all ||= [
21
+ Actions,
22
+ Context,
23
+ Divider,
24
+ File,
25
+ Header,
26
+ Image,
27
+ Input,
28
+ Markdown,
29
+ RichText,
30
+ Section,
31
+ Video
32
+ ].freeze
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module BlockKit
6
+ module Surfaces
7
+ class Base < BlockKit::Base
8
+ self.type = :surface
9
+
10
+ MAX_BLOCKS = 100
11
+ SUPPORTED_BLOCKS = [
12
+ Layout::Actions,
13
+ Layout::Context,
14
+ Layout::Divider,
15
+ Layout::Header,
16
+ Layout::Image,
17
+ Layout::Input,
18
+ Layout::RichText,
19
+ Layout::Section,
20
+ Layout::Video
21
+ ]
22
+
23
+ attribute :blocks, Types::Array.of(Types::Blocks.new(*SUPPORTED_BLOCKS))
24
+ attribute :private_metadata, :string
25
+ attribute :callback_id, :string
26
+ attribute :external_id, :string
27
+
28
+ validates :blocks, presence: true, length: {maximum: MAX_BLOCKS, message: "is too long (maximum is %{count} blocks)"}, "block_kit/validators/associated": true
29
+ validate :no_unsupported_elements
30
+ fix :remove_unsupported_elements, dangerous: true
31
+ fixes :blocks, truncate: {maximum: MAX_BLOCKS, dangerous: true}, associated: true
32
+
33
+ validates :private_metadata, length: {maximum: 3000}, allow_nil: true
34
+ validates :callback_id, length: {maximum: 255}, allow_nil: true
35
+ validates :external_id, length: {maximum: 255}, allow_nil: true
36
+ validate :only_one_element_focuses_on_load
37
+ fix :unset_focus_on_load_on_all_elements
38
+
39
+ dsl_method :blocks, as: :actions, type: Layout::Actions
40
+ dsl_method :blocks, as: :context, type: Layout::Context
41
+ dsl_method :blocks, as: :divider, type: Layout::Divider, yields: false
42
+ dsl_method :blocks, as: :header, type: Layout::Header, required_fields: [:text], yields: false
43
+ dsl_method :blocks, as: :input, type: Layout::Input
44
+ dsl_method :blocks, as: :rich_text, type: Layout::RichText
45
+ dsl_method :blocks, as: :section, type: Layout::Section
46
+ dsl_method :blocks, as: :video, type: Layout::Video, required_fields: [:alt_text, :title, :thumbnail_url, :video_url], yields: false
47
+
48
+ def initialize(attributes = {})
49
+ raise NotImplementedError, "#{self.class} is an abstract class and can't be instantiated." if instance_of?(Base)
50
+
51
+ super
52
+ end
53
+
54
+ def image(alt_text:, image_url: nil, slack_file: nil, title: nil, emoji: nil, block_id: nil)
55
+ if (image_url.nil? && slack_file.nil?) || (image_url && slack_file)
56
+ raise ArgumentError, "Must provide either image_url or slack_file, but not both."
57
+ end
58
+
59
+ append(Layout::Image.new(image_url: image_url, slack_file: slack_file, alt_text: alt_text, title: title, block_id: block_id, emoji: emoji))
60
+ end
61
+
62
+ # Overridden to return `self`, allowing chaining.
63
+ def append(block)
64
+ blocks << block
65
+
66
+ self
67
+ end
68
+
69
+ def as_json(*)
70
+ super.merge(
71
+ blocks: blocks&.map(&:as_json),
72
+ private_metadata: private_metadata,
73
+ callback_id: callback_id,
74
+ external_id: external_id
75
+ ).compact
76
+ end
77
+
78
+ private
79
+
80
+ def no_unsupported_elements
81
+ unsupported_elements = unsupported_elements_by_path
82
+ return if unsupported_elements.empty?
83
+
84
+ errors.add(:blocks, "contains unsupported elements")
85
+
86
+ unsupported_elements.each do |path, element|
87
+ errors.add(path, "is invalid: #{element.class.type} is not a supported element for this surface")
88
+ end
89
+ end
90
+
91
+ # Crawls through deeply nested blocks, looking for blocks that impelement the
92
+ # BlockKit::Concerns::FocusableOnLoad concern, and ensures that only one of them
93
+ # has `focus_on_load` set to true.
94
+ def only_one_element_focuses_on_load
95
+ focusable_blocks = focused_blocks_by_path
96
+
97
+ if focusable_blocks.size > 1
98
+ errors.add(:blocks, "can't have more than one element with focus_on_load set to true")
99
+
100
+ focusable_blocks.each do |path, element|
101
+ errors.add(path, "is invalid: can't set focus_on_load when other elements have set focus_on_load")
102
+ end
103
+ end
104
+ end
105
+
106
+ def unsupported_elements_by_path
107
+ unsupported_elements = {}
108
+
109
+ blocks.each_with_index do |block, index|
110
+ # Context block elements are globally supported, so we don't need to check them.
111
+ case block
112
+ when Layout::Actions
113
+ block.elements.each_with_index do |element, element_index|
114
+ unless self.class::SUPPORTED_ELEMENTS.include?(element.class)
115
+ unsupported_elements["blocks[#{index}].elements[#{element_index}]"] = element
116
+ end
117
+ end
118
+ when Layout::Input
119
+ if block.element.present? && !self.class::SUPPORTED_ELEMENTS.include?(block.element.class)
120
+ unsupported_elements["blocks[#{index}].element"] = block.element
121
+ end
122
+ when Layout::Section
123
+ if block.accessory.present? && !self.class::SUPPORTED_ELEMENTS.include?(block.accessory.class)
124
+ unsupported_elements["blocks[#{index}].accessory"] = block.accessory
125
+ end
126
+ end
127
+ end
128
+
129
+ unsupported_elements
130
+ end
131
+
132
+ def focused_blocks_by_path
133
+ focused_blocks = {}
134
+
135
+ blocks.each_with_index do |block, i|
136
+ case block
137
+ when Layout::Actions
138
+ block.elements.each_with_index do |element, ei|
139
+ if element.respond_to?(:focus_on_load) && element.focus_on_load
140
+ focused_blocks["blocks[#{i}].elements[#{ei}]"] = element
141
+ end
142
+ end
143
+ when Layout::Input
144
+ focused_blocks["blocks[#{i}].element"] = block.element if block.element.respond_to?(:focus_on_load) && block.element.focus_on_load
145
+ when Layout::Section
146
+ focused_blocks["blocks[#{i}].accessory"] = block.accessory if block.accessory.respond_to?(:focus_on_load) && block.accessory.focus_on_load
147
+ end
148
+ end
149
+
150
+ focused_blocks
151
+ end
152
+
153
+ def remove_unsupported_elements
154
+ blocks.each do |block|
155
+ case block
156
+ when Layout::Actions
157
+ block.elements.delete_if { |element| !self.class::SUPPORTED_ELEMENTS.include?(element.class) }
158
+ when Layout::Input
159
+ block.element = nil unless self.class::SUPPORTED_ELEMENTS.include?(block.element.class)
160
+ when Layout::Section
161
+ block.accessory = nil unless self.class::SUPPORTED_ELEMENTS.include?(block.accessory.class)
162
+ end
163
+ end
164
+ end
165
+
166
+ def unset_focus_on_load_on_all_elements
167
+ focused_blocks_by_path.values.each do |element|
168
+ element.focus_on_load = false if element.respond_to?(:focus_on_load)
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module BlockKit
6
+ module Surfaces
7
+ class Home < Base
8
+ self.type = :home
9
+
10
+ SUPPORTED_ELEMENTS = [
11
+ Elements::Button,
12
+ Elements::ChannelsSelect,
13
+ Elements::Checkboxes,
14
+ Elements::ConversationsSelect,
15
+ Elements::DatePicker,
16
+ Elements::ExternalSelect,
17
+ Elements::Image,
18
+ Elements::MultiChannelsSelect,
19
+ Elements::MultiConversationsSelect,
20
+ Elements::MultiExternalSelect,
21
+ Elements::MultiStaticSelect,
22
+ Elements::MultiUsersSelect,
23
+ Elements::Overflow,
24
+ Elements::PlainTextInput,
25
+ Elements::RadioButtons,
26
+ Elements::RichTextInput,
27
+ Elements::StaticSelect,
28
+ Elements::TimePicker,
29
+ Elements::UsersSelect
30
+ ].freeze
31
+
32
+ def initialize(attributes = {})
33
+ attributes = attributes.with_indifferent_access
34
+ attributes[:blocks] ||= []
35
+
36
+ super
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module BlockKit
6
+ module Surfaces
7
+ class Message < BlockKit::Base
8
+ self.type = :message
9
+
10
+ MAX_BLOCKS = 50
11
+ SUPPORTED_ELEMENTS = [
12
+ Elements::Button,
13
+ Elements::ChannelsSelect,
14
+ Elements::Checkboxes,
15
+ Elements::ConversationsSelect,
16
+ Elements::DatePicker,
17
+ Elements::DatetimePicker,
18
+ Elements::ExternalSelect,
19
+ Elements::Image,
20
+ Elements::MultiChannelsSelect,
21
+ Elements::MultiConversationsSelect,
22
+ Elements::MultiExternalSelect,
23
+ Elements::MultiStaticSelect,
24
+ Elements::MultiUsersSelect,
25
+ Elements::Overflow,
26
+ Elements::PlainTextInput,
27
+ Elements::RadioButtons,
28
+ Elements::StaticSelect,
29
+ Elements::TimePicker,
30
+ Elements::UsersSelect,
31
+ Elements::WorkflowButton
32
+ ].freeze
33
+
34
+ attribute :text, :string
35
+ attribute :blocks, Types::Array.of(Types::Blocks.new(*Layout.all))
36
+ attribute :thread_ts, :string
37
+ attribute :mrkdwn, :boolean
38
+
39
+ validates :text, presence: true
40
+ validates :blocks, length: {maximum: MAX_BLOCKS, message: "is too long (maximum is %{count} blocks)"}, "block_kit/validators/associated": true
41
+ validate :no_unsupported_elements
42
+ fix :remove_unsupported_elements, dangerous: true
43
+ fixes :blocks, truncate: {maximum: MAX_BLOCKS, dangerous: true}, associated: true
44
+
45
+ dsl_method :blocks, as: :actions, type: Layout::Actions
46
+ dsl_method :blocks, as: :context, type: Layout::Context
47
+ dsl_method :blocks, as: :divider, type: Layout::Divider, yields: false
48
+ dsl_method :blocks, as: :file, type: Layout::File, required_fields: [:external_id], yields: false
49
+ dsl_method :blocks, as: :header, type: Layout::Header, required_fields: [:text], yields: false
50
+ dsl_method :blocks, as: :input, type: Layout::Input
51
+ dsl_method :blocks, as: :markdown, type: Layout::Markdown, required_fields: [:text], yields: false
52
+ dsl_method :blocks, as: :rich_text, type: Layout::RichText
53
+ dsl_method :blocks, as: :section, type: Layout::Section
54
+ dsl_method :blocks, as: :video, type: Layout::Video, required_fields: [:alt_text, :title, :thumbnail_url, :video_url], yields: false
55
+
56
+ def initialize(attributes = {})
57
+ attributes = attributes.with_indifferent_access
58
+ attributes[:blocks] ||= []
59
+
60
+ super
61
+ end
62
+
63
+ def image(alt_text:, image_url: nil, slack_file: nil, title: nil, emoji: nil, block_id: nil)
64
+ if (image_url.nil? && slack_file.nil?) || (image_url && slack_file)
65
+ raise ArgumentError, "Must provide either image_url or slack_file, but not both."
66
+ end
67
+
68
+ append(Layout::Image.new(image_url: image_url, slack_file: slack_file, alt_text: alt_text, title: title, block_id: block_id, emoji: emoji))
69
+ end
70
+
71
+ # Overridden to return `self`, allowing chaining.
72
+ def append(block)
73
+ blocks << block
74
+
75
+ self
76
+ end
77
+
78
+ def as_json(*)
79
+ super().except(:type).merge(
80
+ text: text,
81
+ blocks: blocks&.map(&:as_json),
82
+ thread_ts: thread_ts,
83
+ mrkdwn: mrkdwn
84
+ ).compact
85
+ end
86
+
87
+ private
88
+
89
+ def no_unsupported_elements
90
+ unsupported_elements = unsupported_elements_by_path
91
+ return if unsupported_elements.empty?
92
+
93
+ errors.add(:blocks, "contains unsupported elements")
94
+
95
+ unsupported_elements.each do |path, element|
96
+ errors.add(path, "is invalid: #{element.class.type} is not a supported element for this surface")
97
+ end
98
+ end
99
+
100
+ def unsupported_elements_by_path
101
+ unsupported_elements = {}
102
+
103
+ blocks.each_with_index do |block, index|
104
+ # Context block elements are globally supported, so we don't need to check them.
105
+ case block
106
+ when Layout::Actions
107
+ block.elements.each_with_index do |element, element_index|
108
+ unless SUPPORTED_ELEMENTS.include?(element.class)
109
+ unsupported_elements["blocks[#{index}].elements[#{element_index}]"] = element
110
+ end
111
+ end
112
+ when Layout::Input
113
+ if block.element.present? && !SUPPORTED_ELEMENTS.include?(block.element.class)
114
+ unsupported_elements["blocks[#{index}].element"] = block.element
115
+ end
116
+ when Layout::Section
117
+ if block.accessory.present? && !SUPPORTED_ELEMENTS.include?(block.accessory.class)
118
+ unsupported_elements["blocks[#{index}].accessory"] = block.accessory
119
+ end
120
+ end
121
+ end
122
+
123
+ unsupported_elements
124
+ end
125
+
126
+ def remove_unsupported_elements
127
+ blocks.each do |block|
128
+ case block
129
+ when Layout::Actions
130
+ block.elements.delete_if { |element| !SUPPORTED_ELEMENTS.include?(element.class) }
131
+ when Layout::Input
132
+ block.element = nil unless SUPPORTED_ELEMENTS.include?(block.element.class)
133
+ when Layout::Section
134
+ block.accessory = nil unless SUPPORTED_ELEMENTS.include?(block.accessory.class)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end