Skip to content
Draft
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 @@ -68,6 +68,8 @@
CarouselBlock,
ContextActionsBlock,
ContextBlock,
DataTableBlock,
DataVisualizationBlock,
DividerBlock,
FileBlock,
HeaderBlock,
Expand Down Expand Up @@ -139,6 +141,8 @@
"CarouselBlock",
"ContextActionsBlock",
"ContextBlock",
"DataTableBlock",
"DataVisualizationBlock",
"DividerBlock",
"FileBlock",
"HeaderBlock",
Expand Down
127 changes: 127 additions & 0 deletions slack_sdk/models/blocks/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ def parse(cls, block: Union[dict, "Block"]) -> Optional["Block"]:
return AlertBlock(**block)
elif type == CarouselBlock.type:
return CarouselBlock(**block)
elif type == DataTableBlock.type:
return DataTableBlock(**block)
elif type == DataVisualizationBlock.type:
return DataVisualizationBlock(**block)
else:
cls.logger.warning(f"Unknown block detected and skipped ({block})")
return None
Expand Down Expand Up @@ -1030,3 +1034,126 @@ 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"
rows_max_length = 101
columns_max_length = 20
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[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 structured, paginated data in a table with a required caption.
https://docs.slack.dev/reference/block-kit/blocks/data-table-block

Args:
rows (required): An array consisting of table rows. Minimum 2 rows (header plus one data row)
and maximum 101 rows (header plus 100 data rows). All rows must have an identical column
count, with a maximum of 20 columns. Each cell has a type of raw_text, raw_number, or
rich_text. The total character limit across all cells is 10,000.
caption (required): A caption for the table; used as the value for the HTML caption element.
page_size: The number of rows to show 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). Defaults to 0 if omitted.
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.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


class DataVisualizationBlock(Block):
type = "data_visualization"
title_max_length = 50
valid_chart_types = {"pie", "bar", "area", "line"}

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

def __init__(
self,
*,
title: str,
chart: Dict[str, Any],
block_id: Optional[str] = None,
**others: dict,
):
"""Displays data as a chart, such as a pie, bar, area, or line chart.
https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block

Args:
title (required): The title of the chart, in plain text. Maximum 50 characters.
chart (required): An object describing the chart to render. The chart's "type" must be
one of "pie", "bar", "area", or "line".
A "pie" chart provides "segments" (1-6), where each segment has a "label" (max 20
characters) and a positive "value".
A "bar", "area", or "line" chart provides "series" (1-6) and an "axis_config". Each
series has a unique "name" (max 20 characters) and "data" (1-20 points), where each
data point has a "label" (max 20 characters, matching an axis category) and a "value".
The "axis_config" defines the "categories" (each max 20 characters) that set the
x-axis order, and optionally "x_label" and "y_label" (each max 50 characters).
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.title = title
self.chart = chart

@JsonValidator("title attribute must be specified")
def _validate_title(self):
return self.title is not None

@JsonValidator(f"title attribute cannot exceed {title_max_length} characters")
def _validate_title_length(self):
return self.title is None or len(self.title) <= self.title_max_length

@JsonValidator("chart attribute must be specified")
def _validate_chart(self):
return self.chart is not None and len(self.chart) > 0

@JsonValidator("chart type must be a valid value (pie, bar, area, line)")
def _validate_chart_type(self):
if not self.chart:
return True
chart_type = self.chart.get("type")
return chart_type is None or chart_type in self.valid_chart_types
178 changes: 178 additions & 0 deletions tests/slack_sdk/models/test_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
CarouselBlock,
ContextActionsBlock,
ContextBlock,
DataTableBlock,
DataVisualizationBlock,
DividerBlock,
FileBlock,
HeaderBlock,
Expand Down Expand Up @@ -1556,6 +1558,182 @@ def test_with_raw_text_object_helper(self):
self.assertDictEqual(expected, block.to_dict())


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


class DataVisualizationBlockTests(unittest.TestCase):
def test_document(self):
"""Test basic pie chart data visualization block from Slack documentation example"""
input = {
"type": "data_visualization",
"title": "Quarterly sales by region",
"chart": {
"type": "pie",
"segments": [
{"label": "West", "value": 120},
{"label": "East", "value": 95},
{"label": "North", "value": 60},
],
},
}
self.assertDictEqual(input, DataVisualizationBlock(**input).to_dict())
self.assertDictEqual(input, Block.parse(input).to_dict())

def test_bar_chart_with_block_id(self):
"""Test a bar chart with series, axis_config, and a block_id"""
input = {
"type": "data_visualization",
"block_id": "data-viz-123",
"title": "Revenue vs cost",
"chart": {
"type": "bar",
"series": [
{
"name": "Revenue",
"data": [
{"label": "Q1", "value": 100},
{"label": "Q2", "value": 150},
],
},
{
"name": "Cost",
"data": [
{"label": "Q1", "value": 80},
{"label": "Q2", "value": 90},
],
},
],
"axis_config": {
"categories": ["Q1", "Q2"],
"x_label": "Quarter",
"y_label": "USD (thousands)",
},
},
}
self.assertDictEqual(input, DataVisualizationBlock(**input).to_dict())
self.assertDictEqual(input, Block.parse(input).to_dict())

def test_line_chart(self):
"""Test a line chart, which permits negative values"""
input = {
"type": "data_visualization",
"title": "Net change",
"chart": {
"type": "line",
"series": [
{
"name": "Change",
"data": [
{"label": "Jan", "value": -10},
{"label": "Feb", "value": 5},
],
},
],
"axis_config": {"categories": ["Jan", "Feb"]},
},
}
self.assertDictEqual(input, DataVisualizationBlock(**input).to_dict())
self.assertDictEqual(input, Block.parse(input).to_dict())

def test_title_required(self):
"""Test that DataVisualizationBlock requires a title argument"""
with self.assertRaises(TypeError):
DataVisualizationBlock(chart={"type": "pie", "segments": [{"label": "A", "value": 1}]})

def test_chart_required(self):
"""Test that DataVisualizationBlock requires a chart argument"""
with self.assertRaises(TypeError):
DataVisualizationBlock(title="No chart")

def test_title_length_validation(self):
"""Test that a title longer than 50 characters fails validation"""
chart = {"type": "pie", "segments": [{"label": "A", "value": 1}]}
with self.assertRaises(SlackObjectFormationError):
DataVisualizationBlock(title="a" * 51, chart=chart).to_dict()
# Exactly 50 characters should pass
DataVisualizationBlock(title="a" * 50, chart=chart).to_dict()

def test_chart_type_validation(self):
"""Test that an unsupported chart type fails validation"""
with self.assertRaises(SlackObjectFormationError):
DataVisualizationBlock(
title="Bad chart",
chart={"type": "scatter", "segments": [{"label": "A", "value": 1}]},
).to_dict()


class CardBlockTests(unittest.TestCase):
def test_document(self):
input = {
Expand Down