diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/AlertBlock.java b/slack-api-model/src/main/java/com/slack/api/model/block/AlertBlock.java new file mode 100644 index 000000000..80003adb3 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/AlertBlock.java @@ -0,0 +1,41 @@ +package com.slack.api.model.block; + +import com.slack.api.model.block.composition.TextObject; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * https://docs.slack.dev/reference/block-kit/blocks/alert-block + *

+ * Displays an inline alert message. Alert blocks are currently only supported in modals. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AlertBlock implements LayoutBlock { + public static final String TYPE = "alert"; + private final String type = TYPE; + + /** + * The message content of the alert, as a plain_text or mrkdwn text object. + * Maximum length for the text in this field is 200 characters. + */ + private TextObject text; + + /** + * The severity level of the alert. One of default, info, warning, error, or success. + * Will be default if omitted. + */ + private String level; + + /** + * A string acting as a unique identifier for a block. If not specified, one will be generated. + * Maximum length for this field is 255 characters. + * block_id should be unique for each message and each iteration of a message. + * If a message is updated, use a new block_id. + */ + private String blockId; +} 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..9abe88458 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,22 @@ public static ShareShortcutBlock shareShortcut() { return ShareShortcutBlock.builder().build(); } + // AlertBlock + + public static AlertBlock alert(ModelConfigurator configurator) { + return configurator.configure(AlertBlock.builder()).build(); + } + + // CardBlock + + public static CardBlock card(ModelConfigurator configurator) { + return configurator.configure(CardBlock.builder()).build(); + } + + // CarouselBlock + + public static CarouselBlock carousel(ModelConfigurator configurator) { + return configurator.configure(CarouselBlock.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..b02c38811 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/CardBlock.java @@ -0,0 +1,75 @@ +package com.slack.api.model.block; + +import com.slack.api.model.block.composition.SlackIconObject; +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; + + /** + * A built-in Slack icon displayed next to the title and subtitle. + * Mutually exclusive with {@code icon}. + */ + private SlackIconObject 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/model/block/CarouselBlock.java b/slack-api-model/src/main/java/com/slack/api/model/block/CarouselBlock.java new file mode 100644 index 000000000..23c0462bf --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/CarouselBlock.java @@ -0,0 +1,30 @@ +package com.slack.api.model.block; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * A carousel is a layout block used to display a horizontally scrollable collection of + * {@link CardBlock cards}. It must contain between 1 and 10 cards. + * + * https://docs.slack.dev/reference/block-kit/blocks/carousel-block + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CarouselBlock implements LayoutBlock { + public static final String TYPE = "carousel"; + private final String type = TYPE; + + /** + * An array of {@link CardBlock card} blocks. Must contain between 1 and 10 cards. + */ + private List elements; + + private String blockId; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/composition/BlockCompositions.java b/slack-api-model/src/main/java/com/slack/api/model/block/composition/BlockCompositions.java index c8cb8c218..f6413c5f0 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/block/composition/BlockCompositions.java +++ b/slack-api-model/src/main/java/com/slack/api/model/block/composition/BlockCompositions.java @@ -87,4 +87,14 @@ public static FeedbackButtonObject feedbackButton(ModelConfigurator configurator) { return configurator.configure(SlackFileObject.builder()).build(); } + + // SlackIconObject + + public static SlackIconObject slackIcon(ModelConfigurator configurator) { + return configurator.configure(SlackIconObject.builder()).build(); + } + + public static SlackIconObject slackIcon(String name) { + return SlackIconObject.builder().name(name).build(); + } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/composition/SlackIconObject.java b/slack-api-model/src/main/java/com/slack/api/model/block/composition/SlackIconObject.java new file mode 100644 index 000000000..f7b15e21a --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/composition/SlackIconObject.java @@ -0,0 +1,25 @@ +package com.slack.api.model.block.composition; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Defines a built-in Slack icon that can be displayed next to the title and subtitle of a card block. + * + * @see document + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SlackIconObject { + public static final String TYPE = "icon"; + private final String type = TYPE; + + /** + * The name of the built-in Slack icon. + */ + private String name; +} 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..d97b36d28 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,12 @@ private Class getLayoutClassInstance(String typeName) { return RichTextBlock.class; case ShareShortcutBlock.TYPE: return ShareShortcutBlock.class; + case AlertBlock.TYPE: + return AlertBlock.class; + case CardBlock.TYPE: + return CardBlock.class; + case CarouselBlock.TYPE: + return CarouselBlock.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..f398c781f 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 @@ -1742,6 +1742,63 @@ public void parseVideoBlocks() { assertEquals("https://www.youtube.com/embed/RRxQQxiM7AA?feature=oembed&autoplay=1", block.getVideoUrl()); } + @Test + public void parseAlertBlocks() { + // https://docs.slack.dev/reference/block-kit/blocks/alert-block + String json = "{\n" + + " \"blocks\": [\n" + + " {\n" + + " \"type\": \"alert\",\n" + + " \"block_id\": \"alert-1\",\n" + + " \"text\": {\n" + + " \"type\": \"mrkdwn\",\n" + + " \"text\": \"Something went *wrong*.\"\n" + + " },\n" + + " \"level\": \"error\"\n" + + " },\n" + + " {\n" + + " \"type\": \"alert\",\n" + + " \"text\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"A plain text alert with no markup.\"\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + View view = GsonFactory.createSnakeCase().fromJson(json, View.class); + assertNotNull(view); + assertEquals(2, view.getBlocks().size()); + + AlertBlock errorAlert = (AlertBlock) view.getBlocks().get(0); + assertEquals("alert", errorAlert.getType()); + assertEquals("alert-1", errorAlert.getBlockId()); + assertEquals("error", errorAlert.getLevel()); + assertEquals("mrkdwn", errorAlert.getText().getType()); + assertEquals("Something went *wrong*.", errorAlert.getText().getText()); + + AlertBlock defaultAlert = (AlertBlock) view.getBlocks().get(1); + assertEquals("plain_text", defaultAlert.getText().getType()); + assertNull(defaultAlert.getLevel()); + assertNull(defaultAlert.getBlockId()); + } + + @Test + public void buildAlertBlock() { + AlertBlock block = alert(a -> a + .blockId("alert-1") + .level("info") + .text(plainText("Heads up!"))); + assertNotNull(block); + assertEquals("alert", block.getType()); + + Gson gson = GsonFactory.createSnakeCase(); + String output = gson.toJson(block); + AlertBlock parsed = gson.fromJson(output, AlertBlock.class); + assertEquals("alert-1", parsed.getBlockId()); + assertEquals("info", parsed.getLevel()); + assertEquals("Heads up!", parsed.getText().getText()); + } + @Test public void parseLinkTriggerMessages() { // https://tools.slack.dev/deno-slack-sdk/ @@ -1940,4 +1997,209 @@ 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)); + } + + @Test + public void parseCarouselBlock() { + // https://docs.slack.dev/reference/block-kit/blocks/carousel-block + String json = "{\"blocks\":[\n" + + " {\n" + + " \"type\": \"carousel\",\n" + + " \"block_id\": \"carousel1\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"card\",\n" + + " \"block_id\": \"card1\",\n" + + " \"title\": {\n" + + " \"type\": \"mrkdwn\",\n" + + " \"text\": \"First card\"\n" + + " },\n" + + " \"body\": {\n" + + " \"type\": \"mrkdwn\",\n" + + " \"text\": \"Body of the first card.\"\n" + + " },\n" + + " \"hero_image\": {\n" + + " \"type\": \"image\",\n" + + " \"image_url\": \"https://picsum.photos/400/300\",\n" + + " \"alt_text\": \"hero\"\n" + + " },\n" + + " \"actions\": [\n" + + " {\n" + + " \"type\": \"button\",\n" + + " \"text\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Open\"\n" + + " },\n" + + " \"action_id\": \"open\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " {\n" + + " \"type\": \"card\",\n" + + " \"title\": {\n" + + " \"type\": \"mrkdwn\",\n" + + " \"text\": \"Second card\"\n" + + " }\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)); + + CarouselBlock carousel = (CarouselBlock) message.getBlocks().get(0); + assertThat(carousel.getType(), is("carousel")); + assertThat(carousel.getBlockId(), is("carousel1")); + assertThat(carousel.getElements().size(), is(2)); + + CardBlock firstCard = carousel.getElements().get(0); + assertThat(firstCard.getType(), is("card")); + assertThat(firstCard.getBlockId(), is("card1")); + assertThat(((com.slack.api.model.block.composition.MarkdownTextObject) firstCard.getTitle()).getText(), is("First card")); + assertThat(((com.slack.api.model.block.composition.MarkdownTextObject) firstCard.getBody()).getText(), is("Body of the first card.")); + assertThat(firstCard.getHeroImage().getImageUrl(), is("https://picsum.photos/400/300")); + assertThat(firstCard.getActions().size(), is(1)); + ButtonElement button = (ButtonElement) firstCard.getActions().get(0); + assertThat(button.getActionId(), is("open")); + + CardBlock secondCard = carousel.getElements().get(1); + assertThat(((com.slack.api.model.block.composition.MarkdownTextObject) secondCard.getTitle()).getText(), is("Second card")); + assertThat(secondCard.getBlockId(), is(nullValue())); + + // Round-trip back to JSON and ensure the carousel survives serialization. + Message roundTrip = gson.fromJson(gson.toJson(message), Message.class); + CarouselBlock reparsed = (CarouselBlock) roundTrip.getBlocks().get(0); + assertThat(reparsed.getBlockId(), is("carousel1")); + assertThat(reparsed.getElements().size(), is(2)); + assertThat(reparsed.getElements().get(0).getType(), is("card")); + } + + @Test + public void buildCarouselBlock() { + CarouselBlock carousel = carousel(c -> c + .blockId("carousel1") + .elements(Arrays.asList( + card(card -> card + .blockId("card1") + .title(com.slack.api.model.block.composition.MarkdownTextObject.builder().text("Promo").build()) + .body(com.slack.api.model.block.composition.PlainTextObject.builder().text("Check this out").build()) + .actions(Arrays.asList( + ButtonElement.builder() + .actionId("open") + .text(plainText("Open")) + .build() + ))), + card(card -> card + .title(com.slack.api.model.block.composition.MarkdownTextObject.builder().text("Second").build())) + )) + ); + assertThat(carousel.getType(), is("carousel")); + assertThat(carousel.getElements().size(), is(2)); + + Gson gson = GsonFactory.createSnakeCase(); + Message message = new Message(); + message.setBlocks(asBlocks(carousel)); + Message reparsed = gson.fromJson(gson.toJson(message), Message.class); + CarouselBlock parsedCarousel = (CarouselBlock) reparsed.getBlocks().get(0); + assertThat(parsedCarousel.getBlockId(), is("carousel1")); + assertThat(parsedCarousel.getElements().size(), is(2)); + assertThat(parsedCarousel.getElements().get(0).getType(), is("card")); + assertThat(parsedCarousel.getElements().get(0).getBlockId(), is("card1")); + assertThat(parsedCarousel.getElements().get(0).getActions().size(), is(1)); + } } 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 12073ab6d..332ee593b 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 @@ -100,6 +100,23 @@ public void testContextActions() { ), is(notNullValue())); } + @Test + public void testAlert() { + assertThat(alert(a -> a.blockId("alert-1").level("warning").text(plainText("Watch out!"))), is(notNullValue())); + } + + @Test + public void testCard() { + assertThat(card(c -> c.blockId("card-1").title(com.slack.api.model.block.composition.MarkdownTextObject.builder().text("Title").build())), is(notNullValue())); + } + + @Test + public void testCarousel() { + assertThat(carousel(c -> c.blockId("carousel-1").elements(Arrays.asList( + card(card -> card.title(com.slack.api.model.block.composition.MarkdownTextObject.builder().text("Card 1").build())) + ))), is(notNullValue())); + } + @Test public void testImage() { assertThat(Blocks.image(i -> i.blockId("block-id").imageUrl("https://www.example.com/")), is(notNullValue()));