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..5a0e46010 --- /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: PlainTextObject? = 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: PlainTextObject) { + 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-kotlin-extension/src/test/kotlin/test_locally/block/ContainerBlockTest.kt b/slack-api-model-kotlin-extension/src/test/kotlin/test_locally/block/ContainerBlockTest.kt new file mode 100644 index 000000000..686337560 --- /dev/null +++ b/slack-api-model-kotlin-extension/src/test/kotlin/test_locally/block/ContainerBlockTest.kt @@ -0,0 +1,160 @@ +package test_locally.block + +import com.slack.api.model.block.ContainerBlock +import com.slack.api.model.block.DividerBlock +import com.slack.api.model.block.SectionBlock +import com.slack.api.model.block.composition.MarkdownTextObject +import com.slack.api.model.block.composition.PlainTextObject +import com.slack.api.model.kotlin_extension.block.withBlocks +import com.slack.api.util.json.GsonFactory +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ContainerBlockTest { + + @Test + fun constructBasicContainer() { + val gson = GsonFactory.createSnakeCase() + val blocks = withBlocks { + container { + blockId("container-1") + title("Container Title") + width("wide") + isCollapsible(true) + defaultCollapsed(false) + hasHeaderDivider(true) + icon("https://example.com/icon.png", "icon") + childBlocks { + divider() + } + } + } + assertEquals(1, blocks.size) + val container = blocks[0] as ContainerBlock + assertEquals("container", container.type) + assertEquals("container-1", container.blockId) + assertEquals("Container Title", container.title.text) + assertEquals("wide", container.width) + assertEquals(true, container.isCollapsible) + assertEquals(false, container.defaultCollapsed) + assertEquals(true, container.hasHeaderDivider) + assertNotNull(container.icon) + assertEquals(1, container.childBlocks.size) + assertTrue(container.childBlocks[0] is DividerBlock) + + val json = gson.toJson(blocks) + assertTrue(json.contains("\"type\":\"container\"")) + assertTrue(json.contains("\"block_id\":\"container-1\"")) + } + + @Test + fun constructContainerWithPlainTextObject() { + val titleObj = PlainTextObject.builder().text("Title with emoji").emoji(true).build() + val blocks = withBlocks { + container { + title(titleObj) + childBlocks { + divider() + } + } + } + val container = blocks[0] as ContainerBlock + assertEquals("Title with emoji", container.title.text) + } + + @Test + fun constructContainerWithSubtitle() { + val blocks = withBlocks { + container { + title("Title") + subtitle("Plain subtitle") + childBlocks { + divider() + } + } + } + val container = blocks[0] as ContainerBlock + assertNotNull(container.subtitle) + assertEquals("Plain subtitle", container.subtitle.text) + } + + @Test + fun constructContainerWithMarkdownSubtitle() { + val blocks = withBlocks { + container { + title("Title") + subtitle(MarkdownTextObject.builder().text("*Bold* subtitle").build()) + childBlocks { + divider() + } + } + } + val container = blocks[0] as ContainerBlock + assertNotNull(container.subtitle) + assertEquals("mrkdwn", container.subtitle.type) + } + + @Test + fun constructContainerWithMultipleChildBlocks() { + val blocks = withBlocks { + container { + title("Title") + childBlocks { + section { + markdownText("Section inside container") + } + divider() + } + } + } + val container = blocks[0] as ContainerBlock + assertEquals(2, container.childBlocks.size) + assertTrue(container.childBlocks[0] is SectionBlock) + assertTrue(container.childBlocks[1] is DividerBlock) + } + + @Test + fun constructContainerWithoutOptionalFields() { + val blocks = withBlocks { + container { + title("Minimal") + childBlocks { + divider() + } + } + } + val container = blocks[0] as ContainerBlock + assertNull(container.blockId) + assertNull(container.width) + assertNull(container.isCollapsible) + assertNull(container.defaultCollapsed) + assertNull(container.hasHeaderDivider) + assertNull(container.icon) + assertNull(container.richTextTitle) + } + + @Test + fun roundTripSerialization() { + val gson = GsonFactory.createSnakeCase() + val blocks = withBlocks { + container { + blockId("rt-1") + title("Round trip") + subtitle("Sub") + width("wide") + isCollapsible(true) + childBlocks { + divider() + } + } + } + val json = gson.toJson(blocks) + val reparsed = gson.fromJson(json, Array::class.java) + assertEquals("rt-1", reparsed[0].blockId) + assertEquals("Round trip", reparsed[0].title.text) + assertEquals("wide", reparsed[0].width) + } +} 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 01352cafa..fe1eb8e13 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,6 +147,12 @@ public static CarouselBlock carousel(ModelConfigurator configurator) { + return configurator.configure(ContainerBlock.builder()).build(); + } + // TaskCardBlock public static TaskCardBlock taskCard(ModelConfigurator configurator) { 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..59543a8f5 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/ContainerBlock.java @@ -0,0 +1,45 @@ +package com.slack.api.model.block; + +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 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 PlainTextObject 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 93e2ce4ca..4c424cade 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 @@ -43,6 +43,8 @@ private Class getLayoutClassInstance(String typeName) { return CardBlock.class; case CarouselBlock.TYPE: return CarouselBlock.class; + case ContainerBlock.TYPE: + return ContainerBlock.class; case ContextActionsBlock.TYPE: return ContextActionsBlock.class; case ContextBlock.TYPE: 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 963407c1b..302b73bc5 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 790d07ef3..de53c96ef 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()));