Repository: RenaudLN/dash-pydantic-form

Issue:
Nested Model in Union gives AttributeError: model_fields 
Apologies if this is the wrong way to communicate but I am trying get an example working that matches the pydantic models I am trying to render in a dash app.

I have taken the example in your Discriminate Unions doc section and extended it by creating an Office class which is then used as the type for an additional attribute called 'options' in HomeOffice. I have done this only because I am stuck with multiple pydantic models that have this structure. I have noticed that if HomeOffice instead inherits Office then it works.

But is there any way of getting the pydantic model structure below to work with dash-pydantic-form ??

![image](https://github.com/RenaudLN/dash-pydantic-form/assets/25059495/35d45280-4352-429d-a85b-e8b2409e3eee)
![image](https://github.com/RenaudLN/dash-pydantic-form/assets/25059495/325535f8-aee3-4dc4-bd28-1c6aa3464cad)
![image](https://github.com/RenaudLN/dash-pydantic-form/assets/25059495/a244876c-59c8-432a-a9ad-ec9c37650556)




Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4922739..36c44e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
+## [0.3.3] - 2024-07-13
+### Fixed
+- Discriminated unions can now have nested models within them
+- Discriminated unions can now be part of nested models
+
+### Changed
+- Checkbox and switch now have a default of False rather than None
+- Changed warning to debug log about ignored args
 
 ## [0.3.2] - 2024-07-05
 ### Fixed
diff --git a/dash_pydantic_form/fields/base_fields.py b/dash_pydantic_form/fields/base_fields.py
index a41a536..ee91ddf 100644
--- a/dash_pydantic_form/fields/base_fields.py
+++ b/dash_pydantic_form/fields/base_fields.py
@@ -122,7 +122,7 @@ class BaseField(BaseModel):
             ignored_kwargs = set(self.input_kwargs) - set(valid_input_kwargs)
             self.input_kwargs = valid_input_kwargs
             if ignored_kwargs:
-                logging.warning("The following kwargs were ignored for %s: %s", self.__class__.__name__, ignored_kwargs)
+                logging.debug("The following kwargs were ignored for %s: %s", self.__class__.__name__, ignored_kwargs)
         if self.field_id_meta is None:
             self.field_id_meta = ""
 
@@ -488,8 +488,16 @@ class CheckboxField(BaseField):
 
     base_component = dmc.Checkbox
 
+    @classmethod
+    def get_value(cls, item: BaseModel, field: str, parent: str) -> Any:
+        """Handle the fact dmc.TimeInput uses datetime rather than plain time."""
+        value = super().get_value(item, field, parent)
+        if value is None:
+            value = False
+        return value
+
 
-class SwitchField(BaseField):
+class SwitchField(CheckboxField):
     """Switch field."""
 
     base_component = dmc.Switch
diff --git a/dash_pydantic_form/fields/list_field.py b/dash_pydantic_form/fields/list_field.py
index 30ce3f2..09ad034 100644
--- a/dash_pydantic_form/fields/list_field.py
+++ b/dash_pydantic_form/fields/list_field.py
@@ -306,7 +306,7 @@ class ListField(BaseField):
         """Create an item for a scalar list."""
         from dash_pydantic_form.fields import get_default_repr
 
-        scalar_cls = get_subitem_cls(item.__class__, get_fullpath(parent, field, index))
+        scalar_cls = get_subitem_cls(item.__class__, get_fullpath(parent, field, index), item=item)
         field_repr = get_default_repr(
             None, annotation=scalar_cls, read_only=read_only, title="", input_kwargs=input_kwargs, **kwargs
         )
diff --git a/dash_pydantic_form/model_form.py b/dash_pydantic_form/model_form.py
index 4b1ce8e..9c8497e 100644
--- a/dash_pydantic_form/model_form.py
+++ b/dash_pydantic_form/model_form.py
@@ -173,7 +173,7 @@ class ModelForm(html.Div):
         *, item: BaseModel, path: str, discriminator: str
     ) -> tuple[type[BaseModel], tuple]:
         """Get the subitem of a model at a given parent, handling type unions."""
-        subitem_cls = get_subitem_cls(item.__class__, path)
+        subitem_cls = get_subitem_cls(item.__class__, path, item=item)
 
         # Handle type unions
         disc_vals = None
@@ -316,6 +316,7 @@ class ModelForm(html.Div):
                     else field_repr
                 ).to_dict()
                 for field_name, field_repr in fields_repr.items()
+                if field_name in subitem_cls.model_fields
             }
             if fields_repr
             else None
