Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
RawTextObject,
TextObject,
)
from .block_elements import (
Expand Down Expand Up @@ -71,6 +72,7 @@
MarkdownBlock,
RichTextBlock,
SectionBlock,
TableBlock,
VideoBlock,
)

Expand All @@ -83,6 +85,7 @@
"Option",
"OptionGroup",
"PlainTextObject",
"RawTextObject",
"TextObject",
"BlockElement",
"ButtonElement",
Expand Down Expand Up @@ -133,6 +136,7 @@
"InputBlock",
"MarkdownBlock",
"SectionBlock",
"TableBlock",
"VideoBlock",
"RichTextBlock",
]
34 changes: 34 additions & 0 deletions slack_sdk/models/blocks/basic_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,40 @@ def direct_from_link(link: Link, title: str = "") -> Dict[str, Any]:
return MarkdownTextObject.from_link(link, title).to_dict()


class RawTextObject(TextObject):
"""raw_text typed text object"""

type = "raw_text"

@property
def attributes(self) -> Set[str]: # type: ignore[override]
return {"text", "type"}

def __init__(self, *, text: str):
"""A raw text object used in table block cells.
https://docs.slack.dev/reference/block-kit/composition-objects/text-object/
https://docs.slack.dev/reference/block-kit/blocks/table-block

Args:
text (required): The text content for the table block cell.
"""
super().__init__(text=text, type=self.type)

@staticmethod
def from_str(text: str) -> "RawTextObject":
"""Transforms a string into a RawTextObject"""
return RawTextObject(text=text)

@staticmethod
def direct_from_string(text: str) -> Dict[str, Any]:
"""Transforms a string into the required object shape to act as a RawTextObject"""
return RawTextObject.from_str(text).to_dict()

@JsonValidator("text attribute must have at least 1 character")
def _validate_text_min_length(self):
return len(self.text) >= 1


class Option(JsonObject):
"""Option object used in dialogs, legacy message actions (interactivity in attachments),
and blocks. JSON must be retrieved with an explicit option_type - the Slack API has
Expand Down
46 changes: 46 additions & 0 deletions slack_sdk/models/blocks/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ def parse(cls, block: Union[dict, "Block"]) -> Optional["Block"]:
return VideoBlock(**block)
elif type == RichTextBlock.type:
return RichTextBlock(**block)
elif type == TableBlock.type:
return TableBlock(**block)
else:
cls.logger.warning(f"Unknown block detected and skipped ({block})")
return None
Expand Down Expand Up @@ -731,3 +733,47 @@ def __init__(
show_unknown_key_warning(self, others)

self.elements = BlockElement.parse_all(elements)


class TableBlock(Block):
type = "table"

@property
def attributes(self) -> Set[str]: # type: ignore[override]
return super().attributes.union({"rows", "column_settings"})

def __init__(
self,
*,
rows: Sequence[Sequence[Dict[str, Any]]],
column_settings: Optional[Sequence[Optional[Dict[str, Any]]]] = None,
block_id: Optional[str] = None,
**others: dict,
):
"""Displays structured information in a table.
https://docs.slack.dev/reference/block-kit/blocks/table-block

