Skip to content

Commit b3e25bf

Browse files
srtaalejzimeg
andauthored
feat: add ContainerBlock layout block support (#1639)
Co-authored-by: Eden Zimbelman <eden.zimbelman@salesforce.com>
1 parent e257f8b commit b3e25bf

9 files changed

Lines changed: 466 additions & 0 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package com.slack.api.model.kotlin_extension.block
2+
3+
import com.slack.api.model.block.ContainerBlock
4+
import com.slack.api.model.block.LayoutBlock
5+
import com.slack.api.model.block.RichTextBlock
6+
import com.slack.api.model.block.composition.PlainTextObject
7+
import com.slack.api.model.block.composition.TextObject
8+
import com.slack.api.model.block.element.ImageElement
9+
import com.slack.api.model.kotlin_extension.block.container.MultiLayoutBlockContainer
10+
11+
@BlockLayoutBuilder
12+
class ContainerBlockBuilder : Builder<ContainerBlock> {
13+
private var blockId: String? = null
14+
private var title: PlainTextObject? = null
15+
private var richTextTitle: RichTextBlock? = null
16+
private var subtitle: TextObject? = null
17+
private var childBlocks: List<LayoutBlock>? = null
18+
private var width: String? = null
19+
private var icon: ImageElement? = null
20+
private var isCollapsible: Boolean? = null
21+
private var defaultCollapsed: Boolean? = null
22+
private var hasHeaderDivider: Boolean? = null
23+
24+
fun blockId(id: String) {
25+
blockId = id
26+
}
27+
28+
fun title(text: String, emoji: Boolean? = null) {
29+
title = PlainTextObject(text, emoji)
30+
}
31+
32+
fun title(textObject: PlainTextObject) {
33+
title = textObject
34+
}
35+
36+
fun richTextTitle(richText: RichTextBlock) {
37+
richTextTitle = richText
38+
}
39+
40+
fun subtitle(text: String, emoji: Boolean? = null) {
41+
subtitle = PlainTextObject(text, emoji)
42+
}
43+
44+
fun subtitle(textObject: TextObject) {
45+
subtitle = textObject
46+
}
47+
48+
fun childBlocks(builder: MultiLayoutBlockContainer.() -> Unit) {
49+
childBlocks = MultiLayoutBlockContainer().apply(builder).underlying
50+
}
51+
52+
fun width(width: String) {
53+
this.width = width
54+
}
55+
56+
fun icon(imageUrl: String, altText: String) {
57+
icon = ImageElement.builder().imageUrl(imageUrl).altText(altText).build()
58+
}
59+
60+
fun isCollapsible(collapsible: Boolean) {
61+
isCollapsible = collapsible
62+
}
63+
64+
fun defaultCollapsed(collapsed: Boolean) {
65+
defaultCollapsed = collapsed
66+
}
67+
68+
fun hasHeaderDivider(divider: Boolean) {
69+
hasHeaderDivider = divider
70+
}
71+
72+
override fun build(): ContainerBlock {
73+
return ContainerBlock.builder()
74+
.blockId(blockId)
75+
.title(title)
76+
.richTextTitle(richTextTitle)
77+
.subtitle(subtitle)
78+
.childBlocks(childBlocks ?: ArrayList())
79+
.width(width)
80+
.icon(icon)
81+
.isCollapsible(isCollapsible)
82+
.defaultCollapsed(defaultCollapsed)
83+
.hasHeaderDivider(hasHeaderDivider)
84+
.build()
85+
}
86+
}

‎slack-api-model-kotlin-extension/src/main/kotlin/com/slack/api/model/kotlin_extension/block/container/MultiLayoutBlockContainer.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ class MultiLayoutBlockContainer : LayoutBlockDsl {
2828
underlying += ActionsBlockBuilder().apply(builder).build()
2929
}
3030

31+
override fun container(builder: ContainerBlockBuilder.() -> Unit) {
32+
underlying += ContainerBlockBuilder().apply(builder).build()
33+
}
34+
3135
override fun context(builder: ContextBlockBuilder.() -> Unit) {
3236
underlying += ContextBlockBuilder().apply(builder).build()
3337
}

‎slack-api-model-kotlin-extension/src/main/kotlin/com/slack/api/model/kotlin_extension/block/dsl/LayoutBlockDsl.kt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ interface LayoutBlockDsl {
3636
*/
3737
fun actions(builder: ActionsBlockBuilder.() -> Unit)
3838

39+
/**
40+
* A container block groups related blocks together into a visually distinct section.
41+
*
42+
* @see <a href="https://docs.slack.dev/reference/block-kit/blocks/container-block">Container documentation</a>
43+
*/
44+
fun container(builder: ContainerBlockBuilder.() -> Unit)
45+
3946
/**
4047
* Displays message context, which can include both images and text.
4148
*
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package test_locally.block
2+
3+
import com.slack.api.model.block.ContainerBlock
4+
import com.slack.api.model.block.DividerBlock
5+
import com.slack.api.model.block.SectionBlock
6+
import com.slack.api.model.block.composition.MarkdownTextObject
7+
import com.slack.api.model.block.composition.PlainTextObject
8+
import com.slack.api.model.kotlin_extension.block.withBlocks
9+
import com.slack.api.util.json.GsonFactory
10+
import org.junit.Test
11+
import kotlin.test.assertEquals
12+
import kotlin.test.assertNotNull
13+
import kotlin.test.assertNull
14+
import kotlin.test.assertTrue
15+
16+
class ContainerBlockTest {
17+
18+
@Test
19+
fun constructBasicContainer() {
20+
val gson = GsonFactory.createSnakeCase()
21+
val blocks = withBlocks {
22+
container {
23+
blockId("container-1")
24+
title("Container Title")
25+
width("wide")
26+
isCollapsible(true)
27+
defaultCollapsed(false)
28+
hasHeaderDivider(true)
29+
icon("https://example.com/icon.png", "icon")
30+
childBlocks {
31+
divider()
32+
}
33+
}
34+
}
35+
assertEquals(1, blocks.size)
36+
val container = blocks[0] as ContainerBlock
37+
assertEquals("container", container.type)
38+
assertEquals("container-1", container.blockId)
39+
assertEquals("Container Title", container.title.text)
40+
assertEquals("wide", container.width)
41+
assertEquals(true, container.isCollapsible)
42+
assertEquals(false, container.defaultCollapsed)
43+
assertEquals(true, container.hasHeaderDivider)
44+
assertNotNull(container.icon)
45+
assertEquals(1, container.childBlocks.size)
46+
assertTrue(container.childBlocks[0] is DividerBlock)
47+
48+
val json = gson.toJson(blocks)
49+
assertTrue(json.contains("\"type\":\"container\""))
50+
assertTrue(json.contains("\"block_id\":\"container-1\""))
51+
}
52+
53+
@Test
54+
fun constructContainerWithPlainTextObject() {
55+
val titleObj = PlainTextObject.builder().text("Title with emoji").emoji(true).build()
56+
val blocks = withBlocks {
57+
container {
58+
title(titleObj)
59+
childBlocks {
60+
divider()
61+
}
62+
}
63+
}
64+
val container = blocks[0] as ContainerBlock
65+
assertEquals("Title with emoji", container.title.text)
66+
}
67+
68+
@Test
69+
fun constructContainerWithSubtitle() {
70+
val blocks = withBlocks {
71+
container {
72+
title("Title")
73+
subtitle("Plain subtitle")
74+
childBlocks {
75+
divider()
76+
}
77+
}
78+
}
79+
val container = blocks[0] as ContainerBlock
80+
assertNotNull(container.subtitle)
81+
assertEquals("Plain subtitle", container.subtitle.text)
82+
}
83+
84+
@Test
85+
fun constructContainerWithMarkdownSubtitle() {
86+
val blocks = withBlocks {
87+
container {
88+
title("Title")
89+
subtitle(MarkdownTextObject.builder().text("*Bold* subtitle").build())
90+
childBlocks {
91+
divider()
92+
}
93+
}
94+
}
95+
val container = blocks[0] as ContainerBlock
96+
assertNotNull(container.subtitle)
97+
assertEquals("mrkdwn", container.subtitle.type)
98+
}
99+
100+
@Test
101+
fun constructContainerWithMultipleChildBlocks() {
102+
val blocks = withBlocks {
103+
container {
104+
title("Title")
105+
childBlocks {
106+
section {
107+
markdownText("Section inside container")
108+
}
109+
divider()
110+
}
111+
}
112+
}
113+
val container = blocks[0] as ContainerBlock
114+
assertEquals(2, container.childBlocks.size)
115+
assertTrue(container.childBlocks[0] is SectionBlock)
116+
assertTrue(container.childBlocks[1] is DividerBlock)
117+
}
118+
119+
@Test
120+
fun constructContainerWithoutOptionalFields() {
121+
val blocks = withBlocks {
122+
container {
123+
title("Minimal")
124+
childBlocks {
125+
divider()
126+
}
127+
}
128+
}
129+
val container = blocks[0] as ContainerBlock
130+
assertNull(container.blockId)
131+
assertNull(container.width)
132+
assertNull(container.isCollapsible)
133+
assertNull(container.defaultCollapsed)
134+
assertNull(container.hasHeaderDivider)
135+
assertNull(container.icon)
136+
assertNull(container.richTextTitle)
137+
}
138+
139+
@Test
140+
fun roundTripSerialization() {
141+
val gson = GsonFactory.createSnakeCase()
142+
val blocks = withBlocks {
143+
container {
144+
blockId("rt-1")
145+
title("Round trip")
146+
subtitle("Sub")
147+
width("wide")
148+
isCollapsible(true)
149+
childBlocks {
150+
divider()
151+
}
152+
}
153+
}
154+
val json = gson.toJson(blocks)
155+
val reparsed = gson.fromJson(json, Array<ContainerBlock>::class.java)
156+
assertEquals("rt-1", reparsed[0].blockId)
157+
assertEquals("Round trip", reparsed[0].title.text)
158+
assertEquals("wide", reparsed[0].width)
159+
}
160+
}

‎slack-api-model/src/main/java/com/slack/api/model/block/Blocks.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ public static CarouselBlock carousel(ModelConfigurator<CarouselBlock.CarouselBlo
147147
return configurator.configure(CarouselBlock.builder()).build();
148148
}
149149

150+
// ContainerBlock
151+
152+
public static ContainerBlock container(ModelConfigurator<ContainerBlock.ContainerBlockBuilder> configurator) {
153+
return configurator.configure(ContainerBlock.builder()).build();
154+
}
155+
150156
// TaskCardBlock
151157

152158
public static TaskCardBlock taskCard(ModelConfigurator<TaskCardBlock.TaskCardBlockBuilder> configurator) {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.slack.api.model.block;
2+
3+
import com.slack.api.model.block.composition.PlainTextObject;
4+
import com.slack.api.model.block.composition.TextObject;
5+
import com.slack.api.model.block.element.ImageElement;
6+
import lombok.AllArgsConstructor;
7+
import lombok.Builder;
8+
import lombok.Data;
9+
import lombok.NoArgsConstructor;
10+
11+
import java.util.ArrayList;
12+
import java.util.List;
13+
14+
/**
15+
* https://docs.slack.dev/reference/block-kit/blocks/container-block
16+
*/
17+
@Data
18+
@Builder
19+
@NoArgsConstructor
20+
@AllArgsConstructor
21+
public class ContainerBlock implements LayoutBlock {
22+
public static final String TYPE = "container";
23+
private final String type = TYPE;
24+
25+
private PlainTextObject title;
26+
27+
private RichTextBlock richTextTitle;
28+
29+
private TextObject subtitle;
30+
31+
@Builder.Default
32+
private List<LayoutBlock> childBlocks = new ArrayList<>();
33+
34+
private String width;
35+
36+
private ImageElement icon;
37+
38+
private Boolean isCollapsible;
39+
40+
private Boolean defaultCollapsed;
41+
42+
private Boolean hasHeaderDivider;
43+
44+
private String blockId;
45+
}

‎slack-api-model/src/main/java/com/slack/api/util/json/GsonLayoutBlockFactory.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ private Class<? extends LayoutBlock> getLayoutClassInstance(String typeName) {
4343
return CardBlock.class;
4444
case CarouselBlock.TYPE:
4545
return CarouselBlock.class;
46+
case ContainerBlock.TYPE:
47+
return ContainerBlock.class;
4648
case ContextActionsBlock.TYPE:
4749
return ContextActionsBlock.class;
4850
case ContextBlock.TYPE:

0 commit comments

Comments
 (0)