diff --git a/slack_sdk/models/blocks/__init__.py b/slack_sdk/models/blocks/__init__.py index eb5ab7c8d..5868e40da 100644 --- a/slack_sdk/models/blocks/__init__.py +++ b/slack_sdk/models/blocks/__init__.py @@ -8,6 +8,7 @@ """ from .basic_components import ( + ActionCellObject, ButtonStyles, ConfirmObject, DynamicSelectElementTypes, @@ -72,6 +73,7 @@ ContainerBlock, ContextActionsBlock, ContextBlock, + DataTableBlock, DividerBlock, FileBlock, HeaderBlock, @@ -87,6 +89,7 @@ ) __all__ = [ + "ActionCellObject", "ButtonStyles", "ConfirmObject", "DynamicSelectElementTypes", @@ -147,6 +150,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 451c8bb97..c4bf51b8d 100644 --- a/slack_sdk/models/blocks/basic_components.py +++ b/slack_sdk/models/blocks/basic_components.py @@ -1,12 +1,15 @@ import copy import logging import warnings -from typing import Any, Dict, List, Optional, Sequence, Set, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Set, Union from slack_sdk.models import show_unknown_key_warning from slack_sdk.models.basic_objects import JsonObject, JsonValidator from slack_sdk.models.messages import Link +if TYPE_CHECKING: + from .block_elements import ButtonElement + ButtonStyles = {"danger", "primary"} DynamicSelectElementTypes = {"channels", "conversations", "users"} @@ -151,6 +154,66 @@ def direct_from_link(link: Link, title: str = "") -> Dict[str, Any]: return MarkdownTextObject.from_link(link, title).to_dict() +class ActionCellObject(JsonObject): + """action_cell typed object.""" + + type = "action_cell" + attributes = {"element", "fallback", "type"} + logger = logging.getLogger(__name__) + + @classmethod + def parse(cls, cell: Union["ActionCellObject", Dict[str, Any]]) -> Optional["ActionCellObject"]: + if cell: + if isinstance(cell, ActionCellObject): + return cell + elif isinstance(cell, dict): + return ActionCellObject(**cell) + else: + cls.logger.warning(f"Unknown type ({type(cell)}) detected when parsing an ActionCellObject") + return None + + def __init__( + self, + *, + element: Union[Dict[str, Any], "ButtonElement"], + fallback: Optional[Union[Dict[str, Any], "RawNumberObject", "RawTextObject"]] = None, + **others: dict, + ): + """Defines an object containing a button element. Action cells can only be used in the data table block. + + https://docs.slack.dev/reference/block-kit/blocks/data-table-block + + Args: + element (required): A button element. + fallback: A raw_text or raw_number cell. + """ + # Imported here since block_elements imports this module + from .block_elements import BlockElement + + show_unknown_key_warning(self, others) + self.element = BlockElement.parse(element) + self.fallback = fallback + if isinstance(fallback, dict): + d = copy.copy(fallback) + t = d.pop("type", None) + if t == RawNumberObject.type: + self.fallback = RawNumberObject(**d) + elif t == RawTextObject.type: + self.fallback = RawTextObject(**d) + + @JsonValidator("element attribute must be specified") + def _validate_element(self): + return self.element is not None + + @JsonValidator("element attribute must be a button element") + def _validate_element_type(self): + return self.element is None or getattr(self.element, "type", None) == "button" + + @JsonValidator("fallback attribute must be a raw_text or raw_number cell") + def _validate_fallback(self): + return self.fallback is None or isinstance(self.fallback, (RawNumberObject, RawTextObject)) + + class RawNumberObject(JsonObject): """raw_number typed object.""" diff --git a/slack_sdk/models/blocks/blocks.py b/slack_sdk/models/blocks/blocks.py index 52e59db8e..f27b10196 100644 --- a/slack_sdk/models/blocks/blocks.py +++ b/slack_sdk/models/blocks/blocks.py @@ -8,6 +8,7 @@ from ...errors import SlackObjectFormationError from .basic_components import ( + ActionCellObject, MarkdownTextObject, PlainTextObject, RawNumberObject, @@ -121,6 +122,8 @@ def parse(cls, block: Union[dict, "Block"]) -> Optional["Block"]: return CarouselBlock(**block) elif type == AuthProviderBlock.type: return AuthProviderBlock(**block) + elif type == DataTableBlock.type: + return DataTableBlock(**block) else: cls.logger.warning(f"Unknown block detected and skipped ({block})") return None @@ -1300,3 +1303,64 @@ def _validate_disconnect_url_length(self): @JsonValidator("accessory attribute must be an overflow menu element") def _validate_accessory(self): return self.accessory is None or isinstance(self.accessory, OverflowMenuElement) + + +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[ActionCellObject, 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 = [[self._parse_cell(cell) for cell in row] for row in rows] if rows is not None else None + self.caption = caption + self.page_size = page_size + self.row_header_column_index = row_header_column_index + + @staticmethod + def _parse_cell( + cell: Union[ActionCellObject, RawTextObject, RawNumberObject, RichTextBlock, Dict[str, Any]], + ) -> Union[ActionCellObject, RawTextObject, RawNumberObject, RichTextBlock, Dict[str, Any], None]: + if isinstance(cell, dict) and cell.get("type") == ActionCellObject.type: + return ActionCellObject.parse(cell) + return cell + + @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 c2f9f3544..72c8623cd 100644 --- a/tests/slack_sdk/models/test_blocks.py +++ b/tests/slack_sdk/models/test_blocks.py @@ -3,6 +3,7 @@ from slack_sdk.errors import SlackObjectFormationError from slack_sdk.models.blocks import ( + ActionCellObject, ActionsBlock, AlertBlock, AuthProviderBlock, @@ -14,6 +15,7 @@ ContainerBlock, ContextActionsBlock, ContextBlock, + DataTableBlock, DividerBlock, FileBlock, HeaderBlock, @@ -1360,6 +1362,93 @@ def test_parsing_empty_block_elements(self): self.assertIsNotNone(block_dict["elements"][3].get("elements")) +# ---------------------------------------------- +# ActionCellObject +# ---------------------------------------------- + + +class ActionCellObjectTests(unittest.TestCase): + def test_document(self): + """Test action_cell from Slack documentation example""" + input = { + "type": "action_cell", + "element": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Mark done", + }, + "action_id": "mark_done", + "value": "task_123", + }, + "fallback": { + "type": "raw_text", + "text": "Open", + }, + } + obj = ActionCellObject(**input) + self.assertIsInstance(obj.element, ButtonElement) + self.assertIsInstance(obj.fallback, RawTextObject) + self.assertDictEqual(input, obj.to_dict()) + + def test_raw_number_fallback(self): + """Test action_cell with a raw_number fallback""" + input = { + "type": "action_cell", + "element": { + "type": "button", + "text": {"type": "plain_text", "text": "Mark done"}, + "action_id": "mark_done", + "value": "task_123", + }, + "fallback": {"type": "raw_number", "value": 123, "text": "123"}, + } + obj = ActionCellObject(**input) + self.assertIsInstance(obj.fallback, RawNumberObject) + self.assertDictEqual(input, obj.to_dict()) + + def test_with_object_helpers(self): + """Test action_cell built from ButtonElement and RawTextObject helpers""" + obj = ActionCellObject( + element=ButtonElement(text="Mark done", action_id="mark_done", value="task_123"), + fallback=RawTextObject(text="Open"), + ) + expected = { + "type": "action_cell", + "element": { + "type": "button", + "text": {"type": "plain_text", "text": "Mark done", "emoji": True}, + "action_id": "mark_done", + "value": "task_123", + }, + "fallback": {"type": "raw_text", "text": "Open"}, + } + self.assertDictEqual(expected, obj.to_dict()) + + def test_element_validation(self): + """Test that a missing element fails validation""" + with self.assertRaises(SlackObjectFormationError): + ActionCellObject(element=None).to_dict() + + def test_element_type_validation(self): + """Test that a non-button element fails validation""" + with self.assertRaises(SlackObjectFormationError): + ActionCellObject(element={"type": "image", "image_url": "https://example.com/a.png", "alt_text": "a"}).to_dict() + + def test_fallback_type_validation(self): + """Test that a fallback other than raw_text or raw_number fails validation""" + with self.assertRaises(SlackObjectFormationError): + ActionCellObject( + element={"type": "button", "text": {"type": "plain_text", "text": "Mark done"}}, + fallback={"type": "rich_text", "elements": []}, + ).to_dict() + + def test_attributes(self): + """Test that ActionCellObject only has element, fallback, and type attributes""" + obj = ActionCellObject(element={"type": "button", "text": {"type": "plain_text", "text": "Mark done"}}) + self.assertEqual(obj.attributes, {"element", "fallback", "type"}) + + # ---------------------------------------------- # RawNumberObject # ---------------------------------------------- @@ -2120,3 +2209,168 @@ def test_accessory_must_be_overflow(self): is_connected=True, accessory={"type": "button", "action_id": "connect", "text": {"type": "plain_text", "text": "Connect"}}, ).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_with_action_cell(self): + """Test data table block with an action_cell from Slack documentation example""" + input = { + "type": "data_table", + "caption": "Tasks", + "rows": [ + [{"type": "raw_text", "text": "Task"}, {"type": "raw_text", "text": "Action"}], + [ + {"type": "raw_text", "text": "Review the plan"}, + { + "type": "action_cell", + "element": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Mark done", + }, + "action_id": "mark_done", + "value": "task_123", + }, + "fallback": { + "type": "raw_text", + "text": "Open", + }, + }, + ], + ], + } + block = DataTableBlock(**input) + self.assertIsInstance(block.rows[1][1], ActionCellObject) + self.assertDictEqual(input, block.to_dict()) + self.assertDictEqual(input, Block.parse(input).to_dict()) + + def test_with_action_cell_without_fallback(self): + """Test data table block with an action_cell that omits the optional fallback""" + input = { + "type": "data_table", + "caption": "Tasks", + "rows": [ + [{"type": "raw_text", "text": "Task"}, {"type": "raw_text", "text": "Action"}], + [ + {"type": "raw_text", "text": "Review the plan"}, + { + "type": "action_cell", + "element": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Mark done", + }, + "action_id": "mark_done", + "value": "task_123", + }, + }, + ], + ], + } + self.assertDictEqual(input, DataTableBlock(**input).to_dict()) + self.assertDictEqual(input, Block.parse(input).to_dict()) + + def test_action_cell_element_required(self): + """Test that an action_cell without an element fails""" + rows = [[{"type": "raw_text", "text": "Action"}], [{"type": "action_cell"}]] + with self.assertRaises(TypeError): + DataTableBlock(caption="missing element", rows=rows) + rows = [[{"type": "raw_text", "text": "Action"}], [{"type": "action_cell", "element": None}]] + with self.assertRaises(SlackObjectFormationError): + DataTableBlock(caption="missing element", rows=rows).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()