diff --git a/blockkit/core.py b/blockkit/core.py index 99b2dda..de9432b 100644 --- a/blockkit/core.py +++ b/blockkit/core.py @@ -139,6 +139,7 @@ def validate(self, field_name: str, value: Any) -> None: ) +# TODO: add support for checking generic types like Typed(list[RawText | RichText]) class Typed(FieldValidator): def __init__(self, *types: type): if not types: @@ -449,10 +450,19 @@ def build(self): if hasattr(field.value, "build"): obj[field.name] = field.value.build() if isinstance(field.value, list | tuple | set): - obj[field.name] = [ - item.build() if hasattr(item, "build") else item - for item in field.value - ] + items = [] + for item in field.value: + if isinstance(item, list | tuple | set): + nested_items = [ + nested.build() if hasattr(nested, "build") else nested + for nested in item + ] + items.append(nested_items) + else: + items.append(item.build() if hasattr(item, "build") else item) + + obj[field.name] = items + return obj @@ -796,6 +806,21 @@ def type(self, type: Literal["plain_text", "mrkdwn"]) -> Self: ) +class RawText(Component): + """ + Raw text object + """ + + def __init__(self, text: str = None): + super().__init__("raw_text") + self.text(text) + + def text(self, text: str | None) -> Self: + return self._add_field( + "text", text, validators=[Typed(str), Required(), Length(1, 10000)] + ) + + class Confirm(Component, StyleMixin): """ Confirmation dialog @@ -2986,6 +3011,80 @@ def expand(self, expand: bool | None = True) -> Self: return self._add_field("expand", expand, validators=[Typed(bool)]) +class ColumnSettings(Component): + """ + Column settings + + Lets you change text alignment and text wrapping behavior for table columns + + Slack docs: + https://docs.slack.dev/reference/block-kit/blocks/table-block/ + """ + + LEFT: Final[Literal["left"]] = "left" + CENTER: Final[Literal["center"]] = "center" + RIGHT: Final[Literal["right"]] = "right" + + def __init__( + self, + align: Literal["left", "center", "right"] | None = None, + is_wrapped: bool | None = None, + ): + super().__init__() + self.align(align) + self.is_wrapped(is_wrapped) + + def align(self, align: Literal["left", "center", "right"] | None) -> Self: + return self._add_field( + "align", + align, + validators=[Typed(str), Strings(self.LEFT, self.CENTER, self.RIGHT)], + ) + + def is_wrapped(self, is_wrapped: bool = True) -> Self: + return self._add_field("is_wrapped", is_wrapped, validators=[Typed(bool)]) + + +class Table(Component, BlockIdMixin): + """ + Table block + + Displays structured information in a table. + + Slack docs: + https://docs.slack.dev/reference/block-kit/blocks/table-block + """ + + def __init__( + self, + rows: list[list[RawText | RichText]] | None = None, + column_settings: list[ColumnSettings] | None = None, + block_id: str | None = None, + ): + super().__init__("table") + self.rows(*rows or ()) + self.column_settings(*column_settings or ()) + self.block_id(block_id) + + def rows(self, *rows: list[RawText | RichText]) -> Self: + return self._add_field( + "rows", + list(rows), + validators=[Typed(list), Required(), Length(1, 100)], + ) + + def add_row(self, *row: list[RawText | RichText]) -> Self: + return self._add_field_value("rows", list(row)) + + def column_settings(self, *column_settings: ColumnSettings) -> Self: + return self._add_field( + "column_settings", list(column_settings), validators=[Typed(ColumnSettings)] + ) + + def add_column_setting(self, column_setting: ColumnSettings) -> Self: + return self._add_field_value("column_settings", column_setting) + + class Video(Component, BlockIdMixin): """ Video block @@ -3088,6 +3187,7 @@ def author_name(self, author_name: str | None) -> Self: | Markdown | RichText | Section + | Table | Video ) diff --git a/tests/test_core.py b/tests/test_core.py index 6277bb1..6531a79 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,3 +1,5 @@ +from blockkit.core import ColumnSettings +from blockkit.core import RawText from datetime import date, datetime, time from zoneinfo import ZoneInfo @@ -27,6 +29,7 @@ Image, ImageEl, Input, + Table, InputParameter, Markdown, Message, @@ -3290,6 +3293,133 @@ def test_builds_fields(self): assert got == want +class TestTable: + def test_builds(self): + want = { + "type": "table", + "column_settings": [ + {"is_wrapped": True}, + {"align": "right"}, + ], + "rows": [ + [ + {"type": "raw_text", "text": "Header A"}, + {"type": "raw_text", "text": "Header B"}, + ], + [ + {"type": "raw_text", "text": "Data 1A"}, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "text": "Data 1B", + "type": "link", + "url": "https://slack.com", + } + ], + } + ], + }, + ], + [ + {"type": "raw_text", "text": "Data 2A"}, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "text": "Data 2B", + "type": "link", + "url": "https://slack.com", + } + ], + } + ], + }, + ], + ], + } + + got = Table( + column_settings=[ + ColumnSettings(is_wrapped=True), + ColumnSettings(align=ColumnSettings.RIGHT), + ], + rows=[ + [ + RawText(text="Header A"), + RawText(text="Header B"), + ], + [ + RawText(text="Data 1A"), + RichText( + elements=[ + RichTextSection( + elements=[ + RichLinkEl( + text="Data 1B", + url="https://slack.com", + ) + ] + ) + ] + ), + ], + [ + {"type": "raw_text", "text": "Data 2A"}, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "text": "Data 2B", + "type": "link", + "url": "https://slack.com", + } + ], + } + ], + }, + ], + ], + ).build() + assert got == want + + got = ( + Table() + .add_column_setting(ColumnSettings(is_wrapped=True)) + .add_column_setting(ColumnSettings(align=ColumnSettings.RIGHT)) + .add_row( + RawText().text("Header A"), + RawText().text("Header B"), + ) + .add_row( + RawText().text("Data 1A"), + RichText().add_element( + RichTextSection().add_element( + RichLinkEl().text("Data 1B").url("https://slack.com") + ) + ), + ) + .add_row( + RawText().text("Data 2A"), + RichText().add_element( + RichTextSection().add_element( + RichLinkEl().text("Data 2B").url("https://slack.com") + ) + ), + ) + ).build() + assert got == want + + class TestVideo: def test_builds(self): want = { diff --git a/uv.lock b/uv.lock index da4cf09..fa1589c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" [[package]]