From 0712684efc3e6c5607b95e158f9f8f1a9456c9a5 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 26 Jun 2026 16:46:14 -0700 Subject: [PATCH] feat(model): add card layout block Add support for the Block Kit `card` layout block to slack-api-model. A card displays a compact, structured summary of content and can be used on its own or grouped inside a carousel. At least one of hero_image, title, actions, or body must be provided. - Add CardBlock model (hero_image, icon, slack_icon, title, subtitle, body, subtext, actions, block_id) following existing LayoutBlock conventions. - Register card in GsonLayoutBlockFactory for deserialization. - Add a Blocks.card(...) DSL helper. - Add BlockKitTest cases covering JSON round-trip and builder usage. Ref: https://docs.slack.dev/reference/block-kit/blocks/card-block Co-Authored-By: Claude --- .../com/slack/api/model/block/Blocks.java | 6 ++ .../com/slack/api/model/block/CardBlock.java | 74 +++++++++++++++ .../api/util/json/GsonLayoutBlockFactory.java | 2 + .../api/model/block/BlockKitTest.java | 94 +++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 slack-api-model/src/main/java/com/slack/api/model/block/CardBlock.java 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 fd451d8d2..cd0e602c5 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 @@ -129,4 +129,10 @@ public static ShareShortcutBlock shareShortcut() { return ShareShortcutBlock.builder().build(); } + // CardBlock + + public static CardBlock card(ModelConfigurator configurator) { + return configurator.configure(CardBlock.builder()).build(); + } + } diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/CardBlock.java b/slack-api-model/src/main/java/com/slack/api/model/block/CardBlock.java new file mode 100644 index 000000000..d3fe95cd1 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/CardBlock.java @@ -0,0 +1,74 @@ +package com.slack.api.model.block; + +import com.slack.api.model.block.composition.TextObject; +import com.slack.api.model.block.element.BlockElement; +import com.slack.api.model.block.element.ImageElement; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * A card is a layout block used to display a compact, structured summary of content. It can be used on + * its own or grouped together inside a {@link CarouselBlock carousel}. At least one of {@code heroImage}, + * {@code title}, {@code actions}, or {@code body} must be provided. + * + * https://docs.slack.dev/reference/block-kit/blocks/card-block + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CardBlock implements LayoutBlock { + public static final String TYPE = "card"; + private final String type = TYPE; + + /** + * A top banner image for the card in the form of an image element. + * The image URL may be up to 3000 characters and the alt text up to 2000 characters. + */ + private ImageElement heroImage; + + /** + * A small icon displayed next to the title and subtitle in the form of an image element. + * The image URL may be up to 3000 characters and the alt text up to 2000 characters. + * Mutually exclusive with {@code slackIcon}. + */ + private ImageElement icon; + + /** + * The name of a built-in Slack icon displayed next to the title and subtitle. + * Mutually exclusive with {@code icon}. + */ + private String slackIcon; + + /** + * The title of the card. Maximum length for the text in this field is 150 characters. + */ + private TextObject title; + + /** + * The subtitle of the card. Maximum length for the text in this field is 150 characters. + */ + private TextObject subtitle; + + /** + * The body text of the card. Maximum length for the text in this field is 200 characters. + */ + private TextObject body; + + /** + * Secondary text displayed beneath the body of the card. Maximum length for the text in this field + * is 200 characters. + */ + private TextObject subtext; + + /** + * Interactive elements (such as buttons) displayed at the bottom of the card. Up to 3 buttons. + */ + private List actions; + + 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 9286e6b51..a4995ca74 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 @@ -61,6 +61,8 @@ private Class getLayoutClassInstance(String typeName) { return RichTextBlock.class; case ShareShortcutBlock.TYPE: return ShareShortcutBlock.class; + case CardBlock.TYPE: + return CardBlock.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 1fc12d58d..cfc44ff3d 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 @@ -1940,4 +1940,98 @@ public void parseRichTextElements() { RichTextSectionElement section = (RichTextSectionElement) ((RichTextBlock) message.getBlocks().get(0)).getElements().get(0); assertThat(section.getElements().size(), is(10)); } + + @Test + public void parseCardBlock() { + // https://docs.slack.dev/reference/block-kit/blocks/card-block + String json = "{\"blocks\":[\n" + + " {\n" + + " \"type\": \"card\",\n" + + " \"block_id\": \"card1\",\n" + + " \"icon\": {\n" + + " \"type\": \"image\",\n" + + " \"image_url\": \"https://picsum.photos/36/36\",\n" + + " \"alt_text\": \"Icon\"\n" + + " },\n" + + " \"hero_image\": {\n" + + " \"type\": \"image\",\n" + + " \"image_url\": \"https://picsum.photos/400/300\",\n" + + " \"alt_text\": \"Sample hero image\"\n" + + " },\n" + + " \"title\": {\n" + + " \"type\": \"mrkdwn\",\n" + + " \"text\": \"Lumon Industries\"\n" + + " },\n" + + " \"subtitle\": {\n" + + " \"type\": \"mrkdwn\",\n" + + " \"text\": \"Committed to work-life balance\"\n" + + " },\n" + + " \"body\": {\n" + + " \"type\": \"mrkdwn\",\n" + + " \"text\": \"Please enjoy each card equally.\"\n" + + " },\n" + + " \"actions\": [\n" + + " {\n" + + " \"type\": \"button\",\n" + + " \"text\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Action Button\"\n" + + " },\n" + + " \"action_id\": \"button_action\"\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)); + + CardBlock card = (CardBlock) message.getBlocks().get(0); + assertThat(card.getType(), is("card")); + assertThat(card.getBlockId(), is("card1")); + assertThat(card.getIcon().getImageUrl(), is("https://picsum.photos/36/36")); + assertThat(card.getIcon().getAltText(), is("Icon")); + assertThat(card.getHeroImage().getImageUrl(), is("https://picsum.photos/400/300")); + assertThat(card.getHeroImage().getAltText(), is("Sample hero image")); + assertThat(card.getTitle().getType(), is("mrkdwn")); + assertThat(((com.slack.api.model.block.composition.MarkdownTextObject) card.getTitle()).getText(), is("Lumon Industries")); + assertThat(((com.slack.api.model.block.composition.MarkdownTextObject) card.getSubtitle()).getText(), is("Committed to work-life balance")); + assertThat(((com.slack.api.model.block.composition.MarkdownTextObject) card.getBody()).getText(), is("Please enjoy each card equally.")); + assertThat(card.getActions().size(), is(1)); + ButtonElement button = (ButtonElement) card.getActions().get(0); + assertThat(button.getActionId(), is("button_action")); + assertThat(button.getText().getText(), is("Action Button")); + + // Round-trip back to JSON and ensure the card survives serialization. + Message roundTrip = gson.fromJson(gson.toJson(message), Message.class); + CardBlock reparsed = (CardBlock) roundTrip.getBlocks().get(0); + assertThat(reparsed.getBlockId(), is("card1")); + assertThat(reparsed.getActions().size(), is(1)); + assertThat(((ButtonElement) reparsed.getActions().get(0)).getActionId(), is("button_action")); + } + + @Test + public void buildCardBlock() { + CardBlock card = card(c -> c + .blockId("card2") + .title(com.slack.api.model.block.composition.MarkdownTextObject.builder().text("Title").build()) + .body(com.slack.api.model.block.composition.PlainTextObject.builder().text("Body").build()) + .actions(Arrays.asList( + ButtonElement.builder() + .actionId("a") + .text(plainText("Click")) + .build() + )) + ); + assertThat(card.getType(), is("card")); + assertThat(card.getBlockId(), is("card2")); + Gson gson = GsonFactory.createSnakeCase(); + Message message = new Message(); + message.setBlocks(asBlocks(card)); + Message reparsed = gson.fromJson(gson.toJson(message), Message.class); + CardBlock parsedCard = (CardBlock) reparsed.getBlocks().get(0); + assertThat(parsedCard.getBlockId(), is("card2")); + assertThat(parsedCard.getActions().size(), is(1)); + } }