From e1e9aed1826b9584e9c22b4068aaf3727ebe4ee2 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 26 Jun 2026 16:44:45 -0700 Subject: [PATCH] feat(models): add data_visualization Block Kit block Add DataVisualizationBlock supporting pie, bar, area, and line charts, including title length and chart type validation, parse() registration, package exports, and tests mirroring existing block coverage. Co-Authored-By: Claude --- slack_sdk/models/blocks/__init__.py | 4 + slack_sdk/models/blocks/blocks.py | 127 ++++++++++++++++++ tests/slack_sdk/models/test_blocks.py | 178 ++++++++++++++++++++++++++ 3 files changed, 309 insertions(+) diff --git a/slack_sdk/models/blocks/__init__.py b/slack_sdk/models/blocks/__init__.py index 6a26ed958..94dd8b58a 100644 --- a/slack_sdk/models/blocks/__init__.py +++ b/slack_sdk/models/blocks/__init__.py @@ -68,6 +68,8 @@ CarouselBlock, ContextActionsBlock, ContextBlock, + DataTableBlock, + DataVisualizationBlock, DividerBlock, FileBlock, HeaderBlock, @@ -139,6 +141,8 @@ "CarouselBlock", "ContextActionsBlock", "ContextBlock", + "DataTableBlock", + "DataVisualizationBlock", "DividerBlock", "FileBlock", "HeaderBlock", diff --git a/slack_sdk/models/blocks/blocks.py b/slack_sdk/models/blocks/blocks.py index db4de1f3a..f575d36c6 100644 --- a/slack_sdk/models/blocks/blocks.py +++ b/slack_sdk/models/blocks/blocks.py @@ -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 @@ -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 diff --git a/tests/slack_sdk/models/test_blocks.py b/tests/slack_sdk/models/test_blocks.py index fc9ff3266..5fc76cefe 100644 --- a/tests/slack_sdk/models/test_blocks.py +++ b/tests/slack_sdk/models/test_blocks.py @@ -12,6 +12,8 @@ CarouselBlock, ContextActionsBlock, ContextBlock, + DataTableBlock, + DataVisualizationBlock, DividerBlock, FileBlock, HeaderBlock, @@ -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 = {