diff --git a/slack_sdk/models/blocks/__init__.py b/slack_sdk/models/blocks/__init__.py index 13c50c679..c14e15c7c 100644 --- a/slack_sdk/models/blocks/__init__.py +++ b/slack_sdk/models/blocks/__init__.py @@ -67,6 +67,7 @@ CallBlock, CardBlock, CarouselBlock, + ContainerBlock, ContextActionsBlock, ContextBlock, DividerBlock, @@ -139,6 +140,7 @@ "CallBlock", "CardBlock", "CarouselBlock", + "ContainerBlock", "ContextActionsBlock", "ContextBlock", "DividerBlock", diff --git a/slack_sdk/models/blocks/blocks.py b/slack_sdk/models/blocks/blocks.py index 90d846790..9b4596571 100644 --- a/slack_sdk/models/blocks/blocks.py +++ b/slack_sdk/models/blocks/blocks.py @@ -113,6 +113,8 @@ def parse(cls, block: Union[dict, "Block"]) -> Optional["Block"]: return CardBlock(**block) elif type == AlertBlock.type: return AlertBlock(**block) + elif type == ContainerBlock.type: + return ContainerBlock(**block) elif type == CarouselBlock.type: return CarouselBlock(**block) else: @@ -1005,6 +1007,101 @@ def _validate_body_length(self): return self.body is None or self.body.text is None or len(self.body.text) <= self.body_max_length +class ContainerBlock(Block): + type = "container" + title_max_length = 150 + subtitle_max_length = 150 + child_blocks_max_length = 10 + valid_widths = {"narrow", "standard", "wide", "full"} + + @property + def attributes(self) -> Set[str]: # type: ignore[override] + return super().attributes.union( + { + "title", + "rich_text_title", + "subtitle", + "child_blocks", + "width", + "icon", + "is_collapsible", + "default_collapsed", + "has_header_divider", + } + ) + + def __init__( + self, + *, + child_blocks: Sequence[Union[dict, "Block"]], + title: Optional[Union[str, dict, PlainTextObject]] = None, + rich_text_title: Optional[Union[dict, "RichTextBlock"]] = None, + subtitle: Optional[Union[str, dict, TextObject]] = None, + width: Optional[str] = None, + icon: Optional[Union[dict, ImageElement]] = None, + is_collapsible: Optional[bool] = None, + default_collapsed: Optional[bool] = None, + has_header_divider: Optional[bool] = None, + block_id: Optional[str] = None, + **others: dict, + ): + """A general-purpose wrapper for grouping child blocks together, with a configurable size. + https://docs.slack.dev/reference/block-kit/blocks/container-block + + Args: + child_blocks (required): An array of blocks. Maximum 10 blocks. + title: Plain text title. Maximum 150 characters. One of title or rich_text_title is required. + rich_text_title: Rich text title. Takes precedence over title if both are provided. + subtitle: Plain text or markdown subtitle. Maximum 150 characters. + width: Controls sizing. One of "narrow", "standard", "wide", or "full". Defaults to "standard". + icon: An image element displayed alongside the title and subtitle. + is_collapsible: Whether the container can be collapsed. Defaults to false. + default_collapsed: Whether the container is collapsed by default. Requires is_collapsible to be true. + has_header_divider: Whether to show a visible border separating header from content. + 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.title = TextObject.parse(title, default_type=PlainTextObject.type) if title is not None else None + self.rich_text_title = rich_text_title + self.subtitle = TextObject.parse(subtitle, default_type=PlainTextObject.type) if subtitle is not None else None + self.child_blocks = Block.parse_all(child_blocks) + self.width = width + self.icon = BlockElement.parse(icon) if icon is not None else None + self.is_collapsible = is_collapsible + self.default_collapsed = default_collapsed + self.has_header_divider = has_header_divider + + @JsonValidator("title or rich_text_title attribute must be specified") + def _validate_title_or_rich_text_title(self): + return self.title is not None or self.rich_text_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 self.title.text is None or len(self.title.text) <= self.title_max_length + + @JsonValidator(f"subtitle attribute cannot exceed {subtitle_max_length} characters") + def _validate_subtitle_length(self): + return self.subtitle is None or self.subtitle.text is None or len(self.subtitle.text) <= self.subtitle_max_length + + @JsonValidator("child_blocks attribute must be specified") + def _validate_child_blocks_present(self): + return self.child_blocks is not None and len(self.child_blocks) >= 1 + + @JsonValidator(f"child_blocks attribute cannot exceed {child_blocks_max_length} blocks") + def _validate_child_blocks_length(self): + return self.child_blocks is None or len(self.child_blocks) <= self.child_blocks_max_length + + @JsonValidator("width must be a valid value (narrow, standard, wide, full)") + def _validate_width(self): + return self.width is None or self.width in self.valid_widths + + @JsonValidator("has_header_divider cannot be set when is_collapsible is true") + def _validate_header_divider_collapsible(self): + return not (self.is_collapsible is True and self.has_header_divider is True) + + class CarouselBlock(Block): type = "carousel" elements_max_length = 10 diff --git a/tests/slack_sdk/models/test_blocks.py b/tests/slack_sdk/models/test_blocks.py index a7513aecb..6008a9251 100644 --- a/tests/slack_sdk/models/test_blocks.py +++ b/tests/slack_sdk/models/test_blocks.py @@ -10,6 +10,7 @@ CallBlock, CardBlock, CarouselBlock, + ContainerBlock, ContextActionsBlock, ContextBlock, DividerBlock, @@ -1754,6 +1755,135 @@ def test_missing_text(self): AlertBlock(text="").validate_json() +class ContainerBlockTests(unittest.TestCase): + def test_document(self): + input = { + "type": "container", + "title": {"type": "plain_text", "text": "My Container"}, + "child_blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": "Hello"}}, + {"type": "divider"}, + ], + } + self.assertDictEqual(input, ContainerBlock(**input).to_dict()) + + def test_parse(self): + input = { + "type": "container", + "title": {"type": "plain_text", "text": "Parsed"}, + "child_blocks": [ + {"type": "header", "text": {"type": "plain_text", "text": "Header"}}, + ], + } + parsed = Block.parse(input) + self.assertIsNotNone(parsed) + self.assertDictEqual(input, parsed.to_dict()) + + def test_all_properties_collapsible(self): + input = { + "type": "container", + "title": {"type": "plain_text", "text": "Full Container"}, + "subtitle": {"type": "mrkdwn", "text": "A subtitle"}, + "child_blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": "Content"}}, + ], + "width": "wide", + "icon": {"type": "image", "image_url": "https://example.com/img.png", "alt_text": "icon"}, + "is_collapsible": True, + "default_collapsed": True, + "block_id": "container-1", + } + self.assertDictEqual(input, ContainerBlock(**input).to_dict()) + + def test_all_properties_header_divider(self): + input = { + "type": "container", + "title": {"type": "plain_text", "text": "Full Container"}, + "subtitle": {"type": "mrkdwn", "text": "A subtitle"}, + "child_blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": "Content"}}, + ], + "width": "wide", + "icon": {"type": "image", "image_url": "https://example.com/img.png", "alt_text": "icon"}, + "has_header_divider": True, + "block_id": "container-2", + } + self.assertDictEqual(input, ContainerBlock(**input).to_dict()) + + def test_rich_text_title(self): + input = { + "type": "container", + "rich_text_title": { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "Rich Title"}], + } + ], + }, + "child_blocks": [ + {"type": "divider"}, + ], + } + self.assertDictEqual(input, ContainerBlock(**input).to_dict()) + + def test_width_values(self): + for width in ["narrow", "standard", "wide", "full"]: + input = { + "type": "container", + "title": {"type": "plain_text", "text": "Test"}, + "child_blocks": [{"type": "divider"}], + "width": width, + } + ContainerBlock(**input).validate_json() + + def test_invalid_width(self): + with self.assertRaises(SlackObjectFormationError): + ContainerBlock( + title={"type": "plain_text", "text": "Test"}, + child_blocks=[{"type": "divider"}], + width="extra_wide", + ).validate_json() + + def test_title_length_validation(self): + with self.assertRaises(SlackObjectFormationError): + ContainerBlock( + title={"type": "plain_text", "text": "a" * 151}, + child_blocks=[{"type": "divider"}], + ).validate_json() + + def test_subtitle_length_validation(self): + with self.assertRaises(SlackObjectFormationError): + ContainerBlock( + title={"type": "plain_text", "text": "Title"}, + subtitle={"type": "mrkdwn", "text": "a" * 151}, + child_blocks=[{"type": "divider"}], + ).validate_json() + + def test_missing_title_and_rich_text_title(self): + with self.assertRaises(SlackObjectFormationError): + ContainerBlock( + child_blocks=[{"type": "divider"}], + ).validate_json() + + def test_empty_child_blocks(self): + with self.assertRaises(SlackObjectFormationError): + ContainerBlock( + title={"type": "plain_text", "text": "Test"}, + child_blocks=[], + ).validate_json() + + def test_collapsible_with_header_divider(self): + with self.assertRaises(SlackObjectFormationError): + ContainerBlock( + title={"type": "plain_text", "text": "Test"}, + child_blocks=[{"type": "divider"}], + is_collapsible=True, + has_header_divider=True, + ).validate_json() + + class CarouselBlockTests(unittest.TestCase): def test_document(self): input = {