diff --git a/docs/library/forms/form.md b/docs/library/forms/form.md index a44c47de6b1..d2d7331666b 100644 --- a/docs/library/forms/form.md +++ b/docs/library/forms/form.md @@ -170,6 +170,114 @@ If you need these controls to be passed in the form data even when their values # Video: Forms ``` +## Validating Form Data with a TypedDict + +The `on_submit` handler usually receives the form data as a plain `dict`, which +means accessing a field is untyped (`form_data["email"]` returns `Any`) and a +typo in a `name` goes unnoticed until runtime. + +Instead, you can annotate the handler's parameter with a +[`TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict). +This gives you typed, autocompleted access to each field inside the handler, and +Reflex validates the form **at compile time**: every required key of the +`TypedDict` must have a matching form control. If a required field has no +control with that `name` (or `id`), Reflex raises an `EventHandlerValueError` +before the app starts, pointing out exactly which fields are missing. + +```python demo exec +from typing import TypedDict + +from typing_extensions import NotRequired + + +class ContactForm(TypedDict): + first_name: str + last_name: str + email: str + message: NotRequired[str] # optional field + + +class TypedFormState(rx.State): + form_data: ContactForm | None = None + + @rx.event + def handle_submit(self, form_data: ContactForm): + """Handle the form submit.""" + # form_data is typed: editors autocomplete the keys below. + self.form_data = form_data + + +def typed_form_example(): + return rx.vstack( + rx.form( + rx.vstack( + rx.input(placeholder="First Name", name="first_name"), + rx.input(placeholder="Last Name", name="last_name"), + rx.input(placeholder="Email", name="email", type="email"), + rx.text_area(placeholder="Message", name="message"), + rx.button("Submit", type="submit"), + ), + on_submit=TypedFormState.handle_submit, + reset_on_submit=True, + ), + rx.divider(), + rx.heading("Results"), + rx.text(TypedFormState.form_data.to_string()), + ) +``` + +### Required and optional fields + +By default every key declared in a `TypedDict` is **required** and must be +backed by a form control. Mark a field as optional with `NotRequired` (or by +inheriting from a `total=False` base) so Reflex won't require a matching +control: + +```python +from typing import TypedDict + +from typing_extensions import NotRequired + + +class ContactForm(TypedDict): + name: str # required: a control named "name" must exist + email: str # required: a control named "email" must exist + message: NotRequired[str] # optional: no control required +``` + +If a required field is missing, creating the form fails fast with a message that +lists the expected, missing, and matching fields: + +```python +class SignupForm(TypedDict): + username: str + email: str + + +class SignupState(rx.State): + @rx.event + def handle_submit(self, form_data: SignupForm): ... + + +# Raises EventHandlerValueError: the form has no control named "email". +rx.form( + rx.input(name="username"), + rx.button("Submit", type="submit"), + on_submit=SignupState.handle_submit, +) +``` + +```md alert info +# When is validation skipped? + +The check only runs when the form fields are statically known. It is +automatically skipped when control `name`/`id` values are dynamic (for example, +built with `rx.foreach`), or when the form has an `id` (since controls can be +associated from elsewhere via the HTML `form` attribute). In those cases the +`TypedDict` still provides typed access inside the handler. At runtime +`form_data` is always a regular dictionary. +``` + ## Dynamic Forms Forms can be dynamically created by iterating through state vars using `rx.foreach`. diff --git a/news/6301.feature.md b/news/6301.feature.md new file mode 100644 index 00000000000..4ef22edcf29 --- /dev/null +++ b/news/6301.feature.md @@ -0,0 +1 @@ +`rx.form` `on_submit` handlers can now annotate their form-data parameter with a `TypedDict` (including `typing_extensions.NotRequired` fields). The submitted mapping is accepted by the event-argument type checker, and at component build time the form statically validates that its controls supply every required `TypedDict` field, raising `EventHandlerValueError` — with the missing and present field names — when a required field has no control with a matching static `name`/`id`. Validation is skipped when the form sets an `id` (controls may be associated externally via the HTML `form` attribute) or when any control identifier is a dynamic `Var`. diff --git a/packages/reflex-base/news/6301.feature.md b/packages/reflex-base/news/6301.feature.md new file mode 100644 index 00000000000..4d026f3cba6 --- /dev/null +++ b/packages/reflex-base/news/6301.feature.md @@ -0,0 +1 @@ +Event-argument type checking now treats a mapping-style payload as compatible with a `TypedDict`-annotated callback parameter, scoped narrowly to `on_submit` triggers whose payload is a `Mapping[str, ...]` so unrelated mapping events are unaffected. Adds the `FORM_SUBMIT_MAPPING` type var (exposed on the event namespace and `pyi_generator`'s default imports) and a `Component._is_form_control` class marker that a component sets to declare it contributes a named field to form submission data. diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 33e597c966c..c2082ce6648 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -833,6 +833,9 @@ class Component(BaseComponent, ABC): # props to change the name of _rename_props: ClassVar[dict[str, str]] = {} + # Whether this component contributes a named field to form submission data. + _is_form_control: ClassVar[bool] = False + custom_attrs: dict[str, Var | Any] = field( doc="Attributes passed directly to the component.", default_factory=dict, diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 842caeae089..2f650c2cf8e 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -25,7 +25,14 @@ overload, ) -from typing_extensions import Self, TypeAliasType, TypedDict, TypeVarTuple, Unpack +from typing_extensions import ( + Self, + TypeAliasType, + TypedDict, + TypeVarTuple, + Unpack, + is_typeddict, +) from reflex_base import constants from reflex_base.components.field import BaseField @@ -61,6 +68,8 @@ if TYPE_CHECKING: from reflex.state import BaseState + BASE_STATE = TypeVar("BASE_STATE", bound=BaseState) + @dataclasses.dataclass( init=True, @@ -839,6 +848,7 @@ def checked_input_event(e: ObjectVar[JavascriptInputEvent]) -> tuple[Var[bool]]: FORM_DATA = Var(_js_expr="form_data") +FORM_SUBMIT_MAPPING = TypeVar("FORM_SUBMIT_MAPPING", bound=Mapping[str, Any]) def on_submit_event() -> tuple[Var[dict[str, Any]]]: @@ -1708,6 +1718,37 @@ def _values_returned_from_event(event_spec_annotations: list[Any]) -> list[Any]: ] +def _is_on_submit_mapping_event_arg_compatible_with_typed_dict( + provided_event_arg_type: Any, + callback_param_type: Any, + key: str, +) -> bool: + """Check whether an on_submit mapping payload can satisfy a TypedDict callback. + + This keeps the compatibility relaxation scoped to form submission payloads + rather than applying to unrelated mapping-based event triggers. + + Args: + provided_event_arg_type: The type produced by the event trigger. + callback_param_type: The callback parameter annotation. + key: The event trigger key being validated. + + Returns: + Whether the provided event payload should be treated as compatible. + """ + if key != constants.EventTriggers.ON_SUBMIT or not is_typeddict( + callback_param_type + ): + return False + + mapping_type = get_origin(provided_event_arg_type) or provided_event_arg_type + if not safe_issubclass(mapping_type, Mapping): + return False + + key_type = get_args(provided_event_arg_type)[:1] + return not key_type or typehint_issubclass(key_type[0], str) + + def _check_event_args_subclass_of_callback( callback_params_names: list[str], provided_event_types: list[Any], @@ -1749,15 +1790,18 @@ def _check_event_args_subclass_of_callback( continue type_match_found.setdefault(arg, False) + callback_param_type = callback_param_name_to_type[arg] try: compare_result = typehint_issubclass( - args_types_without_vars[i], callback_param_name_to_type[arg] + args_types_without_vars[i], callback_param_type + ) or _is_on_submit_mapping_event_arg_compatible_with_typed_dict( + args_types_without_vars[i], callback_param_type, key ) except TypeError as te: callback_name_context = f" of {callback_name}" if callback_name else "" key_context = f" for {key}" if key else "" - msg = f"Could not compare types {args_types_without_vars[i]} and {callback_param_name_to_type[arg]} for argument {arg}{callback_name_context}{key_context}." + msg = f"Could not compare types {args_types_without_vars[i]} and {callback_param_type} for argument {arg}{callback_name_context}{key_context}." raise TypeError(msg) from te if compare_result: @@ -1769,7 +1813,7 @@ def _check_event_args_subclass_of_callback( ) delayed_exceptions.append( EventHandlerArgTypeMismatchError( - f"Event handler {key} expects {args_types_without_vars[i]} for argument {arg} but got {callback_param_name_to_type[arg]}{as_annotated_in} instead." + f"Event handler {key} expects {args_types_without_vars[i]} for argument {arg} but got {callback_param_type}{as_annotated_in} instead." ) ) @@ -2650,10 +2694,6 @@ def __call__(self, *args: Var) -> Any: if TYPE_CHECKING: from reflex.state import BaseState - BASE_STATE = TypeVar("BASE_STATE", bound=BaseState) -else: - BASE_STATE = TypeVar("BASE_STATE") - class EventNamespace: """A namespace for event related classes.""" @@ -2700,6 +2740,7 @@ class EventNamespace: EVENT_ACTIONS_MARKER = EVENT_ACTIONS_MARKER _EVENT_FIELDS = _EVENT_FIELDS FORM_DATA = FORM_DATA + FORM_SUBMIT_MAPPING = FORM_SUBMIT_MAPPING upload_files = upload_files upload_files_chunk = upload_files_chunk stop_propagation = stop_propagation @@ -2733,7 +2774,7 @@ def __new__( @overload def __new__( cls, - func: Callable[[BASE_STATE, Unpack[P]], Any], + func: "Callable[[BASE_STATE, Unpack[P]], Any]", *, background: bool | None = None, stop_propagation: bool | None = None, @@ -2745,7 +2786,7 @@ def __new__( def __new__( cls, - func: Callable[[BASE_STATE, Unpack[P]], Any] | None = None, + func: "Callable[[BASE_STATE, Unpack[P]], Any] | None" = None, *, background: bool | None = None, stop_propagation: bool | None = None, @@ -2753,10 +2794,7 @@ def __new__( throttle: int | None = None, debounce: int | None = None, temporal: bool | None = None, - ) -> ( - EventCallback[Unpack[P]] - | Callable[[Callable[[BASE_STATE, Unpack[P]], Any]], EventCallback[Unpack[P]]] - ): + ) -> "EventCallback[Unpack[P]] | Callable[[Callable[[BASE_STATE, Unpack[P]], Any]], EventCallback[Unpack[P]]]": """Wrap a function to be used as an event. Args: @@ -2804,7 +2842,7 @@ def _build_event_actions(): return event_actions def wrapper( - func: Callable[[BASE_STATE, Unpack[P]], T], + func: "Callable[[BASE_STATE, Unpack[P]], T]", ) -> EventCallback[Unpack[P]]: if background is True: if not inspect.iscoroutinefunction( diff --git a/packages/reflex-base/src/reflex_base/utils/pyi_generator.py b/packages/reflex-base/src/reflex_base/utils/pyi_generator.py index 2b7ee41fb59..3e35b583151 100644 --- a/packages/reflex-base/src/reflex_base/utils/pyi_generator.py +++ b/packages/reflex-base/src/reflex_base/utils/pyi_generator.py @@ -115,6 +115,7 @@ def _safe_issubclass(cls: Any, cls_check: Any | tuple[Any, ...]) -> bool: "EventHandler", "EventSpec", "EventType", + "FORM_SUBMIT_MAPPING", "KeyInputInfo", "PointerEventInfo", ], diff --git a/packages/reflex-components-core/news/6301.feature.md b/packages/reflex-components-core/news/6301.feature.md new file mode 100644 index 00000000000..daf1d172ea8 --- /dev/null +++ b/packages/reflex-components-core/news/6301.feature.md @@ -0,0 +1 @@ +`Form` now validates statically-knowable fields against a `TypedDict`-annotated `on_submit` handler at create time: it walks nested form controls (including components nested in props), collects their static `name`/`id` values, and raises `EventHandlerValueError` listing the missing and present fields when a required `TypedDict` field has no matching control. `input`, `select`, and `textarea` are marked as form controls so their identifiers are collected, and required-field resolution honors `NotRequired` across Python 3.10 and 3.11+. The `on_submit` handler signature also accepts a mapping-style payload via `on_submit_mapping_event`. diff --git a/packages/reflex-components-core/pyproject.toml b/packages/reflex-components-core/pyproject.toml index eab77e789bd..aac2ed49b02 100644 --- a/packages/reflex-components-core/pyproject.toml +++ b/packages/reflex-components-core/pyproject.toml @@ -8,7 +8,7 @@ authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] requires-python = ">=3.10" dependencies = [ - "reflex-base >= 0.9.2", + "reflex-base >= 0.9.4.post23.dev0", "reflex-components-lucide >= 0.9.0", "reflex-components-sonner >= 0.9.0", "python_multipart >= 0.0.21", diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py index beed14826ac..ccd6f4fef3b 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py @@ -2,17 +2,21 @@ from __future__ import annotations +import sys from collections.abc import Iterator +from functools import partial from hashlib import md5 -from typing import Any, ClassVar, Literal +from typing import Any, ClassVar, Literal, get_origin, get_type_hints -from reflex_base.components.component import field +from reflex_base.components.component import BaseComponent, Component, field from reflex_base.components.tags.tag import Tag from reflex_base.constants import Dirs, EventTriggers from reflex_base.event import ( FORM_DATA, + FORM_SUBMIT_MAPPING, EventChain, EventHandler, + EventSpec, checked_input_event, float_input_event, input_event, @@ -21,16 +25,21 @@ on_submit_event, on_submit_string_event, prevent_default, + unwrap_var_annotation, ) +from reflex_base.utils.exceptions import EventHandlerValueError from reflex_base.utils.imports import ImportDict from reflex_base.vars import VarData from reflex_base.vars.base import LiteralVar, Var from reflex_base.vars.number import ternary_operation +from typing_extensions import NotRequired, is_typeddict from reflex_components_core.el.element import Element from .base import BaseHTML, RawTextBaseHTML, VoidBaseHTML +_DYNAMIC_FORM_FIELD = object() + def _handle_submit_js_template( handle_submit_unique_name: str, @@ -66,6 +75,111 @@ def _handle_submit_js_template( """ +def on_submit_mapping_event( + form_data: Var[FORM_SUBMIT_MAPPING], +) -> tuple[Var[FORM_SUBMIT_MAPPING]]: + """Provide a generic mapping-style submit event spec for type checkers. + + Args: + form_data: The form submission payload. + + Returns: + The form data payload. + """ + return (form_data,) + + +def _iter_form_components(component: BaseComponent) -> Iterator[BaseComponent]: + """Yield a component and all nested components that may contribute form data. + + Args: + component: The component to walk. + + Yields: + The component and its nested component descendants. + """ + yield component + for child in component.children: + if isinstance(child, BaseComponent): + yield from _iter_form_components(child) + if isinstance(component, Component): + for component_in_props in component._get_components_in_props(): + yield from _iter_form_components(component_in_props) + + +def _get_static_string_prop( + component: BaseComponent, + prop_name: str, +) -> str | object | None: + """Resolve a component prop when it is statically known to be a string. + + Args: + component: The component being inspected. + prop_name: The prop to resolve. + + Returns: + The resolved string, ``_DYNAMIC_FORM_FIELD`` for dynamic vars, or ``None``. + """ + value = getattr(component, prop_name, None) + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, LiteralVar): + decoded = value._decode() + if isinstance(decoded, str): + return decoded + return None + if isinstance(value, Var): + return _DYNAMIC_FORM_FIELD + return None + + +def _get_required_typed_dict_fields(typed_dict_type: type[Any]) -> frozenset[str]: + """Resolve required TypedDict keys across Python versions. + + On Python 3.11+ ``__required_keys__`` is reliable. On 3.10, + ``typing.TypedDict`` combined with ``typing_extensions.NotRequired`` + fails to populate ``__required_keys__``, so we patch the result by + subtracting fields whose annotation is wrapped with ``NotRequired``. + + Args: + typed_dict_type: The TypedDict class to inspect. + + Returns: + The required field names for the TypedDict. + """ + required = frozenset(getattr(typed_dict_type, "__required_keys__", frozenset())) + if sys.version_info >= (3, 11): + return required + + # On 3.10, __required_keys__ ignores NotRequired from typing_extensions. + # Subtract any field explicitly marked NotRequired. + try: + hints = get_type_hints(typed_dict_type, include_extras=True) + except Exception: + return required + + not_required = frozenset( + name for name, hint in hints.items() if get_origin(hint) is NotRequired + ) + return required - not_required + + +def _format_field_list(fields: tuple[str, ...]) -> str: + """Format field names as a bullet list. + + Args: + fields: The fields to format. + + Returns: + A human-readable bullet list. + """ + if not fields: + return ' - "(none)"' + return "\n".join(f' - "{field}"' for field in fields) + + ButtonType = Literal["submit", "reset", "button"] @@ -172,9 +286,9 @@ class Form(BaseHTML): doc="The name used to make this form's submit handler function unique." ) - on_submit: EventHandler[on_submit_event, on_submit_string_event] = field( - doc="Fired when the form is submitted" - ) + on_submit: EventHandler[ + on_submit_event, on_submit_mapping_event, on_submit_string_event + ] = field(doc="Fired when the form is submitted") @classmethod def create(cls, *children, **props): @@ -196,6 +310,7 @@ def create(cls, *children, **props): # Render the form hooks and use the hash of the resulting code to create a unique name. props["handle_submit_unique_name"] = "" form = super().create(*children, **props) + form._validate_on_submit_typed_dict_fields() # pyright: ignore[reportAttributeAccessIssue] form.handle_submit_unique_name = md5( # pyright: ignore[reportAttributeAccessIssue] str(form._get_all_hooks()).encode("utf-8") ).hexdigest() @@ -263,6 +378,118 @@ def _get_form_refs(self) -> dict[str, Any]: ) return form_refs + def _get_static_form_field_keys(self) -> tuple[set[str], bool]: + """Collect statically known form-data keys and whether any are dynamic. + + Returns: + The known keys and whether any name/id identifiers are dynamic. + """ + form_keys = set(self._get_form_refs()) + has_dynamic_identifiers = False + + for component in _iter_form_components(self): + if component is self or not getattr(component, "_is_form_control", False): + continue + + name = _get_static_string_prop(component, "name") + if name is _DYNAMIC_FORM_FIELD: + has_dynamic_identifiers = True + elif isinstance(name, str): + form_keys.add(name) + + if _get_static_string_prop(component, "id") is _DYNAMIC_FORM_FIELD: + has_dynamic_identifiers = True + + return form_keys, has_dynamic_identifiers + + def _validate_on_submit_typed_dict_fields(self) -> None: + """Validate statically knowable form fields against TypedDict submit handlers. + + Raises: + EventHandlerValueError: If a required TypedDict field is missing. + """ + on_submit = self.event_triggers.get(EventTriggers.ON_SUBMIT) + if not isinstance(on_submit, EventChain): + return + + typed_dict_contracts: list[tuple[str, type[Any], frozenset[str]]] = [] + for event in on_submit.events: + if not isinstance(event, EventSpec): + return + form_data_param_name = next( + ( + param._js_expr + for param, value in event.args + if isinstance(value, Var) and value._js_expr == FORM_DATA._js_expr + ), + None, + ) + if form_data_param_name is None: + continue + + func = ( + event.handler.fn.func + if isinstance(event.handler.fn, partial) + else event.handler.fn + ) + try: + type_hints = get_type_hints(func) + except (NameError, AttributeError, TypeError): + continue + + annotation = type_hints.get(form_data_param_name) + if annotation is None: + continue + + annotation = unwrap_var_annotation(annotation) + if not is_typeddict(annotation): + continue + + required_fields = _get_required_typed_dict_fields(annotation) + typed_dict_contracts.append(( + func.__qualname__, + annotation, + required_fields, + )) + + if not typed_dict_contracts: + return + + # When the form has an id, external controls may be associated via the + # HTML ``form`` attribute so we cannot validate statically. + if _get_static_string_prop(self, "id") is not None: + return + + form_keys, has_dynamic_identifiers = self._get_static_form_field_keys() + + for handler_name, typed_dict_type, required_fields in typed_dict_contracts: + required_field_names = tuple(sorted(required_fields)) + if not required_field_names: + continue + + missing_fields = tuple( + field for field in required_field_names if field not in form_keys + ) + if not missing_fields or has_dynamic_identifiers: + continue + + present_fields = tuple( + field for field in required_field_names if field in form_keys + ) + msg = ( + f"Form field mismatch for on_submit handler `{handler_name}`.\n\n" + f"The handler expects form data matching `{typed_dict_type.__name__}` " + "with required fields:\n" + f"{_format_field_list(required_field_names)}\n\n" + "Fields missing from the form:\n" + f"{_format_field_list(missing_fields)}\n\n" + "Matching fields present in the form:\n" + f"{_format_field_list(present_fields)}\n\n" + "Hint: Add controls with matching static `name` or `id` values, or " + "make the TypedDict fields optional." + ) + raise EventHandlerValueError(msg) + def _get_vars( self, include_children: bool = True, ignore_ids: set[int] | None = None ) -> Iterator[Var]: @@ -309,6 +536,7 @@ class BaseInput(VoidBaseHTML): """A base class for input elements.""" tag = "input" + _is_form_control = True accept: Var[str] = field(doc="Accepted types of files when the input is file type") @@ -586,6 +814,7 @@ class Select(BaseHTML): """Display the select element.""" tag = "select" + _is_form_control = True auto_complete: Var[str] = field( doc="Whether the form control should have autocomplete enabled" @@ -656,6 +885,7 @@ class Textarea(RawTextBaseHTML): """Display the textarea element.""" tag = "textarea" + _is_form_control = True auto_complete: Var[str] = field( doc="Whether the form control should have autocomplete enabled" diff --git a/packages/reflex-components-radix/news/6301.misc.md b/packages/reflex-components-radix/news/6301.misc.md new file mode 100644 index 00000000000..df0f9faf3de --- /dev/null +++ b/packages/reflex-components-radix/news/6301.misc.md @@ -0,0 +1 @@ +Mark the Radix form controls — checkbox, checkbox group, radio group, radio cards, select, switch, and both sliders — with `_is_form_control` so their static `name`/`id` is collected when a form validates its fields against a `TypedDict`-annotated `on_submit` handler. diff --git a/packages/reflex-components-radix/pyproject.toml b/packages/reflex-components-radix/pyproject.toml index 327eaca0554..bfc2e97c9fd 100644 --- a/packages/reflex-components-radix/pyproject.toml +++ b/packages/reflex-components-radix/pyproject.toml @@ -8,7 +8,7 @@ authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] requires-python = ">=3.10" dependencies = [ - "reflex-base >= 0.9.2", + "reflex-base >= 0.9.4.post23.dev0", "reflex-components-core >= 0.9.0", "reflex-components-lucide >= 0.9.0", ] diff --git a/packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.py b/packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.py index 68a4d5971bf..0e9e88474c7 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.py @@ -26,6 +26,7 @@ class SliderRoot(SliderComponent): tag = "Root" alias = "RadixSliderRoot" + _is_form_control = True default_value: Var[Sequence[int]] diff --git a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.py b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.py index bad90762fe4..abd98b12425 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.py @@ -23,6 +23,7 @@ class Checkbox(RadixThemesComponent): """Selects a single value, typically for submission in a form.""" tag = "Checkbox" + _is_form_control = True as_child: Var[bool] = field( doc="Change the default rendered element for the one passed as a child, merging their props and behavior." diff --git a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.py b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.py index 63fe0741361..b1bd87b7199 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.py @@ -15,6 +15,7 @@ class CheckboxGroupRoot(RadixThemesComponent): """Root element for a CheckboxGroup component.""" tag = "CheckboxGroup.Root" + _is_form_control = True size: Var[Responsive[Literal["1", "2", "3"]]] = field( doc="Use the size prop to control the checkbox size." diff --git a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.py b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.py index c87c9d39944..f0dded4db20 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.py @@ -15,6 +15,7 @@ class RadioCardsRoot(RadixThemesComponent): """Root element for RadioCards component.""" tag = "RadioCards.Root" + _is_form_control = True as_child: Var[bool] = field( doc="Change the default rendered element for the one passed as a child, merging their props and behavior." diff --git a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.py b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.py index bbe96e1a2cb..fbda10c17db 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.py @@ -29,6 +29,7 @@ class RadioGroupRoot(RadixThemesComponent): """A set of interactive radio buttons where only one can be selected at a time.""" tag = "RadioGroup.Root" + _is_form_control = True size: Var[Responsive[Literal["1", "2", "3"]]] = field( default=LiteralVar.create("2"), diff --git a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.py b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.py index f54ab46ad5f..633270e9622 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.py @@ -21,6 +21,7 @@ class SelectRoot(RadixThemesComponent): """Displays a list of options for the user to pick from, triggered by a button.""" tag = "Select.Root" + _is_form_control = True size: Var[Responsive[Literal["1", "2", "3"]]] = field( doc=( diff --git a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.py b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.py index e96690d0bc5..5e8e3d0597e 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.py @@ -23,6 +23,7 @@ class Slider(RadixThemesComponent): """Provides user selection from a range of values.""" tag = "Slider" + _is_form_control = True as_child: Var[bool] = field( doc="Change the default rendered element for the one passed as a child, merging their props and behavior." diff --git a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.py b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.py index e379557c48a..6f598928419 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.py @@ -16,6 +16,7 @@ class Switch(RadixThemesComponent): """A toggle switch alternative to the checkbox.""" tag = "Switch" + _is_form_control = True as_child: Var[bool] = field( doc="Change the default rendered element for the one passed as a child, merging their props and behavior." diff --git a/pyi_hashes.json b/pyi_hashes.json index bb44c790e32..d0d09e0a996 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,124 +1,124 @@ { - "packages/reflex-components-code/src/reflex_components_code/code.pyi": "6ef91a4a4976e66b2761539e16d4f28e", - "packages/reflex-components-code/src/reflex_components_code/shiki_code_block.pyi": "d3e0c33fdc34f5c154ac387d550c0d29", + "packages/reflex-components-code/src/reflex_components_code/code.pyi": "ee1377bb8779bcd9a1069246cc41f957", + "packages/reflex-components-code/src/reflex_components_code/shiki_code_block.pyi": "60d916b71e20b2c37ee85c2f77cb1a94", "packages/reflex-components-core/src/reflex_components_core/__init__.pyi": "82b29d23f2490161d42fd21021bd39c3", "packages/reflex-components-core/src/reflex_components_core/base/__init__.pyi": "7009187aaaf191814d031e5462c48318", - "packages/reflex-components-core/src/reflex_components_core/base/app_wrap.pyi": "ecccfd8a9b0e8b2f4128ff13ff27a9da", - "packages/reflex-components-core/src/reflex_components_core/base/body.pyi": "2535814d409e5feaf57da63dcf0abeaf", - "packages/reflex-components-core/src/reflex_components_core/base/document.pyi": "a2e67a9814dc61853ca2299d9d9c698d", - "packages/reflex-components-core/src/reflex_components_core/base/error_boundary.pyi": "59170074a1a228ce58685f3f207954f2", - "packages/reflex-components-core/src/reflex_components_core/base/fragment.pyi": "e4cbfc46eabb904596be4372392add35", - "packages/reflex-components-core/src/reflex_components_core/base/link.pyi": "005866cf4d1cc8ac7693ed6baeca2289", - "packages/reflex-components-core/src/reflex_components_core/base/meta.pyi": "0cfa2d8c52321ce7440e887d03007d5b", - "packages/reflex-components-core/src/reflex_components_core/base/script.pyi": "bfc7fb609b822f597d1141595f8090fe", - "packages/reflex-components-core/src/reflex_components_core/base/strict_mode.pyi": "8ee129808abb4389cbd77a1736190eae", + "packages/reflex-components-core/src/reflex_components_core/base/app_wrap.pyi": "7e6d47b5103645de33309dc4ac1a4317", + "packages/reflex-components-core/src/reflex_components_core/base/body.pyi": "401472c41e11c629598e5b6200434a18", + "packages/reflex-components-core/src/reflex_components_core/base/document.pyi": "1852d2a5b49961a6e164b65bcf153b4a", + "packages/reflex-components-core/src/reflex_components_core/base/error_boundary.pyi": "49fb5ade8b957091a5a5a7c98e6feafb", + "packages/reflex-components-core/src/reflex_components_core/base/fragment.pyi": "ed61f55dd75ae4df5cc14ee01a6540fa", + "packages/reflex-components-core/src/reflex_components_core/base/link.pyi": "581499d67df1d53b4ff57fd660067d9c", + "packages/reflex-components-core/src/reflex_components_core/base/meta.pyi": "762e78d2b1e1c5632afcc0652ca467af", + "packages/reflex-components-core/src/reflex_components_core/base/script.pyi": "cdc59d7bdde7b9b3b10845aa52853299", + "packages/reflex-components-core/src/reflex_components_core/base/strict_mode.pyi": "bb692191ce0f1fbfce859a9eba10cbad", "packages/reflex-components-core/src/reflex_components_core/core/__init__.pyi": "dd5142b3c9087bf2bf22651adf6f2724", - "packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "918dfad4d5925addd0f741e754b3b076", - "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "6040fbada9b96c55637a9c8cc21a5e10", - "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "e3950e0963a6d04299ff58294687e407", - "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "58138b5f1d5901839729d839620ea4da", - "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "7fd81a99bde5b0ff94bb52523597fd5c", - "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "753d6ae315369530dad450ed643f5be6", - "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "ba60a7d9cba75b27a1133bd63a9fbd59", - "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "0810ae4f1aa3c8fcaa228e7555c59f9a", - "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "5e1dcb1130bc8af282783fae329ae6a6", + "packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "d63f077b0c4cd1924c59a6562d558e39", + "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "2f81188abc7a1c8fc2e7573e4335ba63", + "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "289f617c1646449bddef17f8f124999e", + "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "64cd028071ead4892bf5ff4c8d0af34e", + "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "5d542049d242432da93bdb37f064232c", + "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "90f372ef93b742ed445a6afa8b671e5c", + "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "fe237bf5b5c0030cb85c7b72b2eabdfe", + "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "b8e4b197e7678faa653d9a2f7da10591", + "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "c51889b4e63f6b3132ae195da959ea1e", "packages/reflex-components-core/src/reflex_components_core/datadisplay/__init__.pyi": "c96fed4da42a13576d64f84e3c7cb25c", "packages/reflex-components-core/src/reflex_components_core/el/__init__.pyi": "f09129ddefb57ab4c7769c86dc9a3153", - "packages/reflex-components-core/src/reflex_components_core/el/element.pyi": "ff68d843c5987d3f0d773a6367eb9c63", + "packages/reflex-components-core/src/reflex_components_core/el/element.pyi": "19bd843c1294785ec42b3e0f6ae5e46f", "packages/reflex-components-core/src/reflex_components_core/el/elements/__init__.pyi": "e6c845f2f29eb079697a2e31b0c2f23a", - "packages/reflex-components-core/src/reflex_components_core/el/elements/base.pyi": "a3ef8bcb5fe8e4bfb22a8f6d714611b8", - "packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "21e51ccc7307c3c41f2556ffa7019f2c", - "packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "9c1432e70e6b9349f44df04a244a4303", - "packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "f51120c31a1a8b79da9ecf58f19005b9", - "packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "73d19f3d9e389447ad8bbb68e1b7d1c9", - "packages/reflex-components-core/src/reflex_components_core/el/elements/other.pyi": "c86abf00384b5f15725a0daf2533848d", - "packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.pyi": "903432e316a781b342f2b8d334952da1", - "packages/reflex-components-core/src/reflex_components_core/el/elements/sectioning.pyi": "fbbe0bf222d4196c32c88d05cb077997", - "packages/reflex-components-core/src/reflex_components_core/el/elements/tables.pyi": "93a69aab9a6f519e3f293d439a39786b", - "packages/reflex-components-core/src/reflex_components_core/el/elements/typography.pyi": "2b434f2231d6f21b12d32995ac185e79", - "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "1074a512195ae23d479c4a2d553954e1", - "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "8e379fa038c7c6c0672639eb5902934d", - "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "d2dc211d707c402eb24678a4cba945f7", - "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "d16d77881afaae71578177db4d479c13", - "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "e04f22f5d3d2b5dfd99f9fbedb2b4f3d", - "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "d6a02e447dfd3c91bba84bcd02722aed", - "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "91e956633778c6992f04940c69ff7140", + "packages/reflex-components-core/src/reflex_components_core/el/elements/base.pyi": "d89d4cf5fa68f7607cc613ba862dbc33", + "packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "0929908170b04328b23e2343493dbd14", + "packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "106c40b2f1732dc8ab5ac3a55c9ade0a", + "packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "3ee07d06e7ef9b72129d4315e32aa017", + "packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "9bffa93d5d9753fa1123bdf327ef224b", + "packages/reflex-components-core/src/reflex_components_core/el/elements/other.pyi": "9824faa7115bba66a3b0e827022c8cf1", + "packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.pyi": "5cf24239b88c1d2847c92a025d05f31d", + "packages/reflex-components-core/src/reflex_components_core/el/elements/sectioning.pyi": "55eeee66bb8a07af0e73885d61ce819c", + "packages/reflex-components-core/src/reflex_components_core/el/elements/tables.pyi": "5c544e26f79477713d9426dfa6003220", + "packages/reflex-components-core/src/reflex_components_core/el/elements/typography.pyi": "f746255251faf92aa54d5cf28e202758", + "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "b5f6d8ce3fcdfc1d5efd5dfac2b95e3b", + "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "69b644aa2d116ea3532155ada09be70a", + "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "74ce1f8a302aa9b9b317e6e63e5dac41", + "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "cbac4e023ad9b9923736908600d416f6", + "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "62a1b311ed239ff1dc943478ae2b7c7d", + "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "2623f3930c838f52a13018fb5ded01c0", + "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "700dac836a2610094c4e85e068d2c8f7", "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "19216eb3618f68c8a76e5e43801cf4af", "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "5404a8da97e8b5129133d7f300e3f642", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "e8ef2b44f2afe3e9b8d678d523673882", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "e779c6739baee98c8a588768a88de45a", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/dialog.pyi": "ffb06f3aa8722c2345a952869118e224", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/drawer.pyi": "cc724f697e62efba294e19b58c6f1bd8", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/form.pyi": "4d6121ccc963c64e33c49acd4295eb7a", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/progress.pyi": "b3b66ec57525c53ea741897e2bc8370e", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.pyi": "c86bc8d4604e3d8c8d40baad2ac6dc17", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "e48ed5a3fbe79eb73e63ad07aa490241", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "44ae9d7d31f4c0237d9b8ce213454218", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/dialog.pyi": "170dc8766164a4021770ac0447e7978e", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/drawer.pyi": "5bd53bb47323cc26a4e0c4b64f93cbaf", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/form.pyi": "041125ac50c493621e58758412ea5c02", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/progress.pyi": "47e86a27f49fbc9c5f2f8f4832f27d30", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.pyi": "381491180881ae1890396ae1276748f9", "packages/reflex-components-radix/src/reflex_components_radix/themes/__init__.pyi": "b433b9a099dc5b0ab008d02c85d38059", - "packages/reflex-components-radix/src/reflex_components_radix/themes/base.pyi": "e75cbf2a34620721432b1556f3c875cd", - "packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.pyi": "ed020269e4728cc6abe72354193146b7", + "packages/reflex-components-radix/src/reflex_components_radix/themes/base.pyi": "6210c4383081524f9e521441247fdc3a", + "packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.pyi": "56ed24c55adcfcc1665e3c2b1f5bca4f", "packages/reflex-components-radix/src/reflex_components_radix/themes/components/__init__.pyi": "f10f0169f81c78290333da831915762f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/alert_dialog.pyi": "d5e0419729df4ddf2caf214f40ae7845", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/aspect_ratio.pyi": "613abb9870259547c99eb434a3a17512", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/avatar.pyi": "1671e796449b236386d8f53d33e42b2f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/badge.pyi": "9fde9929ca5197e0e1880bce9a08e926", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/button.pyi": "e5d6387a93c74dafaa0d6f1719e08bac", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/callout.pyi": "31da62c4d8c1d459089aab32cd232feb", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/card.pyi": "76afb58340c6be1f26b7b110473efa55", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.pyi": "6cbf013e21d7280118dfd7383998b3bf", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_cards.pyi": "63b4134246f68f9f556896d6ce194462", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.pyi": "ec3f89e7d187303344d4127a83522b22", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/context_menu.pyi": "99541ee46f112eb4096f903a99f5ffb8", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/data_list.pyi": "1dfd91741ff402b3ed93b6daca4939f3", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dialog.pyi": "ed9198da4a7950a8579e50ad970c34ef", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dropdown_menu.pyi": "15f9cee0584414f2d2e0fb82c167f216", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/hover_card.pyi": "3f328bb0ba5225e4478febf8c7623833", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.pyi": "be8eed28e19221a406e554829809ff0d", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/inset.pyi": "9b2adf18f7d239b8e7431f39042ed301", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/popover.pyi": "4d5813a47b8f8b6ac317ca01d87d9afb", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/progress.pyi": "1ab01f45a4c5ef4211eacc00cc99e4a5", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio.pyi": "38a7412205a98617f98218a5b213ada1", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.pyi": "d84b16ac16083a534199fd23659aaa06", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.pyi": "51fda6313f1ce86d5b1ffdfd68ae8b74", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/scroll_area.pyi": "bba40e5eae75314157378c9e8b0eea73", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.pyi": "44770b1f5eb91502bfef3aadd209d0b8", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.pyi": "0f2f7b6ea8d5e4d2e35ff2069364ab75", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/separator.pyi": "519781d33b99c675a12014d400e54d08", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/skeleton.pyi": "f4848f7d89abb4c78f6db52c624cdabf", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.pyi": "61a08374fa19a0bb3f52b8654effc0f2", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/spinner.pyi": "530c51742031389d4b2ae43548ff0f03", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.pyi": "23b21bc11a0012e13ce9bb79b47ba146", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/table.pyi": "8364f40600870bafa585528d9cadedf8", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tabs.pyi": "3a52910c327f55656eb59309f9362361", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.pyi": "fcf562b2f61ecdcc2de6f70d2ebf9907", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_field.pyi": "250b7e77b67e7d8cd3fff2b40526c04c", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tooltip.pyi": "799acce0af81899a3a310bdcd43c403b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/alert_dialog.pyi": "cf4ee8d5881ae637d3921ce47d77288c", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/aspect_ratio.pyi": "fc59489d19ced57d25b79ed3faea1452", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/avatar.pyi": "44c8f858b0dd5a0b2cf3a123819800ca", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/badge.pyi": "4aaf303dda33666f843497f739372f31", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/button.pyi": "9513c578fd0937af79e844b862247e07", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/callout.pyi": "30f9b953cd5f67a3bee5b862758630c3", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/card.pyi": "7fc1a0118435b5326e87218486ba0467", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.pyi": "c1a14bc5ef590547fceb75b0b7ba8133", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_cards.pyi": "386b07f457781c167e70d57cfee33264", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.pyi": "d888952bbd5e8df885e7ba7b9c7c6894", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/context_menu.pyi": "d82544e85d3c47ebddb6b1fee6216f78", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/data_list.pyi": "48e0d7e25cc418e10e3d6f270a1cbcb2", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dialog.pyi": "325f54a610ddca5679ec555220207caa", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dropdown_menu.pyi": "794d02c236c887de7572d7b1fa730d64", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/hover_card.pyi": "30867282512dd968e6570e5aad7b52aa", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.pyi": "19d3d20ea46171346a1c3e488ebd1a3a", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/inset.pyi": "75991b362962882f18c69e081e68c284", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/popover.pyi": "430834b6a91bca074447f9c4b046d7d2", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/progress.pyi": "81e92f87d352f75e1e09f8f44cc782f5", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio.pyi": "f452755354bec757ebf01817de731bda", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.pyi": "4e296a069c395154825e51abc8b37abb", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.pyi": "ba6f983a6dbee4fd9665bb63544152d4", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/scroll_area.pyi": "3b746f28fb7f9518697edb2dfc0a2c90", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.pyi": "0b5e8273d1d3044772c710f32475f2b4", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.pyi": "75f5da11fe90491751d8ebcb3d1332a1", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/separator.pyi": "0885e5b47e0f158511b26c111ccea9ba", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/skeleton.pyi": "b4abd9619577a34783f7b41e4041170c", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.pyi": "e714426507967f2d0d1feebb8ca8c005", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/spinner.pyi": "07e7d6a75c5bb5673d2fc6992ac05b6a", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.pyi": "5d97af6f1bacf7b29ff709922f6835d5", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/table.pyi": "5ca834b3dae21884223fd76f485c7c04", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tabs.pyi": "445695ca79d8efebadf60201c17c9d98", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.pyi": "609fff762d18b6326b87a4f1dfbddc25", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_field.pyi": "e7215f6ebde268e6ff27c7f3f65b91e2", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tooltip.pyi": "e6f6007a6e5ba3ace5f8d761992107c7", "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/__init__.pyi": "9e452af27229b676ad0146e40f75bed5", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/base.pyi": "5b262189e235cac17182e79188b1681a", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/box.pyi": "e06c8fd64132765d61b9edb87a48558b", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/center.pyi": "5aa934d7c6ba3889fa943eabee7dc05f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/container.pyi": "c67fafd1aec105cb5a9927ff0e6d2071", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/flex.pyi": "aa68061a8e5dfd4adf336d1d1cb000fb", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/grid.pyi": "06b92d31331c6f08b5083fcc811b754a", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/list.pyi": "e7cd3a9cea1c34e21f731f1bd05c1ceb", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/section.pyi": "8c968fead3155b2d51c687459811b5df", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/spacer.pyi": "cfc8a927642e5b68feabc80080aeb8dc", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/stack.pyi": "cf88cf870eefaacaf765ead10fb4593b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/base.pyi": "0a60d38f462928eccd5f2e1522f8d9e5", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/box.pyi": "99a43eb5e4f64a9670587f7c4c33e1ab", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/center.pyi": "f7995de1ca82dca1fb0a52baa6221e06", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/container.pyi": "ac557baa161dd9f4fd5ba4239dc6f3e7", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/flex.pyi": "045892084d19790ec5dce0aca23f8ab6", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/grid.pyi": "e09b30f3045fffe6415df56c2ef6b801", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/list.pyi": "081c2dc6b6bdd9ac58865b605f51d73a", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/section.pyi": "7ee955218a908d3ff4724d6f870536f9", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/spacer.pyi": "1f3a21cbcc69131135c069d200ddd432", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/stack.pyi": "71540d9403ebbdac97fa21096b5c01e9", "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/__init__.pyi": "de7ee994f66a4c1d1a6ac2ad3370c30e", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/blockquote.pyi": "92d5a2df77a69a28a4d591000ee46bd1", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/code.pyi": "8a1e4376cadf4961212d39a5128a0e4f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/heading.pyi": "34c7ed3fe1e5f702a98d72751b0052fa", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/link.pyi": "619a9d8351748fffe76136002931e583", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/text.pyi": "4919daa4483b7c12f6fafd02a2275e0f", - "packages/reflex-components-react-player/src/reflex_components_react_player/audio.pyi": "0817c9232a6e4790cff8ea8aa6001950", - "packages/reflex-components-react-player/src/reflex_components_react_player/react_player.pyi": "6c1c26149d57c708fab04b82de0eb515", - "packages/reflex-components-react-player/src/reflex_components_react_player/video.pyi": "75207a9fe4f37ec2a2f1becbbbd5237b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/blockquote.pyi": "ef67ff3ea9805bd95322834f044437cb", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/code.pyi": "636acf47e8aac0cda0ab2b5beb4119de", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/heading.pyi": "bc56e9eb91ae9899560c613d55089375", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/link.pyi": "f4dad92290f4bb8cb65e7702d41d23de", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/text.pyi": "179be574a5127a877780d2839fcae7ee", + "packages/reflex-components-react-player/src/reflex_components_react_player/audio.pyi": "5bbf08502695fededb3ca4d23245d3df", + "packages/reflex-components-react-player/src/reflex_components_react_player/react_player.pyi": "41e57798ed7df7eb405d0ac532289c1b", + "packages/reflex-components-react-player/src/reflex_components_react_player/video.pyi": "ae525af69173fab76a89924d10e07209", "packages/reflex-components-recharts/src/reflex_components_recharts/__init__.pyi": "7b8b69840a3637c1f1cac45ba815cccf", - "packages/reflex-components-recharts/src/reflex_components_recharts/cartesian.pyi": "277bbf09d72e0c450241f0b7d39ebb60", - "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "be20d1d71c3b16f7e973a0329c3d81d6", - "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "5a1a479924ad6184abafe4d796cb04c5", - "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "1979bb6c22bb7a0d3342b2d63fb19d74", - "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "c5288f311fe37b23539518ba2a3d4482", - "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7", + "packages/reflex-components-recharts/src/reflex_components_recharts/cartesian.pyi": "1d4ecc60531f713c9ff5948370ec5657", + "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "45e7fbe250684e338a6ac5c74a6c6b74", + "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "64f5e189d4bda0e7d946c302297070c9", + "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "1ad9783b38fda94d9c64ac808a7f2535", + "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "193ab7e39b83b8898feeeed98f21d542", + "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "13e2466062abe801d0741b55a39ae978", "reflex/__init__.pyi": "674cc55e646deb97c0e414e1d0e850ef", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", - "reflex/experimental/memo.pyi": "79573b03f5cd29222d9c7edd926541b1" + "reflex/experimental/memo.pyi": "665e37631a7b38dfac5497098d6c0a04" } diff --git a/tests/integration/test_typeddict_form_submit.py b/tests/integration/test_typeddict_form_submit.py new file mode 100644 index 00000000000..976a716e136 --- /dev/null +++ b/tests/integration/test_typeddict_form_submit.py @@ -0,0 +1,236 @@ +"""Integration tests for TypedDict-annotated form submissions.""" + +import asyncio +import functools +import json +from collections.abc import Generator + +import pytest +from reflex_base.utils import format +from selenium.webdriver.common.by import By + +from reflex.testing import AppHarness + + +def TypedDictFormSubmit(form_component): + """App with a form using a TypedDict-annotated on_submit handler. + + Args: + form_component: The str name of the form component to use. + """ + from typing import TypedDict + + from typing_extensions import NotRequired + + import reflex as rx + + class ContactData(TypedDict): + name: str + email: str + message: NotRequired[str] + + class FormState(rx.State): + form_data: rx.Field[dict] = rx.field(default_factory=dict) + + def form_submit(self, form_data: ContactData): + self.form_data = dict(form_data) + + app = rx.App() + + @app.add_page + def index(): + return rx.vstack( + rx.input( + value=FormState.router.session.client_token, + is_read_only=True, + id="token", + ), + eval(form_component)( + rx.vstack( + rx.input(name="name"), + rx.input(name="email"), + rx.text_area(name="message"), + rx.button("Submit", type_="submit"), + ), + on_submit=FormState.form_submit, + custom_attrs={"action": "/invalid"}, + ), + rx.text(FormState.form_data.to_string(), id="form-data"), + rx.spacer(), + height="100vh", + ) + + +def TypedDictInheritedFormSubmit(form_component): + """App with a form using an inherited TypedDict with optional parent fields. + + Args: + form_component: The str name of the form component to use. + """ + from typing import TypedDict + + import reflex as rx + + class BaseData(TypedDict, total=False): + nickname: str + + class SignupData(BaseData): + email: str + + class FormState(rx.State): + form_data: rx.Field[dict] = rx.field(default_factory=dict) + + def form_submit(self, form_data: SignupData): + self.form_data = dict(form_data) + + app = rx.App() + + @app.add_page + def index(): + return rx.vstack( + rx.input( + value=FormState.router.session.client_token, + is_read_only=True, + id="token", + ), + eval(form_component)( + rx.vstack( + rx.input(name="email"), + rx.input(name="nickname"), + rx.button("Submit", type_="submit"), + ), + on_submit=FormState.form_submit, + custom_attrs={"action": "/invalid"}, + ), + rx.text(FormState.form_data.to_string(), id="form-data"), + rx.spacer(), + height="100vh", + ) + + +# Each variant carries its own input actions and expected output. +_CONTACT_FIELDS = { + "inputs": {"name": "Alice", "email": "alice@example.com"}, + "textarea": "Hello there", + "expected": { + "name": "Alice", + "email": "alice@example.com", + "message": "Hello there", + }, +} +_INHERITED_FIELDS = { + "inputs": {"email": "user@example.com", "nickname": "cooluser"}, + "textarea": None, + "expected": {"email": "user@example.com", "nickname": "cooluser"}, +} + + +@pytest.fixture( + scope="module", + params=[ + ( + functools.partial(TypedDictFormSubmit, form_component="rx.form.root"), + _CONTACT_FIELDS, + ), + ( + functools.partial(TypedDictFormSubmit, form_component="rx.el.form"), + _CONTACT_FIELDS, + ), + ( + functools.partial( + TypedDictInheritedFormSubmit, form_component="rx.el.form" + ), + _INHERITED_FIELDS, + ), + ], + ids=[ + "typeddict-radix", + "typeddict-html", + "inherited-html", + ], +) +def typeddict_form( + request, tmp_path_factory +) -> Generator[tuple[AppHarness, dict], None, None]: + """Start a TypedDict form app at tmp_path via AppHarness. + + Args: + request: pytest request fixture + tmp_path_factory: pytest tmp_path_factory fixture + + Yields: + running AppHarness instance and its test field config + """ + app_source, fields = request.param + param_id = request._pyfuncitem.callspec.id.replace("-", "_") + with AppHarness.create( + root=tmp_path_factory.mktemp("typeddict_form"), + app_source=app_source, + app_name=app_source.func.__name__ + f"_{param_id}", + ) as harness: + assert harness.app_instance is not None, "app is not running" + yield harness, fields + + +@pytest.fixture +def driver(typeddict_form: tuple[AppHarness, dict]): + """Get an instance of the browser open to the app. + + Args: + typeddict_form: harness and fields for the TypedDict form app + + Yields: + WebDriver instance. + """ + harness, _ = typeddict_form + driver = harness.frontend() + try: + yield driver + finally: + driver.quit() + + +@pytest.mark.asyncio +async def test_typeddict_form_submit(driver, typeddict_form: tuple[AppHarness, dict]): + """Fill a TypedDict-backed form, submit it, and verify the data arrives. + + Args: + driver: selenium WebDriver open to the app + typeddict_form: harness and fields for the app + """ + harness, fields = typeddict_form + assert harness.app_instance is not None, "app is not running" + + token_input = AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "token") + ) + token = harness.poll_for_value(token_input) + assert token + + for input_name, input_value in fields["inputs"].items(): + el = driver.find_element(By.NAME, input_name) + el.send_keys(input_value) + + if fields["textarea"] is not None: + textarea = driver.find_element(By.TAG_NAME, "textarea") + textarea.send_keys(fields["textarea"]) + + await asyncio.sleep(0.5) + + prev_url = driver.current_url + + submit_btn = driver.find_element(By.CLASS_NAME, "rt-Button") + submit_btn.click() + + harness.poll_for_content( + driver.find_element(By.ID, "form-data"), exp_not_equal="{}" + ) + form_data = json.loads(driver.find_element(By.ID, "form-data").text) + assert isinstance(form_data, dict) + form_data = format.collect_form_dict_names(form_data) + + for key, expected_value in fields["expected"].items(): + assert form_data[key] == expected_value, f"Mismatch for {key!r}" + + # submitting the form should NOT change the url (preventDefault) + assert driver.current_url == prev_url diff --git a/tests/units/components/forms/test_form.py b/tests/units/components/forms/test_form.py index 160be7681f9..70c44a840eb 100644 --- a/tests/units/components/forms/test_form.py +++ b/tests/units/components/forms/test_form.py @@ -1,12 +1,20 @@ +from typing import TypedDict + +import pytest from reflex_base.event import EventChain, prevent_default +from reflex_base.utils.exceptions import EventHandlerValueError from reflex_base.vars.base import Var from reflex_components_core.el.elements.forms import ( AUTO_HEIGHT_JS, ENTER_KEY_SUBMIT_JS, + Input, Textarea, ) +from reflex_components_core.el.elements.forms import Form as HTMLForm from reflex_components_radix.primitives.form import Form +from typing_extensions import NotRequired +import reflex as rx from reflex.compiler.utils import _root_only_custom_code @@ -29,6 +37,242 @@ def test_render_no_on_submit(): assert f.event_triggers["on_submit"].events[0] == prevent_default +@pytest.mark.parametrize("form_factory", [HTMLForm.create, Form.create]) +def test_on_submit_accepts_typed_dict_form_data(form_factory): + """TypedDict-annotated submit handlers should be accepted.""" + + class SignupData(TypedDict): + name: str + email: str + + class SignupState(rx.State): + @rx.event + def on_submit(self, form_data: SignupData): + pass + + form = form_factory( + Input.create(name="name"), + Input.create(name="email"), + on_submit=SignupState.on_submit, + ) + + assert isinstance(form.event_triggers["on_submit"], EventChain) + + +def test_on_submit_accepts_id_backed_typed_dict_form_data(): + """Static ids that are mirrored into form_data should satisfy TypedDict keys.""" + + class SignupData(TypedDict): + email_input: str + + class SignupState(rx.State): + @rx.event + def on_submit(self, form_data: SignupData): + pass + + form = HTMLForm.create( + Input.create(id="email_input"), + on_submit=SignupState.on_submit, + ) + + assert isinstance(form.event_triggers["on_submit"], EventChain) + + +def test_on_submit_accepts_typed_dict_with_optional_fields(): + """Optional TypedDict keys should not be required in the form.""" + + class SignupData(TypedDict): + email: str + nickname: NotRequired[str] + + class SignupState(rx.State): + @rx.event + def on_submit(self, form_data: SignupData): + pass + + # RED: without NotRequired handling, nickname would be treated as required + # and the form below (which only has "email") would raise. + form = HTMLForm.create( + Input.create(name="email"), + on_submit=SignupState.on_submit, + ) + assert isinstance(form.event_triggers["on_submit"], EventChain) + + # Prove validation is active: a truly missing required field still raises. + class StrictData(TypedDict): + email: str + nickname: str + + class StrictState(rx.State): + @rx.event + def on_submit(self, form_data: StrictData): + pass + + with pytest.raises(EventHandlerValueError): + HTMLForm.create( + Input.create(name="email"), + on_submit=StrictState.on_submit, + ) + + +def test_on_submit_allows_extra_typed_dict_form_fields(): + """Forms may include more fields than the TypedDict requires.""" + + class SignupData(TypedDict): + email: str + + class SignupState(rx.State): + @rx.event + def on_submit(self, form_data: SignupData): + pass + + form = HTMLForm.create( + Input.create(name="email"), + Input.create(name="nickname"), + on_submit=SignupState.on_submit, + ) + + assert isinstance(form.event_triggers["on_submit"], EventChain) + + +def test_on_submit_resolves_typed_dict_after_bound_args(): + """The final submit payload parameter should still resolve after binding args.""" + + class SignupData(TypedDict): + email: str + + class SignupState(rx.State): + @rx.event + def on_submit(self, source: str, form_data: SignupData): + pass + + form = HTMLForm.create( + Input.create(name="email"), + on_submit=SignupState.on_submit("marketing"), # pyright: ignore [reportCallIssue] + ) + + assert isinstance(form.event_triggers["on_submit"], EventChain) + + +def test_on_submit_typed_dict_missing_fields_raises_helpful_error(): + """Missing required TypedDict keys should produce a focused compile-time error.""" + + class SignupData(TypedDict): + fname: str + lname: str + email: str + + class SignupState(rx.State): + @rx.event + def on_submit(self, form_data: SignupData): + pass + + with pytest.raises(EventHandlerValueError) as err: + HTMLForm.create( + Input.create(name="email"), + on_submit=SignupState.on_submit, + ) + + error = str(err.value) + assert "Form field mismatch for on_submit handler" in error + assert "SignupState.on_submit" in error + assert "SignupData" in error + assert '"fname"' in error + assert '"lname"' in error + assert '"email"' in error + assert "Matching fields present in the form" in error + + +def test_on_submit_accepts_typed_dict_with_inherited_optional_fields(): + """Inherited optional TypedDict keys should remain optional.""" + + class BaseSignupData(TypedDict, total=False): + nickname: str + + class SignupData(BaseSignupData): + email: str + + class SignupState(rx.State): + @rx.event + def on_submit(self, form_data: SignupData): + pass + + # RED: without proper inheritance handling, nickname (from the total=False + # parent) would be treated as required, and this form would raise. + form = HTMLForm.create( + Input.create(name="email"), + on_submit=SignupState.on_submit, + ) + assert isinstance(form.event_triggers["on_submit"], EventChain) + + # Prove the inherited field IS accepted when provided. + form_with_both = HTMLForm.create( + Input.create(name="email"), + Input.create(name="nickname"), + on_submit=SignupState.on_submit, + ) + assert isinstance(form_with_both.event_triggers["on_submit"], EventChain) + + +def test_on_submit_accepts_controls_associated_via_form_attribute(): + """Controls associated via the HTML form attribute should not fail validation.""" + + class SignupData(TypedDict): + email: str + + class SignupState(rx.State): + @rx.event + def on_submit(self, form_data: SignupData): + pass + + # RED: without the form-id escape hatch, this would raise + # EventHandlerValueError because the form has no child inputs + # matching the TypedDict's required "email" field. + # (The input is associated externally via form="signup".) + form = HTMLForm.create( + id="signup", + on_submit=SignupState.on_submit, + ) + Input.create(name="email", form="signup") + + assert isinstance(form.event_triggers["on_submit"], EventChain) + + # Verify it WOULD fail without the id (proving the escape hatch matters). + with pytest.raises(EventHandlerValueError): + HTMLForm.create( + on_submit=SignupState.on_submit, + ) + + +def test_on_submit_typed_dict_skips_dynamic_field_identifiers(): + """Dynamic field names should skip strict validation instead of raising.""" + + class SignupData(TypedDict): + email: str + + class SignupState(rx.State): + @rx.event + def on_submit(self, form_data: SignupData): + pass + + # RED: without the dynamic-field escape hatch, this would raise + # because "email" isn't statically present. The dynamic Var name + # could resolve to "email" at runtime, so validation must be skipped. + form = HTMLForm.create( + Input.create(name=Var(_js_expr="dynamic_name", _var_type=str)), + on_submit=SignupState.on_submit, + ) + + assert isinstance(form.event_triggers["on_submit"], EventChain) + + # Verify it WOULD fail with a static non-matching name. + with pytest.raises(EventHandlerValueError): + HTMLForm.create( + Input.create(name="wrong_field"), + on_submit=SignupState.on_submit, + ) + + def test_textarea_enter_key_submit_emits_helper(): """`enter_key_submit=True` must inject the onKeyDown helper into the page.""" ta = Textarea.create(enter_key_submit=True) diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index d0e2aa09c06..3aad6aaf53b 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -1,7 +1,7 @@ import copy from contextlib import nullcontext from dataclasses import dataclass -from typing import Any, ClassVar +from typing import Any, ClassVar, TypedDict import pytest from reflex_base.components.component import Component, field @@ -840,6 +840,34 @@ def get_event_triggers(cls) -> dict[str, Any]: C1.create(on_foo=C1State.mock_handler) +def test_non_submit_mapping_events_do_not_accept_typed_dict_handlers(): + """TypedDict relaxation should stay scoped to form submission handlers.""" + + class Payload(TypedDict): + email: str + + class C1State(BaseState): + def mock_handler(self, payload: Payload): + """Mock handler.""" + + def on_foo_spec(payload: Var[dict[str, int]]) -> tuple[Var[dict[str, int]]]: + return (payload,) + + class C1(Component): + library = "/local" + tag = "C1" + + @classmethod + def get_event_triggers(cls) -> dict[str, Any]: + return { + **super().get_event_triggers(), + "on_foo": on_foo_spec, + } + + with pytest.raises(EventHandlerArgTypeMismatchError): + C1.create(on_foo=C1State.mock_handler) + + def test_invalid_event_handler_args(component2, test_state: type[TestState]): """Test that an invalid event handler raises an error.