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 4468f2df4..3a25b3af3 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; @@ -79,6 +80,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 fd451d8d2..b2e730fe9 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(); } + // TableBlock + + public static TableBlock table(ModelConfigurator configurator) { + return configurator.configure(TableBlock.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 9286e6b51..810d5a1b7 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 TableBlock.TYPE: + return TableBlock.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 1fc12d58d..aa698a375 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,94 @@ public void parseRichTextElements() { RichTextSectionElement section = (RichTextSectionElement) ((RichTextBlock) message.getBlocks().get(0)).getElements().get(0); assertThat(section.getElements().size(), is(10)); } + + @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 reparsed = gson.fromJson(output, TableBlock.class); + assertThat(reparsed.getRows().size(), is(2)); + assertThat(((RawTextTableCell) reparsed.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,