diff --git a/slack_sdk/models/blocks/__init__.py b/slack_sdk/models/blocks/__init__.py index db11558ab..43cb2f99b 100644 --- a/slack_sdk/models/blocks/__init__.py +++ b/slack_sdk/models/blocks/__init__.py @@ -16,6 +16,7 @@ Option, OptionGroup, PlainTextObject, + RawNumberObject, RawTextObject, TableBlockColumnSettings, TextObject, @@ -70,6 +71,7 @@ ContainerBlock, ContextActionsBlock, ContextBlock, + DataTableBlock, DividerBlock, FileBlock, HeaderBlock, @@ -93,6 +95,7 @@ "Option", "OptionGroup", "PlainTextObject", + "RawNumberObject", "RawTextObject", "TableBlockColumnSettings", "TextObject", @@ -143,6 +146,7 @@ "ContainerBlock", "ContextActionsBlock", "ContextBlock", + "DataTableBlock", "DividerBlock", "FileBlock", "HeaderBlock", diff --git a/slack_sdk/models/blocks/basic_components.py b/slack_sdk/models/blocks/basic_components.py index c86007bab..451c8bb97 100644 --- a/slack_sdk/models/blocks/basic_components.py +++ b/slack_sdk/models/blocks/basic_components.py @@ -151,6 +151,28 @@ def direct_from_link(link: Link, title: str = "") -> Dict[str, Any]: return MarkdownTextObject.from_link(link, title).to_dict() +class RawNumberObject(JsonObject): + """raw_number typed object.""" + + type = "raw_number" + attributes = {"value", "text", "type"} + logger = logging.getLogger(__name__) + + def __init__(self, *, value: Union[int, float], text: str): + """Defines an object containing a numeric value. + + Args: + value (required): The numeric value. + text (required): The text used to display the value. The minimum length is 1 character. + """ + self.value = value + self.text = text + + @JsonValidator("text attribute must have at least 1 character") + def _validate_text_min_length(self): + return len(self.text) >= 1 + + class RawTextObject(TextObject): """raw_text typed text object.""" diff --git a/slack_sdk/models/blocks/blocks.py b/slack_sdk/models/blocks/blocks.py index 04b224159..0d808bb1c 100644 --- a/slack_sdk/models/blocks/blocks.py +++ b/slack_sdk/models/blocks/blocks.py @@ -10,6 +10,7 @@ from .basic_components import ( MarkdownTextObject, PlainTextObject, + RawNumberObject, RawTextObject, SlackFile, TableBlockColumnSettings, @@ -117,6 +118,8 @@ def parse(cls, block: Union[dict, "Block"]) -> Optional["Block"]: return ContainerBlock(**block) elif type == CarouselBlock.type: return CarouselBlock(**block) + elif type == DataTableBlock.type: + return DataTableBlock(**block) else: cls.logger.warning(f"Unknown block detected and skipped ({block})") return None @@ -1156,3 +1159,56 @@ def _validate_elements_present(self): @JsonValidator(f"elements attribute cannot exceed {elements_max_length} cards") def _validate_elements_length(self): return self.elements is None or len(self.elements) <= self.elements_max_length + + +class DataTableBlock(Block): + type = "data_table" + page_size_min = 1 + page_size_max = 100 + + @property + def attributes(self) -> Set[str]: # type: ignore[override] + return super().attributes.union({"rows", "caption", "page_size", "row_header_column_index"}) + + def __init__( + self, + *, + rows: Sequence[Sequence[Union[RawTextObject, RawNumberObject, RichTextBlock, Dict[str, Any]]]], + caption: str, + page_size: Optional[int] = None, + row_header_column_index: Optional[int] = None, + block_id: Optional[str] = None, + **others: dict, + ): + """Displays rich tables that support pagination, sorting, filtering, and interactivity. + + https://docs.slack.dev/reference/block-kit/blocks/data-table-block + + Args: + rows (required): An array consisting of table rows. + caption (required): A caption for the table; used as the value for the HTML caption element. + page_size: Number of rows per page. Min 1, Max 100. Defaults to 5 if omitted. + row_header_column_index: The 0-based index of the column that uniquely identifies each row + (the row header). This column is treated as the row's primary identifier for screen readers. + Defaults to 0 if omitted. + block_id: A unique identifier for a block. If not specified, a block_id will be generated. + """ + super().__init__(type=self.type, block_id=block_id) + show_unknown_key_warning(self, others) + + self.rows = rows + self.caption = caption + self.page_size = page_size + self.row_header_column_index = row_header_column_index + + @JsonValidator("rows attribute must be specified") + def _validate_rows(self): + return self.rows is not None and len(self.rows) > 0 + + @JsonValidator("caption attribute must be specified") + def _validate_caption(self): + return self.caption is not None + + @JsonValidator(f"page_size must be between {page_size_min} and {page_size_max}") + def _validate_page_size(self): + return self.page_size is None or self.page_size_min <= self.page_size <= self.page_size_max diff --git a/tests/slack_sdk/models/test_blocks.py b/tests/slack_sdk/models/test_blocks.py index 369132c32..2a8a46aec 100644 --- a/tests/slack_sdk/models/test_blocks.py +++ b/tests/slack_sdk/models/test_blocks.py @@ -13,6 +13,7 @@ ContainerBlock, ContextActionsBlock, ContextBlock, + DataTableBlock, DividerBlock, FileBlock, HeaderBlock, @@ -26,6 +27,7 @@ OverflowMenuElement, PlainTextObject, PlanBlock, + RawNumberObject, RawTextObject, RichTextBlock, RichTextElementParts, @@ -1358,6 +1360,40 @@ def test_parsing_empty_block_elements(self): self.assertIsNotNone(block_dict["elements"][3].get("elements")) +# ---------------------------------------------- +# RawNumberObject +# ---------------------------------------------- + + +class RawNumberObjectTests(unittest.TestCase): + def test_basic_creation(self): + """Test basic RawNumberObject creation""" + obj = RawNumberObject(value=120, text="120") + expected = {"type": "raw_number", "value": 120, "text": "120"} + self.assertDictEqual(expected, obj.to_dict()) + + def test_float_value(self): + """Test RawNumberObject accepts a float value""" + obj = RawNumberObject(value=3.14, text="3.14") + expected = {"type": "raw_number", "value": 3.14, "text": "3.14"} + self.assertDictEqual(expected, obj.to_dict()) + + def test_text_length_validation_min(self): + """Test that empty text fails validation""" + with self.assertRaises(SlackObjectFormationError): + RawNumberObject(value=0, text="").to_dict() + + def test_text_length_validation_at_min(self): + """Test that text with 1 character passes validation""" + obj = RawNumberObject(value=1, text="1") + obj.to_dict() # Should not raise + + def test_attributes(self): + """Test that RawNumberObject only has value, text, and type attributes""" + obj = RawNumberObject(value=1, text="1") + self.assertEqual(obj.attributes, {"value", "text", "type"}) + + # ---------------------------------------------- # RawTextObject # ---------------------------------------------- @@ -1923,3 +1959,99 @@ def test_single_card(self): def test_empty_elements_validation(self): with self.assertRaises(SlackObjectFormationError): CarouselBlock(elements=[]).validate_json() + + +class DataTableBlockTests(unittest.TestCase): + def test_document(self): + """Test basic data table block from Slack documentation example""" + input = { + "type": "data_table", + "caption": "Quarterly sales by region", + "rows": [ + [{"type": "raw_text", "text": "Region"}, {"type": "raw_text", "text": "Sales"}], + [{"type": "raw_text", "text": "West"}, {"type": "raw_number", "value": 120, "text": "120"}], + [{"type": "raw_text", "text": "East"}, {"type": "raw_number", "value": 95, "text": "95"}], + ], + } + self.assertDictEqual(input, DataTableBlock(**input).to_dict()) + self.assertDictEqual(input, Block.parse(input).to_dict()) + + def test_all_fields(self): + """Test data table block with every optional field set""" + input = { + "type": "data_table", + "block_id": "data-table-123", + "caption": "User directory", + "page_size": 25, + "row_header_column_index": 1, + "rows": [ + [{"type": "raw_text", "text": "ID"}, {"type": "raw_text", "text": "Name"}], + [{"type": "raw_number", "value": 1, "text": "1"}, {"type": "raw_text", "text": "Alice"}], + ], + } + self.assertDictEqual(input, DataTableBlock(**input).to_dict()) + self.assertDictEqual(input, Block.parse(input).to_dict()) + + def test_with_rich_text(self): + """Test data table block with rich_text cells""" + input = { + "type": "data_table", + "caption": "Links", + "rows": [ + [{"type": "raw_text", "text": "Site"}], + [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"text": "Slack", "type": "link", "url": "https://slack.com"}], + } + ], + }, + ], + ], + } + self.assertDictEqual(input, DataTableBlock(**input).to_dict()) + self.assertDictEqual(input, Block.parse(input).to_dict()) + + def test_with_cell_object_helpers(self): + """Test data table block built from RawTextObject and RawNumberObject helpers""" + block = DataTableBlock( + caption="Quarterly sales by region", + rows=[ + [RawTextObject(text="Region").to_dict(), RawTextObject(text="Sales").to_dict()], + [RawTextObject(text="West").to_dict(), RawNumberObject(value=120, text="120").to_dict()], + [RawTextObject(text="East").to_dict(), RawNumberObject(value=95, text="95").to_dict()], + ], + ) + expected = { + "type": "data_table", + "caption": "Quarterly sales by region", + "rows": [ + [{"type": "raw_text", "text": "Region"}, {"type": "raw_text", "text": "Sales"}], + [{"type": "raw_text", "text": "West"}, {"type": "raw_number", "value": 120, "text": "120"}], + [{"type": "raw_text", "text": "East"}, {"type": "raw_number", "value": 95, "text": "95"}], + ], + } + self.assertDictEqual(expected, block.to_dict()) + + def test_rows_validation(self): + """Test that empty rows fail validation""" + with self.assertRaises(SlackObjectFormationError): + DataTableBlock(caption="empty", rows=[]).to_dict() + + def test_caption_required(self): + """Test that DataTableBlock requires a caption argument""" + with self.assertRaises(TypeError): + DataTableBlock(rows=[[{"type": "raw_text", "text": "A"}]]) + + def test_page_size_validation(self): + """Test that page_size outside the allowed range fails validation""" + rows = [[{"type": "raw_text", "text": "A"}], [{"type": "raw_text", "text": "B"}]] + with self.assertRaises(SlackObjectFormationError): + DataTableBlock(caption="too small", rows=rows, page_size=0).to_dict() + with self.assertRaises(SlackObjectFormationError): + DataTableBlock(caption="too big", rows=rows, page_size=101).to_dict() + # A valid page_size should pass + DataTableBlock(caption="ok", rows=rows, page_size=50).to_dict()