From 67f09e29345968a79f26863f5333f9ad6980578d Mon Sep 17 00:00:00 2001 From: jorrick Date: Thu, 25 Sep 2025 15:37:06 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Add=20table=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blockkit/core.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/blockkit/core.py b/blockkit/core.py index 99b2dda..bdc1083 100644 --- a/blockkit/core.py +++ b/blockkit/core.py @@ -2235,6 +2235,32 @@ def add_element(self, element: ActionElement) -> Self: return self._add_field_value("elements", element) # type: ignore[attr-defined] +class ColumnSettings: + """ + 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/ + """ + def __init__( + self, + align: Literal["left", "center", "right"] | None = None, + is_wrapped: bool | None = None, + ): + self.align = align + self.is_wrapped = is_wrapped + + def build(self) -> dict[str, Any]: + obj: dict[str, Any] = {} + if self.align is not None: + obj["align"] = str(self.align) + if self.is_wrapped is not None: + obj["is_wrapped"] = self.is_wrapped + return obj + + ContextElement: TypeAlias = ImageEl | Text @@ -2986,6 +3012,58 @@ def expand(self, expand: bool | None = True) -> Self: return self._add_field("expand", expand, validators=[Typed(bool)]) +class Table(Component, BlockIdMixin): + """ + Table block + + Displays structured information in a table. + WARNING: There is a maximum of 100 rows & 20 cells per row. + + Slack docs: + https://docs.slack.dev/reference/block-kit/blocks/table-block + """ + + def __init__( + self, + rows: list[list[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[RichText]) -> Self: + filtered_rows = [] + for row in rows[:100]: + filtered_rows.append(row[:20]) + return self._add_field("rows", filtered_rows, validators=[Typed(list)]) + + def column_settings(self, *column_settings: ColumnSettings) -> Self: + return self._add_field("column_settings", column_settings, validators=[Typed(ColumnSettings)]) + + def validate(self) -> None: + super().validate() # type: ignore[no-untyped-call] + + expected_row_length: int | None = None + rows = self._fields["rows"].value + column_settings = self._fields["column_settings"].value + for row_index, row in enumerate(rows): + if expected_row_length is None: + expected_row_length = len(row) + + if expected_row_length != len(row): + raise ValueError(f"Row with {row_index=} has {len(row)=} items where {expected_row_length=}.") + if column_settings and len(column_settings) != len(rows): + raise ValueError(f"Column settings has {len(column_settings)=} where we have {len(rows)=}.") + + def build(self) -> dict[str, Any]: + obj: dict[str, Any] = super().build() # type: ignore[no-untyped-call] + obj["rows"] = [[obj.build() for obj in row] for row in obj["rows"]] + return obj + + class Video(Component, BlockIdMixin): """ Video block @@ -3088,6 +3166,7 @@ def author_name(self, author_name: str | None) -> Self: | Markdown | RichText | Section + | Table | Video ) From d9a2aee6cf3a924e400ca949ece6978d43f7ed83 Mon Sep 17 00:00:00 2001 From: Dima Date: Sat, 27 Sep 2025 17:44:21 +0200 Subject: [PATCH 2/2] refine Table block implementation --- blockkit/core.py | 137 ++++++++++++++++++++++++++------------------- tests/test_core.py | 130 ++++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 3 files changed, 210 insertions(+), 59 deletions(-) diff --git a/blockkit/core.py b/blockkit/core.py index bdc1083..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 @@ -2235,32 +2260,6 @@ def add_element(self, element: ActionElement) -> Self: return self._add_field_value("elements", element) # type: ignore[attr-defined] -class ColumnSettings: - """ - 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/ - """ - def __init__( - self, - align: Literal["left", "center", "right"] | None = None, - is_wrapped: bool | None = None, - ): - self.align = align - self.is_wrapped = is_wrapped - - def build(self) -> dict[str, Any]: - obj: dict[str, Any] = {} - if self.align is not None: - obj["align"] = str(self.align) - if self.is_wrapped is not None: - obj["is_wrapped"] = self.is_wrapped - return obj - - ContextElement: TypeAlias = ImageEl | Text @@ -3012,12 +3011,45 @@ 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. - WARNING: There is a maximum of 100 rows & 20 cells per row. Slack docs: https://docs.slack.dev/reference/block-kit/blocks/table-block @@ -3025,7 +3057,7 @@ class Table(Component, BlockIdMixin): def __init__( self, - rows: list[list[RichText]] | None = None, + rows: list[list[RawText | RichText]] | None = None, column_settings: list[ColumnSettings] | None = None, block_id: str | None = None, ): @@ -3034,34 +3066,23 @@ def __init__( self.column_settings(*column_settings or ()) self.block_id(block_id) - def rows(self, *rows: list[RichText]) -> Self: - filtered_rows = [] - for row in rows[:100]: - filtered_rows.append(row[:20]) - return self._add_field("rows", filtered_rows, validators=[Typed(list)]) + 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", column_settings, validators=[Typed(ColumnSettings)]) - - def validate(self) -> None: - super().validate() # type: ignore[no-untyped-call] - - expected_row_length: int | None = None - rows = self._fields["rows"].value - column_settings = self._fields["column_settings"].value - for row_index, row in enumerate(rows): - if expected_row_length is None: - expected_row_length = len(row) - - if expected_row_length != len(row): - raise ValueError(f"Row with {row_index=} has {len(row)=} items where {expected_row_length=}.") - if column_settings and len(column_settings) != len(rows): - raise ValueError(f"Column settings has {len(column_settings)=} where we have {len(rows)=}.") - - def build(self) -> dict[str, Any]: - obj: dict[str, Any] = super().build() # type: ignore[no-untyped-call] - obj["rows"] = [[obj.build() for obj in row] for row in obj["rows"]] - return obj + 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): 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]]