Args:
rows (required): An array consisting of table rows. Maximum 100 rows.
Each row object is an array with a max of 20 table cells.
Table cells can have a type of raw_text or rich_text.
column_settings: An array describing column behavior. If there are fewer items in the column_settings array
than there are columns in the table, then the items in the the column_settings array will describe
the same number of columns in the table as there are in the array itself.
Any additional columns will have the default behavior. Maximum 20 items.
See below for column settings schema.
block_id: A unique identifier for a block. If not specified, a block_id will be generated.
You can use this block_id when you receive an interaction payload to identify the source of the action.
Maximum length for this field is 255 characters.
block_id should be unique for each message and each iteration of a message.
If a message is updated, use a new block_id.
"""
super().__init__(type=self.type, block_id=block_id)
show_unknown_key_warning(self, others)

self.rows = rows
self.column_settings = column_settings

@JsonValidator("rows attribute must be specified")
def _validate_rows(self):
return self.rows is not None and len(self.rows) > 0
202 changes: 202 additions & 0 deletions tests/slack_sdk/models/test_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
Option,
OverflowMenuElement,
PlainTextObject,
RawTextObject,
RichTextBlock,
RichTextElementParts,
RichTextListElement,
Expand All @@ -29,6 +30,7 @@
RichTextSectionElement,
SectionBlock,
StaticSelectElement,
TableBlock,
VideoBlock,
)
from slack_sdk.models.blocks.basic_components import FeedbackButtonObject, SlackFile
Expand Down Expand Up @@ -1267,3 +1269,203 @@ def test_parsing_empty_block_elements(self):
self.assertIsNotNone(block_dict["elements"][1].get("elements"))
self.assertIsNotNone(block_dict["elements"][2].get("elements"))
self.assertIsNotNone(block_dict["elements"][3].get("elements"))


# ----------------------------------------------
# RawTextObject
# ----------------------------------------------


class RawTextObjectTests(unittest.TestCase):
def test_basic_creation(self):
"""Test basic RawTextObject creation"""
obj = RawTextObject(text="Hello")
expected = {"type": "raw_text", "text": "Hello"}
self.assertDictEqual(expected, obj.to_dict())

def test_from_str(self):
"""Test RawTextObject.from_str() helper"""
obj = RawTextObject.from_str("Test text")
expected = {"type": "raw_text", "text": "Test text"}
self.assertDictEqual(expected, obj.to_dict())

def test_direct_from_string(self):
"""Test RawTextObject.direct_from_string() helper"""
result = RawTextObject.direct_from_string("Direct text")
expected = {"type": "raw_text", "text": "Direct text"}
self.assertDictEqual(expected, result)

def test_text_length_validation_min(self):
"""Test that empty text fails validation"""
with self.assertRaises(SlackObjectFormationError):
RawTextObject(text="").to_dict()

def test_text_length_validation_at_min(self):
"""Test that text with 1 character passes validation"""
obj = RawTextObject(text="a")
obj.to_dict() # Should not raise

def test_attributes(self):
"""Test that RawTextObject only has text and type attributes"""
obj = RawTextObject(text="Test")
self.assertEqual(obj.attributes, {"text", "type"})
# Should not have emoji attribute like PlainTextObject
self.assertNotIn("emoji", obj.to_dict())


# ----------------------------------------------
# Table
# ----------------------------------------------


class TableBlockTests(unittest.TestCase):
def test_document(self):
"""Test basic table block from Slack documentation example"""
input = {
"type": "table",
"column_settings": [{"is_wrapped": True}, {"align": "right"}],
"rows": [
[{"type": "raw_text", "text": "Header A"}, {"type": "raw_text", "text": "Header B"}],
[{"type": "raw_text", "text": "Data 1A"}, {"type": "raw_text", "text": "Data 1B"}],
[{"type": "raw_text", "text": "Data 2A"}, {"type": "raw_text", "text": "Data 2B"}],
],
}
self.assertDictEqual(input, TableBlock(**input).to_dict())
self.assertDictEqual(input, Block.parse(input).to_dict())

def test_with_rich_text(self):
"""Test table block with rich_text cells"""
input = {
"type": "table",
"column_settings": [{"is_wrapped": True}, {"align": "right"}],
"rows": [
[{"type": "raw_text", "text": "Header A"}, {"type": "raw_text", "text": "Header B"}],
[
{"type": "raw_text", "text": "Data 1A"},
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_section",
"elements": [{"text": "Data 1B", "type": "link", "url": "https://slack.com"}],
}
],
},
],
],
}
self.assertDictEqual(input, TableBlock(**input).to_dict())
self.assertDictEqual(input, Block.parse(input).to_dict())

def test_minimal_table(self):
"""Test table with only required fields"""
input = {
"type": "table",
"rows": [[{"type": "raw_text", "text": "Cell"}]],
}
self.assertDictEqual(input, TableBlock(**input).to_dict())

def test_with_block_id(self):
"""Test table block with block_id"""
input = {
"type": "table",
"block_id": "table-123",
"rows": [
[{"type": "raw_text", "text": "A"}, {"type": "raw_text", "text": "B"}],
[{"type": "raw_text", "text": "1"}, {"type": "raw_text", "text": "2"}],
],
}
self.assertDictEqual(input, TableBlock(**input).to_dict())

def test_column_settings_variations(self):
"""Test various column_settings configurations"""
# Left align
input1 = {
"type": "table",
"column_settings": [{"align": "left"}],
"rows": [[{"type": "raw_text", "text": "Left"}]],
}
self.assertDictEqual(input1, TableBlock(**input1).to_dict())

# Center align
input2 = {
"type": "table",
"column_settings": [{"align": "center"}],
"rows": [[{"type": "raw_text", "text": "Center"}]],
}
self.assertDictEqual(input2, TableBlock(**input2).to_dict())

# With wrapping
input3 = {
"type": "table",
"column_settings": [{"is_wrapped": False}],
"rows": [[{"type": "raw_text", "text": "No wrap"}]],
}
self.assertDictEqual(input3, TableBlock(**input3).to_dict())

# Combined settings
input4 = {
"type": "table",
"column_settings": [{"align": "center", "is_wrapped": True}],
"rows": [[{"type": "raw_text", "text": "Both"}]],
}
self.assertDictEqual(input4, TableBlock(**input4).to_dict())

def test_column_settings_with_none(self):
"""Test column_settings with None to skip columns"""
input = {
"type": "table",
"column_settings": [{"align": "left"}, None, {"align": "right"}],
"rows": [
[
{"type": "raw_text", "text": "Left"},
{"type": "raw_text", "text": "Default"},
{"type": "raw_text", "text": "Right"},
]
],
}
self.assertDictEqual(input, TableBlock(**input).to_dict())

def test_rows_validation(self):
"""Test that rows validation works correctly"""
# Empty rows should fail validation
with self.assertRaises(SlackObjectFormationError):
TableBlock(rows=[]).to_dict()

def test_multi_row_table(self):
"""Test table with multiple rows"""
input = {
"type": "table",
"rows": [
[{"type": "raw_text", "text": "Name"}, {"type": "raw_text", "text": "Age"}],
[{"type": "raw_text", "text": "Alice"}, {"type": "raw_text", "text": "30"}],
[{"type": "raw_text", "text": "Bob"}, {"type": "raw_text", "text": "25"}],
[{"type": "raw_text", "text": "Charlie"}, {"type": "raw_text", "text": "35"}],
],
}
block = TableBlock(**input)
self.assertEqual(len(block.rows), 4)
self.assertDictEqual(input, block.to_dict())

def test_with_raw_text_object_helper(self):
"""Test table using RawTextObject helper class"""
# Create table using RawTextObject helper
block = TableBlock(
rows=[
[RawTextObject(text="Product").to_dict(), RawTextObject(text="Price").to_dict()],
[RawTextObject(text="Widget").to_dict(), RawTextObject(text="$10").to_dict()],
[RawTextObject(text="Gadget").to_dict(), RawTextObject(text="$20").to_dict()],
],
column_settings=[{"is_wrapped": True}, {"align": "right"}],
)

expected = {
"type": "table",
"column_settings": [{"is_wrapped": True}, {"align": "right"}],
"rows": [
[{"type": "raw_text", "text": "Product"}, {"type": "raw_text", "text": "Price"}],
[{"type": "raw_text", "text": "Widget"}, {"type": "raw_text", "text": "$10"}],
[{"type": "raw_text", "text": "Gadget"}, {"type": "raw_text", "text": "$20"}],
],
}
self.assertDictEqual(expected, block.to_dict())