Bladeren bron

rx.table __bool__ regression fix (#1828)

Elijah Ahianyo 1 jaar geleden
bovenliggende
commit
26885d98cf
4 gewijzigde bestanden met toevoegingen van 454 en 6 verwijderingen
  1. 168 0
      integration/test_table.py
  2. 94 6
      reflex/components/datadisplay/table.py
  3. 15 0
      reflex/utils/types.py
  4. 177 0
      tests/components/datadisplay/test_table.py

+ 168 - 0
integration/test_table.py

@@ -0,0 +1,168 @@
+"""Integration tests for table and related components."""
+from typing import Generator
+
+import pytest
+from selenium.webdriver.common.by import By
+
+from reflex.testing import AppHarness
+
+
+def Table():
+    """App using table component."""
+    from typing import List
+
+    import reflex as rx
+
+    class TableState(rx.State):
+        rows: List[List[str]] = [
+            ["John", "30", "New York"],
+            ["Jane", "31", "San Fransisco"],
+            ["Joe", "32", "Los Angeles"],
+        ]
+
+        headers: List[str] = ["Name", "Age", "Location"]
+
+        footers: List[str] = ["footer1", "footer2", "footer3"]
+
+        caption: str = "random caption"
+
+        @rx.var
+        def token(self) -> str:
+            return self.get_token()
+
+    app = rx.App(state=TableState)
+
+    @app.add_page
+    def index():
+        return rx.center(
+            rx.input(id="token", value=TableState.token, is_read_only=True),
+            rx.table_container(
+                rx.table(
+                    headers=TableState.headers,
+                    rows=TableState.rows,
+                    footers=TableState.footers,
+                    caption=TableState.caption,
+                    variant="striped",
+                    color_scheme="blue",
+                    width="100%",
+                ),
+            ),
+        )
+
+    @app.add_page
+    def another():
+        return rx.center(
+            rx.table_container(
+                rx.table(  # type: ignore
+                    rx.thead(  # type: ignore
+                        rx.tr(  # type: ignore
+                            rx.th("Name"),
+                            rx.th("Age"),
+                            rx.th("Location"),
+                        )
+                    ),
+                    rx.tbody(  # type: ignore
+                        rx.tr(  # type: ignore
+                            rx.td("John"),
+                            rx.td(30),
+                            rx.td("New York"),
+                        ),
+                        rx.tr(  # type: ignore
+                            rx.td("Jane"),
+                            rx.td(31),
+                            rx.td("San Francisco"),
+                        ),
+                        rx.tr(  # type: ignore
+                            rx.td("Joe"),
+                            rx.td(32),
+                            rx.td("Los Angeles"),
+                        ),
+                    ),
+                    rx.tfoot(  # type: ignore
+                        rx.tr(rx.td("footer1"), rx.td("footer2"), rx.td("footer3"))  # type: ignore
+                    ),
+                    rx.table_caption("random caption"),
+                    variant="striped",
+                    color_scheme="teal",
+                )
+            )
+        )
+
+    app.compile()
+
+
+@pytest.fixture()
+def table(tmp_path_factory) -> Generator[AppHarness, None, None]:
+    """Start Table app at tmp_path via AppHarness.
+
+    Args:
+        tmp_path_factory: pytest tmp_path_factory fixture
+
+    Yields:
+        running AppHarness instance
+
+    """
+    with AppHarness.create(
+        root=tmp_path_factory.mktemp("table"),
+        app_source=Table,  # type: ignore
+    ) as harness:
+        assert harness.app_instance is not None, "app is not running"
+        yield harness
+
+
+@pytest.fixture
+def driver(table: AppHarness):
+    """GEt an instance of the browser open to the table app.
+
+    Args:
+        table: harness for Table app
+
+    Yields:
+        WebDriver instance.
+    """
+    driver = table.frontend()
+    try:
+        token_input = driver.find_element(By.ID, "token")
+        assert token_input
+        # wait for the backend connection to send the token
+        token = table.poll_for_value(token_input)
+        assert token is not None
+
+        yield driver
+    finally:
+        driver.quit()
+
+
+@pytest.mark.parametrize("route", ["", "/another"])
+def test_table(driver, table: AppHarness, route):
+    """Test that a table component is rendered properly.
+
+    Args:
+        driver: Selenium WebDriver open to the app
+        table: Harness for Table app
+        route: Page route or path.
+    """
+    driver.get(f"{table.frontend_url}/{route}")
+    assert table.app_instance is not None, "app is not running"
+
+    thead = driver.find_element(By.TAG_NAME, "thead")
+    # poll till page is fully loaded.
+    table.poll_for_content(element=thead)
+    # check headers
+    assert thead.find_element(By.TAG_NAME, "tr").text == "NAME AGE LOCATION"
+    # check first row value
+    assert (
+        driver.find_element(By.TAG_NAME, "tbody")
+        .find_elements(By.TAG_NAME, "tr")[0]
+        .text
+        == "John 30 New York"
+    )
+    # check footer
+    assert (
+        driver.find_element(By.TAG_NAME, "tfoot")
+        .find_element(By.TAG_NAME, "tr")
+        .text.lower()
+        == "footer1 footer2 footer3"
+    )
+    # check caption
+    assert driver.find_element(By.TAG_NAME, "caption").text == "random caption"

+ 94 - 6
reflex/components/datadisplay/table.py

@@ -1,9 +1,10 @@
 """Table components."""
-from typing import List
+from typing import List, Tuple
 
 from reflex.components.component import Component
 from reflex.components.layout.foreach import Foreach
 from reflex.components.libs.chakra import ChakraComponent
+from reflex.utils import types
 from reflex.vars import Var
 
 
@@ -44,16 +45,16 @@ class Table(ChakraComponent):
         if len(children) == 0:
             children = []
 
-            if caption:
+            if caption is not None:
                 children.append(TableCaption.create(caption))
 
-            if headers:
+            if headers is not None:
                 children.append(Thead.create(headers=headers))
 
-            if rows:
+            if rows is not None:
                 children.append(Tbody.create(rows=rows))
 
-            if footers:
+            if footers is not None:
                 children.append(Tfoot.create(footers=footers))
         return super().create(*children, **props)
 
@@ -77,11 +78,36 @@ class Thead(ChakraComponent):
 
         Returns:
             The table header component.
+
         """
         if len(children) == 0:
+            cls.validate_headers(headers)
+
             children = [Tr.create(cell_type="header", cells=headers)]
         return super().create(*children, **props)
 
+    @staticmethod
+    def validate_headers(headers):
+        """Type checking for table headers.
+
+        Args:
+            headers: The table headers.
+
+        Raises:
+            TypeError: If headers are not of type list or type tuple.
+
+        """
+        allowed_types = (list, tuple)
+        if (
+            (
+                isinstance(headers, Var)
+                and not types.check_type_in_allowed_types(headers.type_, allowed_types)
+            )
+            or not isinstance(headers, Var)
+            and not types.check_type_in_allowed_types(type(headers), allowed_types)
+        ):
+            raise TypeError("table headers should be a list or tuple")
+
 
 class Tbody(ChakraComponent):
     """A table body component."""
@@ -101,9 +127,11 @@ class Tbody(ChakraComponent):
             **props: The properties of the component.
 
         Returns:
-            Component: _description_
+            Component: The table body component
         """
         if len(children) == 0:
+            cls.validate_rows(rows) if rows is not None else None
+
             if isinstance(rows, Var):
                 children = [
                     Foreach.create(
@@ -116,6 +144,44 @@ class Tbody(ChakraComponent):
                 ]
         return super().create(*children, **props)
 
+    @staticmethod
+    def validate_rows(rows):
+        """Type checking for table rows.
+
+        Args:
+            rows: Table rows.
+
+        Raises:
+            TypeError: If rows are not lists or tuples containing inner lists or tuples.
+        """
+        allowed_subclasses = (List, Tuple)
+        if isinstance(rows, Var):
+            outer_type = rows.type_
+            inner_type = (
+                outer_type.__args__[0] if hasattr(outer_type, "__args__") else None
+            )
+
+            # check that the outer container and inner container types are lists or tuples.
+            if not (
+                types._issubclass(types.get_base_class(outer_type), allowed_subclasses)
+                and (
+                    inner_type is None
+                    or types._issubclass(
+                        types.get_base_class(inner_type), allowed_subclasses
+                    )
+                )
+            ):
+                raise TypeError(
+                    f"table rows should be a list or tuple containing inner lists or tuples. Got {outer_type} instead"
+                )
+        elif not (
+            types._issubclass(type(rows), allowed_subclasses)
+            and (not rows or types._issubclass(type(rows[0]), allowed_subclasses))
+        ):
+            raise TypeError(
+                "table rows should be a list or tuple containing inner lists or tuples."
+            )
+
 
 class Tfoot(ChakraComponent):
     """A table footer component."""
@@ -138,9 +204,31 @@ class Tfoot(ChakraComponent):
             The table footer component.
         """
         if len(children) == 0:
+            cls.validate_footers(footers)
             children = [Tr.create(cell_type="header", cells=footers)]
         return super().create(*children, **props)
 
+    @staticmethod
+    def validate_footers(footers):
+        """Type checking for table footers.
+
+        Args:
+            footers: Table rows.
+
+        Raises:
+            TypeError: If footers are not of type list.
+        """
+        allowed_types = (list, tuple)
+        if (
+            (
+                isinstance(footers, Var)
+                and not types.check_type_in_allowed_types(footers.type_, allowed_types)
+            )
+            or not isinstance(footers, Var)
+            and not types.check_type_in_allowed_types(type(footers), allowed_types)
+        ):
+            raise TypeError("table headers should be a list or tuple")
+
 
 class Tr(ChakraComponent):
     """A table row component."""

+ 15 - 0
reflex/utils/types.py

@@ -170,6 +170,21 @@ def is_backend_variable(name: str) -> bool:
     return name.startswith("_") and not name.startswith("__")
 
 
+def check_type_in_allowed_types(
+    value_type: Type, allowed_types: typing.Iterable
+) -> bool:
+    """Check that a value type is found in a list of allowed types.
+
+    Args:
+        value_type: Type of value.
+        allowed_types: Iterable of allowed types.
+
+    Returns:
+        If the type is found in the allowed types.
+    """
+    return get_base_class(value_type) in allowed_types
+
+
 # Store this here for performance.
 StateBases = get_base_class(StateVar)
 StateIterBases = get_base_class(StateIterVar)

+ 177 - 0
tests/components/datadisplay/test_table.py

@@ -0,0 +1,177 @@
+import sys
+from typing import List, Tuple
+
+import pytest
+
+from reflex.components.datadisplay.table import Tbody, Tfoot, Thead
+from reflex.state import State
+
+PYTHON_GT_V38 = sys.version_info.major >= 3 and sys.version_info.minor > 8
+
+
+class TableState(State):
+    """Test State class."""
+
+    rows_List_List_str: List[List[str]] = [["random", "row"]]
+    rows_List_List: List[List] = [["random", "row"]]
+    rows_List_str: List[str] = ["random", "row"]
+    rows_Tuple_List_str: Tuple[List[str]] = (["random", "row"],)
+    rows_Tuple_List: Tuple[List] = ["random", "row"]  # type: ignore
+    rows_Tuple_str_str: Tuple[str, str] = (
+        "random",
+        "row",
+    )
+    rows_Tuple_Tuple_str_str: Tuple[Tuple[str, str]] = (
+        (
+            "random",
+            "row",
+        ),
+    )
+    rows_Tuple_Tuple: Tuple[Tuple] = (
+        (
+            "random",
+            "row",
+        ),
+    )
+    rows_str: str = "random, row"
+    headers_List_str: List[str] = ["header1", "header2"]
+    headers_Tuple_str_str: Tuple[str, str] = (
+        "header1",
+        "header2",
+    )
+    headers_str: str = "headers1, headers2"
+    footers_List_str: List[str] = ["footer1", "footer2"]
+    footers_Tuple_str_str: Tuple[str, str] = (
+        "footer1",
+        "footer2",
+    )
+    footers_str: str = "footer1, footer2"
+
+    if sys.version_info.major >= 3 and sys.version_info.minor > 8:
+        rows_list_list_str: list[list[str]] = [["random", "row"]]
+        rows_list_list: list[list] = [["random", "row"]]
+        rows_list_str: list[str] = ["random", "row"]
+        rows_tuple_list_str: tuple[list[str]] = (["random", "row"],)
+        rows_tuple_list: tuple[list] = ["random", "row"]  # type: ignore
+        rows_tuple_str_str: tuple[str, str] = (
+            "random",
+            "row",
+        )
+        rows_tuple_tuple_str_str: tuple[tuple[str, str]] = (
+            (
+                "random",
+                "row",
+            ),
+        )
+        rows_tuple_tuple: tuple[tuple] = (
+            (
+                "random",
+                "row",
+            ),
+        )
+
+
+valid_extras = (
+    [
+        TableState.rows_list_list_str,
+        TableState.rows_list_list,
+        TableState.rows_tuple_list_str,
+        TableState.rows_tuple_list,
+        TableState.rows_tuple_tuple_str_str,
+        TableState.rows_tuple_tuple,
+    ]
+    if PYTHON_GT_V38
+    else []
+)
+invalid_extras = (
+    [TableState.rows_list_str, TableState.rows_tuple_str_str] if PYTHON_GT_V38 else []
+)
+
+
+@pytest.mark.parametrize(
+    "rows",
+    [
+        [["random", "row"]],
+        TableState.rows_List_List_str,
+        TableState.rows_List_List,
+        TableState.rows_Tuple_List_str,
+        TableState.rows_Tuple_List,
+        TableState.rows_Tuple_Tuple_str_str,
+        TableState.rows_Tuple_Tuple,
+        *valid_extras,
+    ],
+)
+def test_create_table_body_with_valid_rows_prop(rows):
+    render_dict = Tbody.create(rows=rows).render()
+    assert render_dict["name"] == "Tbody"
+    assert len(render_dict["children"]) == 1
+
+
+@pytest.mark.parametrize(
+    "rows",
+    [
+        ["random", "row"],
+        "random, rows",
+        TableState.rows_List_str,
+        TableState.rows_Tuple_str_str,
+        TableState.rows_str,
+        *invalid_extras,
+    ],
+)
+def test_create_table_body_with_invalid_rows_prop(rows):
+    with pytest.raises(TypeError):
+        Tbody.create(rows=rows)
+
+
+@pytest.mark.parametrize(
+    "headers",
+    [
+        ["random", "header"],
+        TableState.headers_List_str,
+        TableState.headers_Tuple_str_str,
+    ],
+)
+def test_create_table_head_with_valid_headers_prop(headers):
+    render_dict = Thead.create(headers=headers).render()
+    assert render_dict["name"] == "Thead"
+    assert len(render_dict["children"]) == 1
+    assert render_dict["children"][0]["name"] == "Tr"
+
+
+@pytest.mark.parametrize(
+    "headers",
+    [
+        "random, header",
+        TableState.headers_str,
+    ],
+)
+def test_create_table_head_with_invalid_headers_prop(headers):
+    with pytest.raises(TypeError):
+        Thead.create(headers=headers)
+
+
+@pytest.mark.parametrize(
+    "footers",
+    [
+        ["random", "footers"],
+        TableState.footers_List_str,
+        TableState.footers_Tuple_str_str,
+    ],
+)
+def test_create_table_footer_with_valid_footers_prop(footers):
+    render_dict = Tfoot.create(footers=footers).render()
+    assert render_dict["name"] == "Tfoot"
+    assert len(render_dict["children"]) == 1
+    assert render_dict["children"][0]["name"] == "Tr"
+
+
+@pytest.mark.parametrize(
+    "footers",
+    [
+        "random, footers",
+        TableState.footers_str,
+    ],
+)
+def test_create_table_footer_with_invalid_footers_prop(footers):
+    with pytest.raises(TypeError):
+        Tfoot.create(footers=footers)