From 9406c8b20a063569635b0c226daab346bafd844c Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Mon, 31 Aug 2026 15:23:32 -0400 Subject: [PATCH] feat: add ContainerBlock layout block support --- .../block/ContainerBlockBuilder.kt | 86 ++++++++++ .../container/MultiLayoutBlockContainer.kt | 4 + .../block/dsl/LayoutBlockDsl.kt | 7 + .../com/slack/api/model/block/Blocks.java | 6 + .../slack/api/model/block/ContainerBlock.java | 44 ++++++ .../api/util/json/GsonLayoutBlockFactory.java | 2 + .../api/model/block/BlockKitTest.java | 148 ++++++++++++++++++ .../api/model/block/BlocksTest.java | 8 + 8 files changed, 305 insertions(+) create mode 100644 slack-api-model-kotlin-extension/src/main/kotlin/com/slack/api/model/kotlin_extension/block/ContainerBlockBuilder.kt create mode 100644 slack-api-model/src/main/java/com/slack/api/model/block/ContainerBlock.java diff --git a/slack-api-model-kotlin-extension/src/main/kotlin/com/slack/api/model/kotlin_extension/block/ContainerBlockBuilder.kt b/slack-api-model-kotlin-extension/src/main/kotlin/com/slack/api/model/kotlin_extension/block/ContainerBlockBuilder.kt new file mode 100644 index 000000000..1d3081f38 --- /dev/null +++ b/slack-api-model-kotlin-extension/src/main/kotlin/com/slack/api/model/kotlin_extension/block/ContainerBlockBuilder.kt @@ -0,0 +1,86 @@ +package com.slack.api.model.kotlin_extension.block + +import com.slack.api.model.block.ContainerBlock +import com.slack.api.model.block.LayoutBlock +import com.slack.api.model.block.RichTextBlock +import com.slack.api.model.block.composition.PlainTextObject +import com.slack.api.model.block.composition.TextObject +import com.slack.api.model.block.element.ImageElement +import com.slack.api.model.kotlin_extension.block.container.MultiLayoutBlockContainer + +@BlockLayoutBuilder +class ContainerBlockBuilder : Builder { + private var blockId: String? = null + private var title: TextObject? = null + private var richTextTitle: RichTextBlock? = null + private var subtitle: TextObject? = null + private var childBlocks: List? = null + private var width: String? = null + private var icon: ImageElement? = null + private var isCollapsible: Boolean? = null + private var defaultCollapsed: Boolean? = null + private var hasHeaderDivider: Boolean? = null + + fun blockId(id: String) { + blockId = id + } + + fun title(text: String, emoji: Boolean? = null) { + title = PlainTextObject(text, emoji) + } + + fun title(textObject: TextObject) { + title = textObject + } + + fun richTextTitle(richText: RichTextBlock) { + richTextTitle = richText + } + + fun subtitle(text: String, emoji: Boolean? = null) { + subtitle = PlainTextObject(text, emoji) + } + + fun subtitle(textObject: TextObject) { + subtitle = textObject + } + + fun childBlocks(builder: MultiLayoutBlockContainer.() -> Unit) { + childBlocks = MultiLayoutBlockContainer().apply(builder).underlying + } + + fun width(width: String) { + this.width = width + } + + fun icon(imageUrl: String, altText: String) { + icon = ImageElement.builder().imageUrl(imageUrl).altText(altText).build() + } + + fun isCollapsible(collapsible: Boolean) { + isCollapsible = collapsible + } + + fun defaultCollapsed(collapsed: Boolean) { + defaultCollapsed = collapsed + } + + fun hasHeaderDivider(divider: Boolean) { + hasHeaderDivider = divider + } + + override fun build(): ContainerBlock { + return ContainerBlock.builder() + .blockId(blockId) + .title(title) + .richTextTitle(richTextTitle) + .subtitle(subtitle) + .childBlocks(childBlocks ?: ArrayList()) + .width(width) + .icon(icon) + .isCollapsible(isCollapsible) + .defaultCollapsed(defaultCollapsed) + .hasHeaderDivider(hasHeaderDivider) + .build() + } +} diff --git a/slack-api-model-kotlin-extension/src/main/kotlin/com/slack/api/model/kotlin_extension/block/container/MultiLayoutBlockContainer.kt b/slack-api-model-kotlin-extension/src/main/kotlin/com/slack/api/model/kotlin_extension/block/container/MultiLayoutBlockContainer.kt index 9f35b0c67..d228b4715 100644 --- a/slack-api-model-kotlin-extension/src/main/kotlin/com/slack/api/model/kotlin_extension/block/container/MultiLayoutBlockContainer.kt +++ b/slack-api-model-kotlin-extension/src/main/kotlin/com/slack/api/model/kotlin_extension/block/container/MultiLayoutBlockContainer.kt @@ -67,4 +67,8 @@ class MultiLayoutBlockContainer : LayoutBlockDsl { override fun shareShortcut(builder: ShareShortcutBlockBuilder.() -> Unit) { underlying += ShareShortcutBlockBuilder().apply(builder).build() } + + override fun container(builder: ContainerBlockBuilder.() -> Unit) { + underlying += ContainerBlockBuilder().apply(builder).build() + } } diff --git a/slack-api-model-kotlin-extension/src/main/kotlin/com/slack/api/model/kotlin_extension/block/dsl/LayoutBlockDsl.kt b/slack-api-model-kotlin-extension/src/main/kotlin/com/slack/api/model/kotlin_extension/block/dsl/LayoutBlockDsl.kt index b6087f1cd..7692afa31 100644 --- a/slack-api-model-kotlin-extension/src/main/kotlin/com/slack/api/model/kotlin_extension/block/dsl/LayoutBlockDsl.kt +++ b/slack-api-model-kotlin-extension/src/main/kotlin/com/slack/api/model/kotlin_extension/block/dsl/LayoutBlockDsl.kt @@ -95,4 +95,11 @@ interface LayoutBlockDsl { * @see Next generation platform */ fun shareShortcut(builder: ShareShortcutBlockBuilder.() -> Unit) + + /** + * A container block groups related blocks together into a visually distinct section. + * + * @see Container documentation + */ + fun container(builder: ContainerBlockBuilder.() -> Unit) } diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/Blocks.java b/slack-api-model/src/main/java/com/slack/api/model/block/Blocks.java index 9abe88458..94b4667c7 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/block/Blocks.java +++ b/slack-api-model/src/main/java/com/slack/api/model/block/Blocks.java @@ -147,4 +147,10 @@ public static CarouselBlock carousel(ModelConfigurator configurator) { + return configurator.configure(ContainerBlock.builder()).build(); + } + } diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/ContainerBlock.java b/slack-api-model/src/main/java/com/slack/api/model/block/ContainerBlock.java new file mode 100644 index 000000000..14cf70118 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/ContainerBlock.java @@ -0,0 +1,44 @@ +package com.slack.api.model.block; + +import com.slack.api.model.block.composition.TextObject; +import com.slack.api.model.block.element.ImageElement; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +/** + * https://docs.slack.dev/reference/block-kit/blocks/container-block + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ContainerBlock implements LayoutBlock { + public static final String TYPE = "container"; + private final String type = TYPE; + + private TextObject title; + + private RichTextBlock richTextTitle; + + private TextObject subtitle; + + @Builder.Default + private List childBlocks = new ArrayList<>(); + + private String width; + + private ImageElement icon; + + private Boolean isCollapsible; + + private Boolean defaultCollapsed; + + private Boolean hasHeaderDivider; + + private String blockId; +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/GsonLayoutBlockFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/GsonLayoutBlockFactory.java index d97b36d28..41285af9c 100644 --- a/slack-api-model/src/main/java/com/slack/api/util/json/GsonLayoutBlockFactory.java +++ b/slack-api-model/src/main/java/com/slack/api/util/json/GsonLayoutBlockFactory.java @@ -67,6 +67,8 @@ private Class getLayoutClassInstance(String typeName) { return CardBlock.class; case CarouselBlock.TYPE: return CarouselBlock.class; + case ContainerBlock.TYPE: + return ContainerBlock.class; default: if (failOnUnknownProperties) { throw new JsonParseException("Unsupported layout block type: " + typeName); diff --git a/slack-api-model/src/test/java/test_locally/api/model/block/BlockKitTest.java b/slack-api-model/src/test/java/test_locally/api/model/block/BlockKitTest.java index f398c781f..70e6a2e34 100644 --- a/slack-api-model/src/test/java/test_locally/api/model/block/BlockKitTest.java +++ b/slack-api-model/src/test/java/test_locally/api/model/block/BlockKitTest.java @@ -2169,6 +2169,154 @@ public void parseCarouselBlock() { assertThat(reparsed.getElements().get(0).getType(), is("card")); } + @Test + public void parseContainerBlock() { + // https://docs.slack.dev/reference/block-kit/blocks/container-block + String json = "{\"blocks\":[\n" + + " {\n" + + " \"type\": \"container\",\n" + + " \"block_id\": \"container1\",\n" + + " \"title\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Container Title\"\n" + + " },\n" + + " \"subtitle\": {\n" + + " \"type\": \"mrkdwn\",\n" + + " \"text\": \"*Bold* subtitle\"\n" + + " },\n" + + " \"width\": \"wide\",\n" + + " \"is_collapsible\": true,\n" + + " \"default_collapsed\": false,\n" + + " \"icon\": {\n" + + " \"type\": \"image\",\n" + + " \"image_url\": \"https://example.com/icon.png\",\n" + + " \"alt_text\": \"icon\"\n" + + " },\n" + + " \"child_blocks\": [\n" + + " {\n" + + " \"type\": \"section\",\n" + + " \"text\": {\n" + + " \"type\": \"mrkdwn\",\n" + + " \"text\": \"Section inside container.\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"type\": \"divider\"\n" + + " }\n" + + " ]\n" + + " }\n" + + "]}"; + Gson gson = GsonFactory.createSnakeCase(); + Message message = gson.fromJson(json, Message.class); + assertThat(message, is(notNullValue())); + assertThat(message.getBlocks().size(), is(1)); + + ContainerBlock container = (ContainerBlock) message.getBlocks().get(0); + assertThat(container.getType(), is("container")); + assertThat(container.getBlockId(), is("container1")); + assertThat(container.getTitle().getType(), is("plain_text")); + assertThat(container.getTitle().getText(), is("Container Title")); + assertThat(container.getSubtitle().getType(), is("mrkdwn")); + assertThat(container.getWidth(), is("wide")); + assertThat(container.getIsCollapsible(), is(true)); + assertThat(container.getDefaultCollapsed(), is(false)); + assertThat(container.getIcon().getImageUrl(), is("https://example.com/icon.png")); + assertThat(container.getChildBlocks().size(), is(2)); + assertThat(container.getChildBlocks().get(0), is(instanceOf(SectionBlock.class))); + assertThat(container.getChildBlocks().get(1), is(instanceOf(DividerBlock.class))); + + // Round-trip + Message roundTrip = gson.fromJson(gson.toJson(message), Message.class); + ContainerBlock reparsed = (ContainerBlock) roundTrip.getBlocks().get(0); + assertThat(reparsed.getBlockId(), is("container1")); + assertThat(reparsed.getChildBlocks().size(), is(2)); + } + + @Test + public void parseContainerBlockWithRichTextTitle() { + String json = "{\"blocks\":[\n" + + " {\n" + + " \"type\": \"container\",\n" + + " \"rich_text_title\": {\n" + + " \"type\": \"rich_text\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"rich_text_section\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"text\",\n" + + " \"text\": \"Rich Title\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"child_blocks\": [\n" + + " {\n" + + " \"type\": \"divider\"\n" + + " }\n" + + " ]\n" + + " }\n" + + "]}"; + Gson gson = GsonFactory.createSnakeCase(); + Message message = gson.fromJson(json, Message.class); + assertThat(message, is(notNullValue())); + ContainerBlock container = (ContainerBlock) message.getBlocks().get(0); + assertThat(container.getRichTextTitle(), is(notNullValue())); + assertThat(container.getRichTextTitle().getType(), is("rich_text")); + assertThat(container.getRichTextTitle().getElements().size(), is(1)); + assertThat(container.getTitle(), is(nullValue())); + } + + @Test + public void parseContainerBlockWithHeaderDivider() { + String json = "{\"blocks\":[\n" + + " {\n" + + " \"type\": \"container\",\n" + + " \"title\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"With divider\"\n" + + " },\n" + + " \"has_header_divider\": true,\n" + + " \"child_blocks\": [\n" + + " {\n" + + " \"type\": \"divider\"\n" + + " }\n" + + " ]\n" + + " }\n" + + "]}"; + Gson gson = GsonFactory.createSnakeCase(); + Message message = gson.fromJson(json, Message.class); + ContainerBlock container = (ContainerBlock) message.getBlocks().get(0); + assertThat(container.getHasHeaderDivider(), is(true)); + assertThat(container.getIsCollapsible(), is(nullValue())); + } + + @Test + public void buildContainerBlock() { + ContainerBlock container = container(c -> c + .blockId("container1") + .title(com.slack.api.model.block.composition.PlainTextObject.builder().text("Title").build()) + .width("wide") + .isCollapsible(true) + .childBlocks(Arrays.asList( + divider() + )) + ); + assertThat(container.getType(), is("container")); + assertThat(container.getBlockId(), is("container1")); + + Gson gson = GsonFactory.createSnakeCase(); + Message message = new Message(); + message.setBlocks(asBlocks(container)); + Message reparsed = gson.fromJson(gson.toJson(message), Message.class); + ContainerBlock parsedContainer = (ContainerBlock) reparsed.getBlocks().get(0); + assertThat(parsedContainer.getBlockId(), is("container1")); + assertThat(parsedContainer.getWidth(), is("wide")); + assertThat(parsedContainer.getIsCollapsible(), is(true)); + assertThat(parsedContainer.getChildBlocks().size(), is(1)); + } + @Test public void buildCarouselBlock() { CarouselBlock carousel = carousel(c -> c diff --git a/slack-api-model/src/test/java/test_locally/api/model/block/BlocksTest.java b/slack-api-model/src/test/java/test_locally/api/model/block/BlocksTest.java index 332ee593b..4efc14887 100644 --- a/slack-api-model/src/test/java/test_locally/api/model/block/BlocksTest.java +++ b/slack-api-model/src/test/java/test_locally/api/model/block/BlocksTest.java @@ -117,6 +117,14 @@ public void testCarousel() { ))), is(notNullValue())); } + @Test + public void testContainer() { + assertThat(container(c -> c.blockId("container-1") + .title(com.slack.api.model.block.composition.PlainTextObject.builder().text("Title").build()) + .childBlocks(Arrays.asList(divider())) + ), is(notNullValue())); + } + @Test public void testImage() { assertThat(Blocks.image(i -> i.blockId("block-id").imageUrl("https://www.example.com/")), is(notNullValue()));