diff --git a/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java b/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java index a823465cc..46405a301 100644 --- a/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java +++ b/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java @@ -11,6 +11,7 @@ import com.slack.api.model.block.ContextBlockElement; import com.slack.api.model.block.ContextActionsBlockElement; import com.slack.api.model.block.LayoutBlock; +import com.slack.api.model.block.TableCell; import com.slack.api.model.block.composition.TextObject; import com.slack.api.model.block.element.BlockElement; import com.slack.api.model.block.element.RichTextElement; @@ -80,6 +81,7 @@ public static void registerTypeAdapters(GsonBuilder builder, boolean failOnUnkno .registerTypeAdapter(ContextActionsBlockElement.class, new GsonContextActionsBlockElementFactory(failOnUnknownProps)) .registerTypeAdapter(BlockElement.class, new GsonBlockElementFactory(failOnUnknownProps)) .registerTypeAdapter(RichTextElement.class, new GsonRichTextElementFactory(failOnUnknownProps)) + .registerTypeAdapter(TableCell.class, new GsonTableCellFactory(failOnUnknownProps)) .registerTypeAdapter(FunctionExecutedEvent.InputValue.class, new GsonFunctionExecutedEventInputValueFactory()) .registerTypeAdapter(Attachment.VideoHtml.class, new GsonMessageAttachmentVideoHtmlFactory(failOnUnknownProps)) .registerTypeAdapter(MessageChangedEvent.PreviousMessage.class, new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProps)) 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..0a593ca96 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 @@ -28,6 +28,30 @@ public static ActionsBlock actions(List elements) { public static ActionsBlock actions(String blockId, List elements) { return ActionsBlock.builder().blockId(blockId).elements(elements).build(); } + + // AlertBlock + + public static AlertBlock alert(ModelConfigurator configurator) { + return configurator.configure(AlertBlock.builder()).build(); + } + + // CallBlock + + public static CallBlock call(ModelConfigurator configurator) { + return configurator.configure(CallBlock.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(); + } // ContextBlock @@ -70,6 +94,12 @@ public static DividerBlock divider(String blockId) { public static DividerBlock divider() { return DividerBlock.builder().build(); } + + // FileBlock + + public static FileBlock file(ModelConfigurator configurator) { + return configurator.configure(FileBlock.builder()).build(); + } // HeaderBlock @@ -77,18 +107,6 @@ public static HeaderBlock header(ModelConfigurator configurator) { - return configurator.configure(CallBlock.builder()).build(); - } - - // FileBlock - - public static FileBlock file(ModelConfigurator configurator) { - return configurator.configure(FileBlock.builder()).build(); - } - // ImageBlock public static ImageBlock image(ModelConfigurator configurator) { @@ -119,32 +137,22 @@ public static SectionBlock section(ModelConfigurator configurator) { - return configurator.configure(VideoBlock.builder()).build(); - } - // ShareShortcutBlock + public static ShareShortcutBlock shareShortcut() { return ShareShortcutBlock.builder().build(); } - // AlertBlock + // TableBlock - 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(); + public static TableBlock table(ModelConfigurator configurator) { + return configurator.configure(TableBlock.builder()).build(); } - // CarouselBlock + // VideoBlock - public static CarouselBlock carousel(ModelConfigurator configurator) { - return configurator.configure(CarouselBlock.builder()).build(); + public static VideoBlock video(ModelConfigurator configurator) { + return configurator.configure(VideoBlock.builder()).build(); } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/RawNumberTableCell.java b/slack-api-model/src/main/java/com/slack/api/model/block/RawNumberTableCell.java new file mode 100644 index 000000000..0264b068f --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/RawNumberTableCell.java @@ -0,0 +1,23 @@ +package com.slack.api.model.block; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A {@code raw_number} table cell, holding a numeric value. The optional {@code text} + * field carries the display representation of the value (for example a formatted string). + * + * @see Table block + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RawNumberTableCell implements TableCell { + public static final String TYPE = "raw_number"; + private final String type = TYPE; + private Double value; + private String text; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/RawTextTableCell.java b/slack-api-model/src/main/java/com/slack/api/model/block/RawTextTableCell.java new file mode 100644 index 000000000..4bd36e77c --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/RawTextTableCell.java @@ -0,0 +1,21 @@ +package com.slack.api.model.block; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A {@code raw_text} table cell, holding unformatted plain text. + * + * @see Table block + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RawTextTableCell implements TableCell { + public static final String TYPE = "raw_text"; + private final String type = TYPE; + private String text; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/RichTextBlock.java b/slack-api-model/src/main/java/com/slack/api/model/block/RichTextBlock.java index ad204e225..ac0476e29 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/block/RichTextBlock.java +++ b/slack-api-model/src/main/java/com/slack/api/model/block/RichTextBlock.java @@ -16,7 +16,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor -public class RichTextBlock implements LayoutBlock { +public class RichTextBlock implements LayoutBlock, TableCell { public static final String TYPE = "rich_text"; private final String type = TYPE; @Builder.Default diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/TableBlock.java b/slack-api-model/src/main/java/com/slack/api/model/block/TableBlock.java new file mode 100644 index 000000000..61debad48 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/TableBlock.java @@ -0,0 +1,43 @@ +package com.slack.api.model.block; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +/** + * Displays tabular data as a grid of rows and cells. + * + *

Each row is a list of {@link TableCell cells}; a cell may be a + * {@link RawTextTableCell} ({@code raw_text}), {@link RawNumberTableCell} + * ({@code raw_number}), or a {@link RichTextBlock} ({@code rich_text}). A table + * supports up to 100 rows and up to 20 cells per row, with optional per-column + * behavior configured via {@link TableColumnSetting column settings}.

+ * + * @see Table block + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TableBlock implements LayoutBlock { + public static final String TYPE = "table"; + private final String type = TYPE; + + /** + * The rows of the table. Each row is a list of cells. Maximum 100 rows, with up to + * 20 cells per row. + */ + @Builder.Default + private List> rows = new ArrayList<>(); + + /** + * Per-column behavior configuration (alignment, wrapping). Maximum 20 items. + */ + private List columnSettings; + + private String blockId; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/TableCell.java b/slack-api-model/src/main/java/com/slack/api/model/block/TableCell.java new file mode 100644 index 000000000..27cda7555 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/TableCell.java @@ -0,0 +1,18 @@ +package com.slack.api.model.block; + +/** + * A single cell within a {@link TableBlock} row. A cell can be one of: + * + *
    + *
  • {@link RawTextTableCell} ({@code raw_text})
  • + *
  • {@link RawNumberTableCell} ({@code raw_number})
  • + *
  • {@link com.slack.api.model.block.RichTextBlock RichTextBlock} ({@code rich_text})
  • + *
+ * + * @see Table block + */ +public interface TableCell { + + String getType(); + +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/TableColumnSetting.java b/slack-api-model/src/main/java/com/slack/api/model/block/TableColumnSetting.java new file mode 100644 index 000000000..5e0411116 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/TableColumnSetting.java @@ -0,0 +1,28 @@ +package com.slack.api.model.block; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Describes the behavior of a single column in a {@link TableBlock}. Up to 20 column + * settings can be supplied, one per column. + * + * @see Table block + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TableColumnSetting { + /** + * Horizontal text alignment for the column. One of {@code left}, {@code center}, + * or {@code right}. Defaults to {@code left}. + */ + private String align; + /** + * Whether text in the column wraps onto multiple lines. Defaults to {@code false}. + */ + private Boolean isWrapped; +} 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..62c304c98 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 @@ -33,40 +33,42 @@ public LayoutBlock deserialize(JsonElement json, Type typeOfT, JsonDeserializati private Class getLayoutClassInstance(String typeName) { switch (typeName) { - case SectionBlock.TYPE: - return SectionBlock.class; - case DividerBlock.TYPE: - return DividerBlock.class; - case ImageBlock.TYPE: - return ImageBlock.class; + case ActionsBlock.TYPE: + return ActionsBlock.class; + case AlertBlock.TYPE: + return AlertBlock.class; + case CallBlock.TYPE: + return CallBlock.class; + case CardBlock.TYPE: + return CardBlock.class; + case CarouselBlock.TYPE: + return CarouselBlock.class; case ContextBlock.TYPE: return ContextBlock.class; case ContextActionsBlock.TYPE: return ContextActionsBlock.class; - case CallBlock.TYPE: - return CallBlock.class; - case ActionsBlock.TYPE: - return ActionsBlock.class; + case DividerBlock.TYPE: + return DividerBlock.class; case FileBlock.TYPE: return FileBlock.class; - case InputBlock.TYPE: - return InputBlock.class; case HeaderBlock.TYPE: return HeaderBlock.class; + case ImageBlock.TYPE: + return ImageBlock.class; + case InputBlock.TYPE: + return InputBlock.class; case MarkdownBlock.TYPE: return MarkdownBlock.class; - case VideoBlock.TYPE: - return VideoBlock.class; case RichTextBlock.TYPE: return RichTextBlock.class; + case SectionBlock.TYPE: + return SectionBlock.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; + case TableBlock.TYPE: + return TableBlock.class; + case VideoBlock.TYPE: + return VideoBlock.class; default: if (failOnUnknownProperties) { throw new JsonParseException("Unsupported layout block type: " + typeName); diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/GsonTableCellFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/GsonTableCellFactory.java new file mode 100644 index 000000000..176f80c01 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/json/GsonTableCellFactory.java @@ -0,0 +1,56 @@ +package com.slack.api.util.json; + +import com.google.gson.*; +import com.slack.api.model.block.RawNumberTableCell; +import com.slack.api.model.block.RawTextTableCell; +import com.slack.api.model.block.RichTextBlock; +import com.slack.api.model.block.TableCell; + +import java.lang.reflect.Type; + +/** + * Factory for deserializing the cells of a + * {@link com.slack.api.model.block.TableBlock table block}. + * + * @see Table block + */ +public class GsonTableCellFactory implements JsonDeserializer, JsonSerializer { + + private final boolean failOnUnknownProperties; + + public GsonTableCellFactory() { + this(false); + } + + public GsonTableCellFactory(boolean failOnUnknownProperties) { + this.failOnUnknownProperties = failOnUnknownProperties; + } + + @Override + public TableCell deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + final JsonObject jsonObject = json.getAsJsonObject(); + final JsonPrimitive prim = (JsonPrimitive) jsonObject.get("type"); + final String typeName = prim.getAsString(); + final Class clazz = getTableCellClassInstance(typeName); + return context.deserialize(jsonObject, clazz); + } + + @Override + public JsonElement serialize(TableCell src, Type typeOfSrc, JsonSerializationContext context) { + return context.serialize(src); + } + + private Class getTableCellClassInstance(String typeName) { + switch (typeName) { + case RawTextTableCell.TYPE: + return RawTextTableCell.class; + case RawNumberTableCell.TYPE: + return RawNumberTableCell.class; + case RichTextBlock.TYPE: + return RichTextBlock.class; + default: + throw new JsonParseException("Unknown table cell 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..ef93a1c01 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 @@ -2202,4 +2202,94 @@ public void buildCarouselBlock() { assertThat(parsedCarousel.getElements().get(0).getBlockId(), is("card1")); assertThat(parsedCarousel.getElements().get(0).getActions().size(), is(1)); } + + @Test + public void parseTableBlock() { + // https://docs.slack.dev/reference/block-kit/blocks/table-block + String json = "{\n" + + " \"blocks\": [\n" + + " {\n" + + " \"type\": \"table\",\n" + + " \"block_id\": \"table1\",\n" + + " \"column_settings\": [\n" + + " { \"align\": \"left\", \"is_wrapped\": false },\n" + + " { \"align\": \"right\", \"is_wrapped\": true }\n" + + " ],\n" + + " \"rows\": [\n" + + " [\n" + + " { \"type\": \"raw_text\", \"text\": \"Item\" },\n" + + " { \"type\": \"raw_text\", \"text\": \"Cost\" }\n" + + " ],\n" + + " [\n" + + " {\n" + + " \"type\": \"rich_text\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"rich_text_section\",\n" + + " \"elements\": [\n" + + " { \"type\": \"text\", \"text\": \"Rent\", \"style\": { \"bold\": true } }\n" + + " ]\n" + + " }\n" + + " ]\n" + + " },\n" + + " { \"type\": \"raw_number\", \"value\": 400, \"text\": \"$400\" }\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)); + + TableBlock table = (TableBlock) message.getBlocks().get(0); + assertThat(table.getType(), is("table")); + assertThat(table.getBlockId(), is("table1")); + + assertThat(table.getColumnSettings().size(), is(2)); + assertThat(table.getColumnSettings().get(0).getAlign(), is("left")); + assertThat(table.getColumnSettings().get(0).getIsWrapped(), is(false)); + assertThat(table.getColumnSettings().get(1).getAlign(), is("right")); + assertThat(table.getColumnSettings().get(1).getIsWrapped(), is(true)); + + assertThat(table.getRows().size(), is(2)); + + RawTextTableCell header0 = (RawTextTableCell) table.getRows().get(0).get(0); + assertThat(header0.getType(), is(RawTextTableCell.TYPE)); + assertThat(header0.getText(), is("Item")); + + RichTextBlock richCell = (RichTextBlock) table.getRows().get(1).get(0); + assertThat(richCell.getType(), is(RichTextBlock.TYPE)); + RichTextSectionElement richSection = (RichTextSectionElement) richCell.getElements().get(0); + RichTextSectionElement.Text richText = (RichTextSectionElement.Text) richSection.getElements().get(0); + assertThat(richText.getText(), is("Rent")); + assertThat(richText.getStyle().isBold(), is(true)); + + RawNumberTableCell numberCell = (RawNumberTableCell) table.getRows().get(1).get(1); + assertThat(numberCell.getType(), is(RawNumberTableCell.TYPE)); + assertThat(numberCell.getValue(), is(400.0)); + assertThat(numberCell.getText(), is("$400")); + + // round-trips back to a table block + String output = gson.toJson(table); + TableBlock reparsedTable = gson.fromJson(output, TableBlock.class); + assertThat(reparsedTable.getRows().size(), is(2)); + assertThat(((RawTextTableCell) reparsedTable.getRows().get(0).get(1)).getText(), is("Cost")); + } + + @Test + public void buildTableBlock() { + TableBlock table = Blocks.table(t -> t + .blockId("b") + .columnSettings(Arrays.asList( + TableColumnSetting.builder().align("center").isWrapped(true).build())) + .rows(Arrays.asList( + Arrays.asList( + RawTextTableCell.builder().text("Header").build(), + RawNumberTableCell.builder().value(1.0).text("1").build())))); + assertThat(table, is(notNullValue())); + assertThat(table.getType(), is("table")); + assertThat(table.getRows().get(0).size(), is(2)); + } } diff --git a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java index 0d30bab1c..4d3b1c5fa 100644 --- a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java +++ b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java @@ -8,6 +8,7 @@ import com.slack.api.model.block.ContextBlockElement; import com.slack.api.model.block.ContextActionsBlockElement; import com.slack.api.model.block.LayoutBlock; +import com.slack.api.model.block.TableCell; import com.slack.api.model.block.composition.TextObject; import com.slack.api.model.block.element.BlockElement; import com.slack.api.model.block.element.RichTextElement; @@ -39,6 +40,7 @@ public static Gson createSnakeCase(boolean failOnUnknownProperties, boolean unkn .registerTypeAdapter(ContextActionsBlockElement.class, new GsonContextActionsBlockElementFactory(failOnUnknownProperties)) .registerTypeAdapter(TextObject.class, new GsonTextObjectFactory(failOnUnknownProperties)) .registerTypeAdapter(RichTextElement.class, new GsonRichTextElementFactory(failOnUnknownProperties)) + .registerTypeAdapter(TableCell.class, new GsonTableCellFactory(failOnUnknownProperties)) .registerTypeAdapter(FunctionExecutedEvent.InputValue.class, new GsonFunctionExecutedEventInputValueFactory(failOnUnknownProperties)) .registerTypeAdapter(Attachment.VideoHtml.class,