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 = {