diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/Block.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/Block.java index e51880f9..783d42aa 100644 --- a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/Block.java +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/Block.java @@ -30,6 +30,7 @@ value = ContextActionsBlock.class, name = ContextActionsBlock.TYPE ), + @JsonSubTypes.Type(value = Card.class, name = Card.TYPE), @JsonSubTypes.Type(value = Table.class, name = Table.TYPE), @JsonSubTypes.Type(value = DataTable.class, name = DataTable.TYPE), } diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/BlockElementLengthLimits.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/BlockElementLengthLimits.java index 542a6599..c813da6a 100644 --- a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/BlockElementLengthLimits.java +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/BlockElementLengthLimits.java @@ -12,6 +12,10 @@ public enum BlockElementLengthLimits { MAX_OPTION_VALUE_LENGTH(75), MAX_CHECKBOXES_NUMBER(10), MAX_RADIO_BUTTONS_NUMBER(10), + MAX_CARD_TITLE_LENGTH(150), + MAX_CARD_BODY_LENGTH(200), + MAX_CARD_ACTIONS_COUNT(3), + MAX_CARD_BLOCK_ID_LENGTH(255), MAX_TABLE_ROWS(100), MAX_TABLE_COLUMNS(20), MAX_TABLE_BLOCK_ID_LENGTH(255), diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/CardIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/CardIF.java new file mode 100644 index 00000000..f166d0dd --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/CardIF.java @@ -0,0 +1,111 @@ +package com.hubspot.slack.client.models.blocks; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.hubspot.immutables.style.HubSpotStyle; +import com.hubspot.slack.client.models.blocks.elements.BlockElement; +import com.hubspot.slack.client.models.blocks.elements.Image; +import com.hubspot.slack.client.models.blocks.objects.SlackIconObject; +import com.hubspot.slack.client.models.blocks.objects.Text; +import java.util.Optional; +import org.immutables.value.Value; +import org.immutables.value.Value.Check; +import org.immutables.value.Value.Immutable; + +@Immutable +@HubSpotStyle +@JsonNaming(SnakeCaseStrategy.class) +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public interface CardIF extends Block { + String TYPE = "card"; + + @Override + @Value.Derived + default String getType() { + return TYPE; + } + + Optional getHeroImage(); + + Optional getIcon(); + + Optional getTitle(); + + Optional getSubtitle(); + + Optional getBody(); + + Optional getSubtext(); + + ImmutableList getActions(); + + Optional getSlackIcon(); + + @Check + default void check() { + Preconditions.checkState( + getHeroImage().isPresent() || + getTitle().isPresent() || + !getActions().isEmpty() || + getBody().isPresent(), + "A card block must have at least one of: hero_image, title, actions, or body" + ); + Preconditions.checkState( + !(getIcon().isPresent() && getSlackIcon().isPresent()), + "A card block cannot have both icon and slack_icon" + ); + getTitle() + .ifPresent(title -> + Preconditions.checkState( + title.getText().length() <= + BlockElementLengthLimits.MAX_CARD_TITLE_LENGTH.getLimit(), + "title cannot exceed %s characters", + BlockElementLengthLimits.MAX_CARD_TITLE_LENGTH.getLimit() + ) + ); + getSubtitle() + .ifPresent(subtitle -> + Preconditions.checkState( + subtitle.getText().length() <= + BlockElementLengthLimits.MAX_CARD_TITLE_LENGTH.getLimit(), + "subtitle cannot exceed %s characters", + BlockElementLengthLimits.MAX_CARD_TITLE_LENGTH.getLimit() + ) + ); + getBody() + .ifPresent(body -> + Preconditions.checkState( + body.getText().length() <= + BlockElementLengthLimits.MAX_CARD_BODY_LENGTH.getLimit(), + "body cannot exceed %s characters", + BlockElementLengthLimits.MAX_CARD_BODY_LENGTH.getLimit() + ) + ); + getSubtext() + .ifPresent(subtext -> + Preconditions.checkState( + subtext.getText().length() <= + BlockElementLengthLimits.MAX_CARD_BODY_LENGTH.getLimit(), + "subtext cannot exceed %s characters", + BlockElementLengthLimits.MAX_CARD_BODY_LENGTH.getLimit() + ) + ); + Preconditions.checkState( + getActions().size() <= BlockElementLengthLimits.MAX_CARD_ACTIONS_COUNT.getLimit(), + "A card block cannot have more than %s actions", + BlockElementLengthLimits.MAX_CARD_ACTIONS_COUNT.getLimit() + ); + getBlockId() + .ifPresent(blockId -> + Preconditions.checkState( + blockId.length() <= + BlockElementLengthLimits.MAX_CARD_BLOCK_ID_LENGTH.getLimit(), + "block_id cannot exceed %s characters", + BlockElementLengthLimits.MAX_CARD_BLOCK_ID_LENGTH.getLimit() + ) + ); + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/SlackIconName.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/SlackIconName.java new file mode 100644 index 00000000..d7dc27cb --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/SlackIconName.java @@ -0,0 +1,74 @@ +package com.hubspot.slack.client.models.blocks.objects; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Icon names derived from @slack docs + */ +public enum SlackIconName { + ARCHIVE("archive"), + BOOK("book"), + BOOKMARK("bookmark"), + BOT("bot"), + BUG("bug"), + CALENDAR("calendar"), + CALL("call"), + CARET_LEFT("caret-left"), + CARET_RIGHT("caret-right"), + CHECK("check"), + CLIPBOARD("clipboard"), + CODE("code"), + COMMENT("comment"), + COMPASS("compass"), + COPY("copy"), + CUBE("cube"), + DOWNLOAD("download"), + EDIT("edit"), + EMAIL("email"), + EYE_CLOSED("eye-closed"), + EYE_OPEN("eye-open"), + FILE("file"), + FLAG("flag"), + FOLDER("folder"), + GEAR("gear"), + GLOBE("globe"), + HEART("heart"), + HELP("help"), + IMAGE("image"), + INFO("info"), + KEY("key"), + LIGHTBULB("lightbulb"), + LINK("link"), + MAP("map"), + MOBILE("mobile"), + NEW_WINDOW("new-window"), + PIN("pin"), + PLUS("plus"), + REFINE("refine"), + REFRESH("refresh"), + ROCKET("rocket"), + SAVE("save"), + SCREEN("screen"), + SHARE("share"), + SPARKLE("sparkle"), + STAR("star"), + STAR_FILLED("star-filled"), + TAG("tag"), + THUMBS_DOWN("thumbs-down"), + THUMBS_UP("thumbs-up"), + TRASH("trash"), + UPLOAD("upload"), + USER("user"), + WARNING("warning"); + + private final String value; + + SlackIconName(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/SlackIconObjectIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/SlackIconObjectIF.java new file mode 100644 index 00000000..2601f3ba --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/SlackIconObjectIF.java @@ -0,0 +1,22 @@ +package com.hubspot.slack.client.models.blocks.objects; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.hubspot.immutables.style.HubSpotStyle; +import org.immutables.value.Value; +import org.immutables.value.Value.Immutable; + +@Immutable +@HubSpotStyle +@JsonNaming(SnakeCaseStrategy.class) +public interface SlackIconObjectIF extends CompositionObject { + String TYPE = "icon"; + + @Value.Derived + default String getType() { + return TYPE; + } + + @Value.Parameter + SlackIconName getName(); +} diff --git a/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/CardBlockTest.java b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/CardBlockTest.java new file mode 100644 index 00000000..888faa91 --- /dev/null +++ b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/CardBlockTest.java @@ -0,0 +1,195 @@ +package com.hubspot.slack.client.models.blocks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableList; +import com.hubspot.slack.client.jackson.ObjectMapperUtils; +import com.hubspot.slack.client.models.JsonLoader; +import com.hubspot.slack.client.models.blocks.elements.Button; +import com.hubspot.slack.client.models.blocks.elements.Image; +import com.hubspot.slack.client.models.blocks.objects.SlackIconName; +import com.hubspot.slack.client.models.blocks.objects.SlackIconObject; +import com.hubspot.slack.client.models.blocks.objects.Text; +import com.hubspot.slack.client.models.blocks.objects.TextType; +import java.io.IOException; +import org.junit.BeforeClass; +import org.junit.Test; + +public class CardBlockTest { + + private static final ObjectMapper MAPPER = ObjectMapperUtils.mapper(); + private static final int SLACK_ICON_WITH_ACTIONS_INDEX = 0; + private static final int HERO_IMAGE_INDEX = 1; + private static final int ICON_WITH_ACTIONS_INDEX = 2; + private static final int TITLE_ONLY_INDEX = 3; + private static final int ACTIONS_ONLY_INDEX = 4; + private static Card[] blocks; + + @BeforeClass + public static void loadBlocks() throws IOException { + String rawJson = JsonLoader.loadJsonFromFile("card_block.json"); + JsonNode root = MAPPER.readTree(rawJson); + blocks = new Card[root.size()]; + for (int i = 0; i < root.size(); i++) { + blocks[i] = MAPPER.treeToValue(root.get(i), Card.class); + } + } + + @Test + public void itDeserializesAsCorrectBlockType() throws IOException { + String rawJson = JsonLoader.loadJsonFromFile("card_block.json"); + JsonNode root = MAPPER.readTree(rawJson); + Block block = MAPPER.treeToValue(root.get(0), Block.class); + assertThat(block).isInstanceOf(Card.class); + } + + @Test + public void itDeserializesSlackIcon() { + Card block = blocks[SLACK_ICON_WITH_ACTIONS_INDEX]; + assertThat(block.getSlackIcon()).isPresent(); + SlackIconObject icon = block.getSlackIcon().get(); + assertThat(icon.getName()).isEqualTo(SlackIconName.CALENDAR); + assertThat(icon.getName().getValue()).isEqualTo("calendar"); + assertThat(icon.getType()).isEqualTo("icon"); + } + + @Test + public void itSerializesIconNameToHyphenatedWireValue() throws IOException { + SlackIconObject icon = SlackIconObject.of(SlackIconName.STAR_FILLED); + JsonNode node = MAPPER.readTree(MAPPER.writeValueAsString(icon)); + assertThat(node.get("name").asText()).isEqualTo("star-filled"); + assertThat(MAPPER.readValue(MAPPER.writeValueAsString(icon), SlackIconObject.class)) + .isEqualTo(icon); + } + + @Test + public void itDeserializesTextFields() { + Card block = blocks[SLACK_ICON_WITH_ACTIONS_INDEX]; + assertThat(block.getTitle()).isPresent(); + assertThat(block.getTitle().get().getText()).isEqualTo("Meeting title"); + assertThat(block.getTitle().get().getType()).isEqualTo(TextType.MARKDOWN); + assertThat(block.getSubtitle()).isPresent(); + assertThat(block.getSubtitle().get().getText()).isEqualTo("This is a subtitle"); + assertThat(block.getBody()).isPresent(); + assertThat(block.getBody().get().getText()).isEqualTo("This is the body text."); + } + + @Test + public void itDeserializesActions() { + Card block = blocks[SLACK_ICON_WITH_ACTIONS_INDEX]; + assertThat(block.getActions()).hasSize(3); + assertThat(block.getActions().get(0)).isInstanceOf(Button.class); + assertThat(block.getActions().get(1)).isInstanceOf(Button.class); + assertThat(block.getActions().get(2)).isInstanceOf(Button.class); + } + + @Test + public void itDeserializesHeroImage() { + Card block = blocks[HERO_IMAGE_INDEX]; + assertThat(block.getHeroImage()).isPresent(); + Image heroImage = block.getHeroImage().get(); + assertThat(heroImage.getImageUrl()).isEqualTo("https://example.com/hero.png"); + assertThat(heroImage.getAltText()).isEqualTo("Sample hero image"); + } + + @Test + public void itDeserializesIconImage() { + Card block = blocks[ICON_WITH_ACTIONS_INDEX]; + assertThat(block.getIcon()).isPresent(); + Image icon = block.getIcon().get(); + assertThat(icon.getImageUrl()).isEqualTo("https://example.com/icon.png"); + assertThat(icon.getAltText()).isEqualTo("Icon"); + } + + @Test + public void itDeserializesTitleOnlyCard() { + Card block = blocks[TITLE_ONLY_INDEX]; + assertThat(block.getTitle()).isPresent(); + assertThat(block.getHeroImage()).isEmpty(); + assertThat(block.getBody()).isEmpty(); + assertThat(block.getActions()).isEmpty(); + } + + @Test + public void itDeserializesActionsOnlyCard() { + Card block = blocks[ACTIONS_ONLY_INDEX]; + assertThat(block.getActions()).isNotEmpty(); + assertThat(block.getTitle()).isEmpty(); + assertThat(block.getHeroImage()).isEmpty(); + assertThat(block.getBody()).isEmpty(); + } + + @Test + public void itSerializesAndDeserializes() throws IOException { + Card original = Card + .builder() + .setTitle(Text.of(TextType.MARKDOWN, "Test Title")) + .setBody(Text.of(TextType.MARKDOWN, "Test body")) + .build(); + String serialized = MAPPER.writeValueAsString(original); + Card deserialized = MAPPER.readValue(serialized, Card.class); + assertThat(deserialized).isEqualTo(original); + } + + @Test + public void itFailsWhenNoRequiredFieldsPresent() { + assertThatThrownBy(() -> Card.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("at least one of"); + } + + @Test + public void itFailsWhenIconAndSlackIconBothPresent() { + assertThatThrownBy(() -> + Card + .builder() + .setTitle(Text.of(TextType.MARKDOWN, "Title")) + .setIcon(Image.of("https://example.com/icon.png", "icon")) + .setSlackIcon(SlackIconObject.of(SlackIconName.ROCKET)) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cannot have both icon and slack_icon"); + } + + @Test + public void itFailsWhenTitleExceedsMaxLength() { + String longTitle = "a".repeat(151); + assertThatThrownBy(() -> + Card.builder().setTitle(Text.of(TextType.MARKDOWN, longTitle)).build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("title cannot exceed"); + } + + @Test + public void itFailsWhenBodyExceedsMaxLength() { + String longBody = "a".repeat(201); + assertThatThrownBy(() -> + Card.builder().setBody(Text.of(TextType.MARKDOWN, longBody)).build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("body cannot exceed"); + } + + @Test + public void itFailsWhenActionsExceedMaxCount() { + Button button = Button + .builder() + .setText(Text.of(TextType.PLAIN_TEXT, "Click")) + .setActionId("btn") + .build(); + assertThatThrownBy(() -> + Card + .builder() + .setTitle(Text.of(TextType.MARKDOWN, "Title")) + .setActions(ImmutableList.of(button, button, button, button)) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cannot have more than"); + } +} diff --git a/slack-base/src/test/resources/card_block.json b/slack-base/src/test/resources/card_block.json new file mode 100644 index 00000000..0b59bd3c --- /dev/null +++ b/slack-base/src/test/resources/card_block.json @@ -0,0 +1,124 @@ +[ + { + "type": "card", + "slack_icon": { + "type": "icon", + "name": "calendar" + }, + "title": { + "type": "mrkdwn", + "text": "Meeting title", + "verbatim": false + }, + "subtitle": { + "type": "mrkdwn", + "text": "This is a subtitle", + "verbatim": false + }, + "body": { + "type": "mrkdwn", + "text": "This is the body text.", + "verbatim": false + }, + "actions": [ + { + "type": "button", + "style": "danger", + "text": { + "type": "plain_text", + "text": "Delete", + "emoji": false + }, + "action_id": "button_danger" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Cancel", + "emoji": false + }, + "action_id": "button_default" + }, + { + "type": "button", + "style": "primary", + "text": { + "type": "plain_text", + "text": "Confirm", + "emoji": false + }, + "action_id": "button_primary" + } + ] + }, + { + "type": "card", + "title": { + "type": "mrkdwn", + "text": "Card with hero image", + "verbatim": false + }, + "hero_image": { + "type": "image", + "image_url": "https://example.com/hero.png", + "alt_text": "Sample hero image" + }, + "body": { + "type": "mrkdwn", + "text": "Body text.", + "verbatim": false + } + }, + { + "type": "card", + "icon": { + "type": "image", + "image_url": "https://example.com/icon.png", + "alt_text": "Icon" + }, + "title": { + "type": "mrkdwn", + "text": "Card with icon and actions", + "verbatim": false + }, + "body": { + "type": "mrkdwn", + "text": "Body text.", + "verbatim": false + }, + "actions": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Action Button", + "emoji": false + }, + "action_id": "button_action" + } + ] + }, + { + "type": "card", + "title": { + "type": "mrkdwn", + "text": "Title only card", + "verbatim": false + } + }, + { + "type": "card", + "actions": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Action Button", + "emoji": false + }, + "action_id": "button_action" + } + ] + } +]