diff --git a/dash_pydantic_form/utils.py b/dash_pydantic_form/utils.py
index fd04330..e073e76 100644
--- a/dash_pydantic_form/utils.py
+++ b/dash_pydantic_form/utils.py
@@ -139,7 +139,7 @@ def get_model_value(item: BaseModel, field: str, parent: str, allow_default: boo
         return subitem[field]
     except:
         if allow_default:
-            subitem_cls = get_subitem_cls(item.__class__, parent)
+            subitem_cls = get_subitem_cls(item.__class__, parent, item=item)
             if not is_subclass(subitem_cls, BaseModel):
                 return None
             field_info = subitem_cls.model_fields[field]
@@ -183,7 +183,7 @@ def is_idx_template(val: str):
     return bool(re.findall(r"^\{\{[\w|\{\}]+\}\}$", val))
 
 
-def get_subitem_cls(model: type[BaseModel], parent: str) -> type[BaseModel]:
+def get_subitem_cls(model: type[BaseModel], parent: str, item: BaseModel | None = None) -> type[BaseModel]:
     """Get the subitem class of a model at a given parent.
 
     e.g., get_subitem_cls(Person, "au_metadata") = AUMetadata
@@ -204,26 +204,38 @@ def get_subitem_cls(model: type[BaseModel], parent: str) -> type[BaseModel]:
     if isinstance(second_part, str) and second_part.isdigit():
         second_part = int(second_part)
 
-    first_annotation = get_non_null_annotation(model.model_fields[first_part].annotation)
+    field_info = model.model_fields[first_part]
+    first_annotation = get_non_null_annotation(field_info.annotation)
+    subitem = get_subitem(item, first_part) if item is not None else None
+    if Type.classify(first_annotation, field_info.discriminator) == Type.DISCRIMINATED_MODEL:
+        if not item:
+            raise TypeError("Discriminated models with nesting need passing item data to be displayed")
+        discriminator_value = None if subitem is None else getattr(subitem, field_info.discriminator, None)
+        subitem_cls, _ = handle_discriminated(
+            item.__class__, path, first_annotation, field_info.discriminator, discriminator_value
+        )
+        return get_subitem_cls(subitem_cls, SEP.join(path[1:]), item=subitem)
     if (
-        get_origin(first_annotation) == list
+        get_origin(first_annotation) is list
         and (isinstance(second_part, int) or is_idx_template(second_part))
         and get_args(first_annotation)
     ):
         return get_subitem_cls(
             get_non_null_annotation(get_args(first_annotation)[0]),
             SEP.join(path[2:]),
+            item=subitem,
         )
     if (
-        get_origin(first_annotation) == dict
+        get_origin(first_annotation) is dict
         and (isinstance(second_part, int) or is_idx_template(second_part))
         and get_args(first_annotation)
     ):
         return get_subitem_cls(
             get_non_null_annotation(get_args(first_annotation)[1]),
             SEP.join(path[2:]),
+            item=subitem,
         )
-    return get_subitem_cls(first_annotation, SEP.join(path[1:]))
+    return get_subitem_cls(first_annotation, SEP.join(path[1:]), item=subitem)
 
 
 def handle_discriminated(model: type[BaseModel], parent: str, annotation: type, disc_field: str, disc_val: Any):
diff --git a/usage.py b/usage.py
index 6493d36..f9c3e8a 100644
--- a/usage.py
+++ b/usage.py
@@ -42,13 +42,6 @@ class Species(Enum):
     DOG = "dog"
 
 
-class Metadata(BaseModel):
-    """Metadata model."""
-
-    languages: list[Literal["fr", "en", "sp", "cn"]] = Field(title="Languages spoken", default_factory=list)
-    siblings: int | None = Field(title="Siblings", default=None, ge=0)
-
-
 class Pet(BaseModel):
     """Pet model."""
 
@@ -62,11 +55,27 @@ class Pet(BaseModel):
         return str(self.name)
 
 
+class Desk(BaseModel):
+    """Desk model."""
+
+    height: int = Field(title="Height", description="Height of the desk", ge=0)
+    material: str = Field(title="Material", description="Material of the desk")
+
+
+class WorkStation(BaseModel):
+    """Work station model."""
+
+    has_desk: bool = Field(title="Has desk")
+    has_monitor: bool = Field(title="Has monitor")
+    desk: Desk | None = Field(title="Desk", default=None)
+
+
 class HomeOffice(BaseModel):
     """Home office model."""
 
     type: Literal["home_office"]
     has_workstation: bool = Field(title="Has workstation", description="Does the employee have a suitable workstation")
+    workstation: WorkStation | None = Field(title="Workstation", default=None)
 
 
 class WorkOffice(BaseModel):
@@ -76,6 +85,14 @@ class WorkOffice(BaseModel):
     commute_time: int = Field(title="Commute time", description="Commute time in minutes", ge=0)
 
 
+class Metadata(BaseModel):
+    """Metadata model."""
+
+    languages: list[Literal["fr", "en", "sp", "cn"]] = Field(title="Languages spoken", default_factory=list)
+    siblings: int | None = Field(title="Siblings", default=None, ge=0)
+    location: HomeOffice | WorkOffice | None = Field(title="Work location", default=None, discriminator="type")
+
+
 class Employee(BaseModel):
     """Employee model."""
 
@@ -85,7 +102,6 @@ class Employee(BaseModel):
     joined: date = Field(title="Joined", description="Date when the employee joined the company")
     office: Office = Field(title="Office", description="Office of the employee")
     metadata: Metadata | None = Field(title="Employee metadata", default=None)
-    location: HomeOffice | WorkOffice | None = Field(title="Work location", default=None, discriminator="type")
     pets: list[Pet] = Field(title="Pets", description="Employee pets", default_factory=list)
     jobs: list[str] = Field(
         title="Past jobs", description="List of previous jobs the employee has held", default_factory=list
@@ -98,12 +114,12 @@ bob = Employee(
     joined="2020-01-01",
     mini_bio="### Birth\nSomething something\n\n### Education\nCollege",
     office="au",
-    metadata={"languages": ["fr", "en"], "siblings": 2},
-    pets=[{"name": "Rex", "species": "cat"}],
-    location={
-        "type": "home_office",
-        "has_workstation": True,
+    metadata={
+        "languages": ["fr", "en"],
+        "siblings": 2,
+        "location": {"type": "home_office", "has_workstation": True},
     },
+    pets=[{"name": "Rex", "species": "cat"}],
     jobs=["Engineer", "Lawyer"],
 )
 
@@ -161,6 +177,17 @@ app.layout = dmc.MantineProvider(
                                                 "cn": "Chinese",
                                             },
                                         ),
+                                        "location": {
+                                            "fields_repr": {
+                                                "type": fields.RadioItems(
+                                                    options_labels={"home_office": "Home", "work_office": "Work"}
+                                                ),
+                                                "workstation": {
+                                                    "visible": ("has_workstation", "==", True),
+                                                    "fields_repr": {"desk": {"visible": ("has_desk", "==", True)}},
+                                                },
+                                            },
+                                        },
                                     },
                                     "visible": ("_root_:office", "==", "au"),
                                 },
@@ -170,13 +197,6 @@ app.layout = dmc.MantineProvider(
                                     },
                                     table_height=200,
                                 ),
-                                "location": {
-                                    "fields_repr": {
-                                        "type": fields.RadioItems(
-                                            options_labels={"home_office": "Home", "work_office": "Work"}
-                                        )
-                                    },
-                                },
                                 "jobs": {"placeholder": "A job name"},
                             },
                             sections=Sections(


Test patch:
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 0000000..49c7298
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,8 @@
+import pytest
+from dash import _dash_renderer
+
+
+@pytest.fixture(scope="session", autouse=True)
+def set_react_18():
+    """Set react version to 18.2.0 to work with DMC."""
+    _dash_renderer._set_react_version("18.2.0")
diff --git a/tests/test_model_form.py b/tests/test_basic_form.py
similarity index 50%
rename from tests/test_model_form.py
rename to tests/test_basic_form.py
index b6b9c1e..0ae0767 100644
--- a/tests/test_model_form.py
+++ b/tests/test_basic_form.py
@@ -4,22 +4,20 @@ from enum import Enum
 from typing import Literal
 
 import dash_mantine_components as dmc
-from dash import Dash, _dash_renderer
+from dash import Dash  # , Input, Output
 from pydantic import BaseModel, Field
 from selenium.webdriver.common.by import By
 
 from dash_pydantic_form import ModelForm, ids
-from tests.utils import check_elem_values, check_ids_exist, get_field_ids
-
-_dash_renderer._set_react_version("18.2.0")
-external_stylesheets = [
-    "https://unpkg.com/@mantine/dates@7/styles.css",
-    "https://unpkg.com/@mantine/code-highlight@7/styles.css",
-    "https://unpkg.com/@mantine/charts@7/styles.css",
-    "https://unpkg.com/@mantine/carousel@7/styles.css",
-    "https://unpkg.com/@mantine/notifications@7/styles.css",
-    "https://unpkg.com/@mantine/nprogress@7/styles.css",
-]
+from tests.utils import (
+    check_elem_values,
+    check_ids_exist,
+    get_field_ids,
+    # set_checkbox,
+    # set_input,
+    # set_select,
+    stringify_id,
+)
 
 
 class E(Enum):
@@ -43,13 +41,19 @@ class Basic(BaseModel):
 basic_data = {"a": 1, "b": "foo", "c": "a", "d": True, "e": "B", "f": "2022-01-01"}
 
 
-def test_0001_basic_form():
+def test_bf0001_basic_form():
     """Test a basic form."""
     aio_id = "basic"
     form_id = "form"
     form = ModelForm(Basic, aio_id=aio_id, form_id=form_id)
     check_ids_exist(form, list(get_field_ids(Basic, aio_id, form_id)))
-    check_elem_values(form, {json.dumps(fid): None for fid in get_field_ids(Basic, aio_id, form_id)})
+    check_elem_values(
+        form,
+        {
+            json.dumps(fid): None if fid["component"] == ids.value_field.args[0] else False
+            for fid in get_field_ids(Basic, aio_id, form_id)
+        },
+    )
 
     assert list(basic_data) == list(Basic.model_fields)
     item = Basic(**basic_data)
@@ -64,24 +68,52 @@ def test_0001_basic_form():
     )
 
 
-def test_0002_basic_form_in_browser(dash_duo):
+def test_bf0002_basic_form_in_browser(dash_duo):
     """Test a basic form in browser."""
-    app = Dash(
-        __name__, external_stylesheets=external_stylesheets, serve_locally=False, suppress_callback_exceptions=False
-    )
+    app = Dash(__name__)
     item = Basic(**basic_data)
     aio_id = "basic"
     form_id = "form"
-    app.layout = dmc.MantineProvider(
-        ModelForm(item, aio_id=aio_id, form_id=form_id),
-        defaultColorScheme="dark",
-    )
+    app.layout = dmc.MantineProvider(ModelForm(item, aio_id=aio_id, form_id=form_id))
 
     dash_duo.start_server(app)
     for fid, val in zip(get_field_ids(Basic, aio_id, form_id), item.model_dump(mode="json").values(), strict=True):
-        str_id = json.dumps(fid, sort_keys=True).replace(" ", "").replace('"', r"\"")
+        str_id = stringify_id(fid)
         elem = dash_duo.driver.find_element(By.ID, str_id)
         if ids.value_field.args[0] in str_id:
             assert elem.get_property("value") == str(val)
         else:
             assert elem.get_property("checked") == val
+
+
+# def test_bf0003_basic_form_form_data(dash_duo):
+#     """Test a basic form, retrieving its form data."""
+#     app = Dash(__name__)
+#     item = Basic(**basic_data)
+#     aio_id = "basic"
+#     form_id = "form"
+#     app.layout = dmc.MantineProvider(
+#         [
+#             ModelForm(item, aio_id=aio_id, form_id=form_id),
+#             dmc.Text(id="output"),
+#         ]
+#     )
+
+#     @app.callback(
+#         Output("output", "children"),
+#         Input(ModelForm.ids.main(aio_id, form_id), "data"),
+#     )
+#     def display(form_data):
+#         return json.dumps(form_data, sort_keys=True)
+
+#     dash_duo.start_server(app)
+
+#     set_input(dash_duo, ids.value_field(aio_id, form_id, "a"), basic_data["a"])
+#     dash_duo.wait_for_text_to_equal("#output", json.dumps(basic_data, sort_keys=True))
+
+#     set_input(dash_duo, ids.value_field(aio_id, form_id, "a"), 123)
+#     set_select(dash_duo, ids.value_field(aio_id, form_id, "c"), "b")
+#     set_checkbox(dash_duo, ids.checked_field(aio_id, form_id, "d"), False)
+#     dash_duo.wait_for_text_to_equal(
+#         "#output", json.dumps(basic_data | {"a": 123, "c": "b", "d": False}, sort_keys=True)
+#     )
diff --git a/tests/test_discriminated_union.py b/tests/test_discriminated_union.py
new file mode 100644
index 0000000..785c941
--- /dev/null
+++ b/tests/test_discriminated_union.py
@@ -0,0 +1,164 @@
+# from typing import Literal
+
+# import dash_mantine_components as dmc
+# from dash import Dash
+# from pydantic import BaseModel, Field
+
+# from dash_pydantic_form import ModelForm, ids
+# from tests.utils import (
+#     check_ids_absent,
+#     check_ids_exist,
+#     set_checkbox,
+#     set_input,
+#     set_select,
+# )
+
+
+# def test_du0001_basic_discriminated_union(dash_duo):
+#     """Test a basic discriminated union."""
+
+#     class Cat(BaseModel):
+#         species: Literal["cat"]
+#         meows: bool = True
+
+#     class Dog(BaseModel):
+#         species: Literal["dog"]
+#         barks: bool = True
+
+#     class Basic(BaseModel):
+#         pet: Cat | Dog | None = Field(title="Pet", discriminator="species", default=None)
+
+#     app = Dash(__name__)
+#     item = Basic()
+#     aio_id = "basic"
+#     form_id = "form"
+#     app.layout = dmc.MantineProvider(ModelForm(item, aio_id=aio_id, form_id=form_id))
+#     check_ids_absent(
+#         app.layout,
+#         [
+#             ids.checked_field(aio_id, form_id, "meows", parent="pet"),
+#             ids.checked_field(aio_id, form_id, "barks", parent="pet"),
+#         ],
+#     )
+
+#     dash_duo.start_server(app)
+#     set_select(dash_duo, ids.value_field(aio_id, form_id, "species", parent="pet", meta="discriminator"), "cat")
+#     set_checkbox(dash_duo, ids.checked_field(aio_id, form_id, "meows", parent="pet"), False)
+#     set_select(dash_duo, ids.value_field(aio_id, form_id, "species", parent="pet", meta="discriminator"), "dog")
+#     set_checkbox(dash_duo, ids.checked_field(aio_id, form_id, "barks", parent="pet"), False)
+
+
+# def test_du0002_discriminated_union_nested(dash_duo):
+#     """Test a discriminated union nested in a model."""
+
+#     class Cat(BaseModel):
+#         species: Literal["cat"]
+#         meows: bool = True
+
+#     class Dog(BaseModel):
+#         species: Literal["dog"]
+#         barks: bool = True
+
+#     class Nested(BaseModel):
+#         pet: Cat | Dog | None = Field(title="Pet", discriminator="species", default=None)
+
+#     class Basic(BaseModel):
+#         nested: Nested
+
+#     app = Dash(__name__)
+#     item = Basic(nested={"pet": {"species": "cat"}})
+#     aio_id = "basic"
+#     form_id = "form"
+#     app.layout = dmc.MantineProvider(ModelForm(item, aio_id=aio_id, form_id=form_id))
+#     check_ids_exist(
+#         app.layout,
+#         [ids.checked_field(aio_id, form_id, "meows", parent="nested:pet")],
+#     )
+#     dash_duo.start_server(app)
+#     set_checkbox(dash_duo, ids.checked_field(aio_id, form_id, "meows", parent="nested:pet"), False)
+#     set_select(
+#         dash_duo, ids.value_field(aio_id, form_id, "species", parent="nested:pet", meta="discriminator"), "dog"
+#     )
+#     set_checkbox(dash_duo, ids.checked_field(aio_id, form_id, "barks", parent="nested:pet"), False)
+
+
+# def test_du0003_discriminated_union_with_nested_submodel(dash_duo):
+#     """Test a discriminated union with a nested submodel."""
+
+#     class CatOptions(BaseModel):
+#         meows: bool = True
+
+#     class DogOptions(BaseModel):
+#         barks: bool = True
+
+#     class Cat(BaseModel):
+#         species: Literal["cat"]
+#         options: CatOptions = Field(default_factory=CatOptions)
+
+#     class Dog(BaseModel):
+#         species: Literal["dog"]
+#         options: DogOptions = Field(default_factory=DogOptions)
+
+#     class Basic(BaseModel):
+#         pet: Cat | Dog | None = Field(title="Pet", discriminator="species", default=None)
+
+#     app = Dash(__name__)
+#     item = Basic(pet={"species": "cat"})
+#     aio_id = "basic"
+#     form_id = "form"
+#     app.layout = dmc.MantineProvider(ModelForm(item, aio_id=aio_id, form_id=form_id))
+#     check_ids_exist(
+#         app.layout,
+#         [ids.checked_field(aio_id, form_id, "meows", parent="pet:options")],
+#     )
+
+#     dash_duo.start_server(app)
+#     set_checkbox(dash_duo, ids.checked_field(aio_id, form_id, "meows", parent="pet:options"), False)
+#     set_select(dash_duo, ids.value_field(aio_id, form_id, "species", parent="pet", meta="discriminator"), "dog")
+#     set_checkbox(dash_duo, ids.checked_field(aio_id, form_id, "barks", parent="pet:options"), False)
+
+
+# def test_du004_discriminated_in_discriminated(dash_duo):
+#     """Test a model with nesting of discriminated unions."""
+
+#     class BlackOptions(BaseModel):
+#         color: Literal["black"]
+#         a: str = "A"
+
+#     class OrangeOptions(BaseModel):
+#         color: Literal["orange"]
+#         b: str = "B"
+
+#     class Cat(BaseModel):
+#         species: Literal["cat"]
+#         options: BlackOptions | OrangeOptions | None = Field(discriminator="color", default=None)
+
+#     class Dog(BaseModel):
+#         species: Literal["dog"]
+#         barks: bool = True
+
+#     class Basic(BaseModel):
+#         pet: Cat | Dog | None = Field(title="Pet", discriminator="species", default=None)
+
+#     app = Dash(__name__)
+#     item = Basic(pet={"species": "cat", "options": {"color": "black"}})
+#     aio_id = "basic"
+#     form_id = "form"
+#     app.layout = dmc.MantineProvider(ModelForm(item, aio_id=aio_id, form_id=form_id))
+#     check_ids_exist(
+#         app.layout,
+#         [
+#             ids.value_field(aio_id, form_id, "a", parent="pet:options"),
+#             ids.value_field(aio_id, form_id, "color", parent="pet:options", meta="discriminator"),
+#             ids.value_field(aio_id, form_id, "species", parent="pet", meta="discriminator"),
+#         ],
+#     )
+
+#     dash_duo.start_server(app)
+#     set_input(dash_duo, ids.value_field(aio_id, form_id, "a", parent="pet:options"), "123")
+#     set_select(
+#         dash_duo, ids.value_field(aio_id, form_id, "color", parent="pet:options", meta="discriminator"), "orange"
+#     )
+#     set_input(dash_duo, ids.value_field(aio_id, form_id, "b", parent="pet:options"), "456")
+#     set_select(dash_duo, ids.value_field(aio_id, form_id, "species", parent="pet", meta="discriminator"), "dog")
+#     set_checkbox(dash_duo, ids.checked_field(aio_id, form_id, "barks", parent="pet"), False)
diff --git a/tests/utils.py b/tests/utils.py
index a5641be..64ad1da 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -1,7 +1,9 @@
 import json
+from typing import Any
 
 from dash.development.base_component import Component
 from pydantic import BaseModel
+from selenium.webdriver.common.by import By
 
 from dash_pydantic_form import ids
 from dash_pydantic_form.utils import get_non_null_annotation
@@ -25,6 +27,13 @@ def check_ids_exist(component: Component, ids: list[str | dict]):
         raise ValueError(f"Could not find ids: {ids_not_found}")
 
 
+def check_ids_absent(component: Component, ids: list[str | dict]):
+    """Check if ids are absent in a component tree."""
+    ids_found, _ = find_ids(component, ids)
+    if ids_found:
+        raise ValueError(f"Found ids: {ids_found}")
+
+
 def check_elem_values(component: Component, expected: dict):
     """Check if elements in a component tree have the expected values."""
     mismatched_values = {}
@@ -45,3 +54,35 @@ def get_field_ids(model: type[BaseModel], aio_id: str, form_id: str, fields: lis
         if fields is None or field_name in fields:
             base_id = ids.checked_field if get_non_null_annotation(field_info.annotation) is bool else ids.value_field
             yield base_id(aio_id, form_id, field_name, **id_kwargs)
+
+
+def stringify_id(id: dict):
+    """Stringify dict id to use with selenium."""
+    if isinstance(id, str):
+        return id
+    return json.dumps(id, sort_keys=True).replace(" ", "").replace('"', r"\"")
+
+
+def set_input(dash_duo, id: str | dict, value: Any):
+    """Set input value."""
+    str_id = stringify_id(id)
+    elem = dash_duo.driver.find_element(By.ID, str_id)
+    elem.clear()
+    elem.send_keys(str(value))
+
+
+def set_select(dash_duo, id: str | dict, value: str):
+    """Set select value."""
+    str_id = stringify_id(id)
+    dash_duo.driver.find_element(By.ID, str_id).click()
+    dash_duo.driver.find_element(
+        By.CSS_SELECTOR, f"[aria-labelledby='{str_id}-label'] [role='option'][value='{value}']"
+    ).click()
+
+
+def set_checkbox(dash_duo, id: str | dict, value: bool):
+    """Set checkbox value."""
+    str_id = stringify_id(id)
+    elem = dash_duo.driver.find_element(By.ID, str_id)
+    if elem.get_property("checked") != value:
+        elem.click()