Skip to content
4 changes: 4 additions & 0 deletions slack_sdk/models/blocks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Option,
OptionGroup,
PlainTextObject,
RawNumberObject,
RawTextObject,
TableBlockColumnSettings,
TextObject,
Expand Down Expand Up @@ -70,6 +71,7 @@
ContainerBlock,
ContextActionsBlock,
ContextBlock,
DataTableBlock,
DividerBlock,
FileBlock,
HeaderBlock,
Expand All @@ -93,6 +95,7 @@
"Option",
"OptionGroup",
"PlainTextObject",
"RawNumberObject",
"RawTextObject",
"TableBlockColumnSettings",
"TextObject",
Expand Down Expand Up @@ -143,6 +146,7 @@
"ContainerBlock",
"ContextActionsBlock",
"ContextBlock",
"DataTableBlock",
"DividerBlock",
"FileBlock",
"HeaderBlock",
Expand Down
22 changes: 22 additions & 0 deletions slack_sdk/models/blocks/basic_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
56 changes: 56 additions & 0 deletions slack_sdk/models/blocks/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .basic_components import (
MarkdownTextObject,
PlainTextObject,
RawNumberObject,
RawTextObject,
SlackFile,
TableBlockColumnSettings,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
132 changes: 132 additions & 0 deletions tests/slack_sdk/models/test_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ContainerBlock,
ContextActionsBlock,
ContextBlock,
DataTableBlock,
DividerBlock,
FileBlock,
HeaderBlock,
Expand All @@ -26,6 +27,7 @@
OverflowMenuElement,
PlainTextObject,
PlanBlock,
RawNumberObject,
RawTextObject,
RichTextBlock,
RichTextElementParts,
Expand Down Expand Up @@ -1358,6 +1360,40 @@ def test_parsing_empty_block_elements(self):
self.assertIsNotNone(block_dict["elements"][3].get("elements"))


# ----------------------------------------------
# RawNumberObject
# ----------------------------------------------
Comment on lines +1363 to +1365

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎲 note: This and the RawTextObject following might later be moved to tests for composition objects perhaps?



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
# ----------------------------------------------
Expand Down Expand Up @@ -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):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📇 note: These tests follow CarouselBlock and holds off on wider orderings for now.

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()
Loading