Skip to content

Commit 73d255a

Browse files
srtaalejzimeg
andauthored
feat(blocks): add Card, Carousel, and Alert block types (#1865)
Co-authored-by: Eden Zimbelman <eden.zimbelman@salesforce.com>
1 parent 60bd43d commit 73d255a

3 files changed

Lines changed: 311 additions & 0 deletions

File tree

slack_sdk/models/blocks/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,11 @@
6161
)
6262
from .blocks import (
6363
ActionsBlock,
64+
AlertBlock,
6465
Block,
6566
CallBlock,
67+
CardBlock,
68+
CarouselBlock,
6669
ContextActionsBlock,
6770
ContextBlock,
6871
DividerBlock,
@@ -129,8 +132,11 @@
129132
"RichTextQuoteElement",
130133
"RichTextSectionElement",
131134
"ActionsBlock",
135+
"AlertBlock",
132136
"Block",
133137
"CallBlock",
138+
"CardBlock",
139+
"CarouselBlock",
134140
"ContextActionsBlock",
135141
"ContextBlock",
136142
"DividerBlock",

slack_sdk/models/blocks/blocks.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ def parse(cls, block: Union[dict, "Block"]) -> Optional["Block"]:
102102
return TaskCardBlock(**block)
103103
elif type == PlanBlock.type:
104104
return PlanBlock(**block)
105+
elif type == CardBlock.type:
106+
return CardBlock(**block)
107+
elif type == AlertBlock.type:
108+
return AlertBlock(**block)
109+
elif type == CarouselBlock.type:
110+
return CarouselBlock(**block)
105111
else:
106112
cls.logger.warning(f"Unknown block detected and skipped ({block})")
107113
return None
@@ -878,3 +884,149 @@ def __init__(
878884

879885
self.title = title
880886
self.tasks = tasks
887+
888+
889+
class AlertBlock(Block):
890+
type = "alert"
891+
valid_levels = {"default", "info", "warning", "error", "success"}
892+
893+
@property
894+
def attributes(self) -> Set[str]: # type: ignore[override]
895+
return super().attributes.union({"text", "level"})
896+
897+
def __init__(
898+
self,
899+
*,
900+
text: Union[str, dict, TextObject],
901+
level: Optional[str] = None,
902+
block_id: Optional[str] = None,
903+
**others: dict,
904+
):
905+
"""Displays alerts, warnings, and informational messages.
906+
https://docs.slack.dev/reference/block-kit/blocks/alert-block
907+
908+
Args:
909+
text (required): The alert message, using plain_text or mrkdwn formatting.
910+
level: One of "default", "info", "warning", "error", or "success".
911+
Will be "default" if omitted.
912+
block_id: A unique identifier for a block. If not specified, a block_id will be generated.
913+
"""
914+
super().__init__(type=self.type, block_id=block_id)
915+
show_unknown_key_warning(self, others)
916+
917+
self.text = TextObject.parse(text)
918+
self.level = level
919+
920+
@JsonValidator("text attribute must be specified")
921+
def _validate_text(self):
922+
return self.text is not None
923+
924+
@JsonValidator("level must be a valid value (default, info, warning, error, success)")
925+
def _validate_level(self):
926+
return self.level is None or self.level in self.valid_levels
927+
928+
929+
class CardBlock(Block):
930+
type = "card"
931+
title_max_length = 150
932+
subtitle_max_length = 150
933+
body_max_length = 200
934+
935+
@property
936+
def attributes(self) -> Set[str]: # type: ignore[override]
937+
return super().attributes.union(
938+
{
939+
"hero_image",
940+
"icon",
941+
"title",
942+
"subtitle",
943+
"body",
944+
"actions",
945+
}
946+
)
947+
948+
def __init__(
949+
self,
950+
*,
951+
block_id: Optional[str] = None,
952+
hero_image: Optional[str] = None,
953+
icon: Optional[str] = None,
954+
title: Optional[Union[str, dict, TextObject]] = None,
955+
subtitle: Optional[Union[str, dict, TextObject]] = None,
956+
body: Optional[Union[str, dict, TextObject]] = None,
957+
actions: Optional[Sequence[Union[dict, BlockElement]]] = None,
958+
**others: dict,
959+
):
960+
"""Displays content in a card.
961+
https://docs.slack.dev/reference/block-kit/blocks/card-block
962+
963+
Args:
964+
block_id: A unique identifier for a block. If not specified, a block_id will be generated.
965+
hero_image: Link to the top image used on the card.
966+
icon: Link to the small image used next to the card's title and subtitle.
967+
title: Title of the card. 150 characters max.
968+
subtitle: Subtitle of the card. 150 characters max.
969+
body: Content of the card. 200 characters max.
970+
actions: Action buttons shown at the bottom of the card.
971+
"""
972+
super().__init__(type=self.type, block_id=block_id)
973+
show_unknown_key_warning(self, others)
974+
975+
self.hero_image = hero_image
976+
self.icon = icon
977+
self.title = TextObject.parse(title, default_type=MarkdownTextObject.type) # type: ignore[arg-type]
978+
self.subtitle = TextObject.parse(subtitle, default_type=MarkdownTextObject.type) # type: ignore[arg-type]
979+
self.body = TextObject.parse(body, default_type=MarkdownTextObject.type) # type: ignore[arg-type]
980+
self.actions = BlockElement.parse_all(actions) if actions else None
981+
982+
@JsonValidator("At least one of hero_image, title, actions, or body is required")
983+
def _validate_content(self):
984+
return self.hero_image is not None or self.title is not None or self.actions is not None or self.body is not None
985+
986+
@JsonValidator(f"title attribute cannot exceed {title_max_length} characters")
987+
def _validate_title_length(self):
988+
return self.title is None or self.title.text is None or len(self.title.text) <= self.title_max_length
989+
990+
@JsonValidator(f"subtitle attribute cannot exceed {subtitle_max_length} characters")
991+
def _validate_subtitle_length(self):
992+
return self.subtitle is None or self.subtitle.text is None or len(self.subtitle.text) <= self.subtitle_max_length
993+
994+
@JsonValidator(f"body attribute cannot exceed {body_max_length} characters")
995+
def _validate_body_length(self):
996+
return self.body is None or self.body.text is None or len(self.body.text) <= self.body_max_length
997+
998+
999+
class CarouselBlock(Block):
1000+
type = "carousel"
1001+
elements_max_length = 10
1002+
1003+
@property
1004+
def attributes(self) -> Set[str]: # type: ignore[override]
1005+
return super().attributes.union({"elements"})
1006+
1007+
def __init__(
1008+
self,
1009+
*,
1010+
elements: Sequence[Union[dict, CardBlock]],
1011+
block_id: Optional[str] = None,
1012+
**others: dict,
1013+
):
1014+
"""Displays related card blocks in a horizontally-scrolling container.
1015+
https://docs.slack.dev/reference/block-kit/blocks/carousel-block
1016+
1017+
Args:
1018+
elements (required): A list of cards. Minimum 1, maximum 10 cards.
1019+
block_id: A unique identifier for a block. If not specified, a block_id will be generated.
1020+
"""
1021+
super().__init__(type=self.type, block_id=block_id)
1022+
show_unknown_key_warning(self, others)
1023+
1024+
self.elements = Block.parse_all(elements)
1025+
1026+
@JsonValidator("elements attribute must contain at least 1 card")
1027+
def _validate_elements_present(self):
1028+
return self.elements is not None and len(self.elements) >= 1
1029+
1030+
@JsonValidator(f"elements attribute cannot exceed {elements_max_length} cards")
1031+
def _validate_elements_length(self):
1032+
return self.elements is None or len(self.elements) <= self.elements_max_length

tests/slack_sdk/models/test_blocks.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
from slack_sdk.errors import SlackObjectFormationError
55
from slack_sdk.models.blocks import (
66
ActionsBlock,
7+
AlertBlock,
78
Block,
89
ButtonElement,
910
CallBlock,
11+
CardBlock,
12+
CarouselBlock,
1013
ContextActionsBlock,
1114
ContextBlock,
1215
DividerBlock,
@@ -1551,3 +1554,153 @@ def test_with_raw_text_object_helper(self):
15511554
],
15521555
}
15531556
self.assertDictEqual(expected, block.to_dict())
1557+
1558+
1559+
class CardBlockTests(unittest.TestCase):
1560+
def test_document(self):
1561+
input = {
1562+
"type": "card",
1563+
"icon": "https://picsum.photos/36/36",
1564+
"title": {"type": "mrkdwn", "text": "Lumon Industries", "verbatim": False},
1565+
"subtitle": {"type": "mrkdwn", "text": "Committed to work-life balance", "verbatim": False},
1566+
"hero_image": "https://picsum.photos/400/300",
1567+
"body": {"type": "mrkdwn", "text": "Please enjoy each card equally.", "verbatim": False},
1568+
"actions": [
1569+
{
1570+
"type": "button",
1571+
"text": {"type": "plain_text", "text": "Action Button", "emoji": False},
1572+
"action_id": "button_action",
1573+
}
1574+
],
1575+
}
1576+
self.assertDictEqual(input, CardBlock(**input).to_dict())
1577+
1578+
def test_parse(self):
1579+
input = {
1580+
"type": "card",
1581+
"title": {"type": "mrkdwn", "text": "Title"},
1582+
"body": {"type": "mrkdwn", "text": "Body text"},
1583+
}
1584+
parsed = Block.parse(input)
1585+
self.assertIsNotNone(parsed)
1586+
self.assertDictEqual(input, parsed.to_dict())
1587+
1588+
def test_minimal_with_title(self):
1589+
input = {
1590+
"type": "card",
1591+
"title": {"type": "mrkdwn", "text": "Just a title"},
1592+
}
1593+
self.assertDictEqual(input, CardBlock(**input).to_dict())
1594+
1595+
def test_minimal_with_body(self):
1596+
input = {
1597+
"type": "card",
1598+
"body": {"type": "mrkdwn", "text": "Just body text"},
1599+
}
1600+
self.assertDictEqual(input, CardBlock(**input).to_dict())
1601+
1602+
def test_validation_at_least_one_field(self):
1603+
with self.assertRaises(SlackObjectFormationError):
1604+
CardBlock().validate_json()
1605+
1606+
def test_title_length_validation(self):
1607+
with self.assertRaises(SlackObjectFormationError):
1608+
CardBlock(title={"type": "mrkdwn", "text": "a" * 151}).validate_json()
1609+
1610+
def test_subtitle_length_validation(self):
1611+
with self.assertRaises(SlackObjectFormationError):
1612+
CardBlock(
1613+
title={"type": "mrkdwn", "text": "Title"},
1614+
subtitle={"type": "mrkdwn", "text": "a" * 151},
1615+
).validate_json()
1616+
1617+
def test_body_length_validation(self):
1618+
with self.assertRaises(SlackObjectFormationError):
1619+
CardBlock(body={"type": "mrkdwn", "text": "a" * 201}).validate_json()
1620+
1621+
1622+
class AlertBlockTests(unittest.TestCase):
1623+
def test_document(self):
1624+
input = {
1625+
"type": "alert",
1626+
"text": {"type": "mrkdwn", "text": "The work is mysterious and important.", "verbatim": False},
1627+
"level": "info",
1628+
}
1629+
self.assertDictEqual(input, AlertBlock(**input).to_dict())
1630+
1631+
def test_parse(self):
1632+
input = {
1633+
"type": "alert",
1634+
"text": {"type": "mrkdwn", "text": "Notice"},
1635+
"level": "warning",
1636+
}
1637+
parsed = Block.parse(input)
1638+
self.assertIsNotNone(parsed)
1639+
self.assertDictEqual(input, parsed.to_dict())
1640+
1641+
def test_minimal(self):
1642+
input = {
1643+
"type": "alert",
1644+
"text": {"type": "plain_text", "text": "Simple alert"},
1645+
}
1646+
self.assertDictEqual(input, AlertBlock(**input).to_dict())
1647+
1648+
def test_all_levels(self):
1649+
for level in ["default", "info", "warning", "error", "success"]:
1650+
input = {
1651+
"type": "alert",
1652+
"text": {"type": "plain_text", "text": "Test"},
1653+
"level": level,
1654+
}
1655+
AlertBlock(**input).validate_json()
1656+
1657+
def test_invalid_level(self):
1658+
with self.assertRaises(SlackObjectFormationError):
1659+
AlertBlock(text={"type": "plain_text", "text": "Test"}, level="critical").validate_json()
1660+
1661+
def test_missing_text(self):
1662+
with self.assertRaises(SlackObjectFormationError):
1663+
AlertBlock(text="").validate_json()
1664+
1665+
1666+
class CarouselBlockTests(unittest.TestCase):
1667+
def test_document(self):
1668+
input = {
1669+
"type": "carousel",
1670+
"elements": [
1671+
{
1672+
"type": "card",
1673+
"title": {"type": "mrkdwn", "text": "Card 1"},
1674+
},
1675+
{
1676+
"type": "card",
1677+
"title": {"type": "mrkdwn", "text": "Card 2"},
1678+
"body": {"type": "mrkdwn", "text": "Some body text"},
1679+
},
1680+
],
1681+
}
1682+
self.assertDictEqual(input, CarouselBlock(**input).to_dict())
1683+
1684+
def test_parse(self):
1685+
input = {
1686+
"type": "carousel",
1687+
"elements": [
1688+
{"type": "card", "title": {"type": "mrkdwn", "text": "Card 1"}},
1689+
],
1690+
}
1691+
parsed = Block.parse(input)
1692+
self.assertIsNotNone(parsed)
1693+
self.assertDictEqual(input, parsed.to_dict())
1694+
1695+
def test_single_card(self):
1696+
input = {
1697+
"type": "carousel",
1698+
"elements": [
1699+
{"type": "card", "title": {"type": "mrkdwn", "text": "Only card"}},
1700+
],
1701+
}
1702+
self.assertDictEqual(input, CarouselBlock(**input).to_dict())
1703+
1704+
def test_empty_elements_validation(self):
1705+
with self.assertRaises(SlackObjectFormationError):
1706+
CarouselBlock(elements=[]).validate_json()

0 commit comments

Comments
 (0)