Skip to content
6 changes: 3 additions & 3 deletions docs/library/forms/form-ll.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,10 @@ rx.code_block(
)
```

A Form Root (`form.root`) contains all the parts of a form. The Form Field (`form.field`), Form Submit (`form.submit`), etc should all be inside a Form Root. A Form Field can contain a Form Label (`form.label`), a Form Control (`form.control`), and a Form Message (`form.message`). A Form Label is a label element. A Form Control is where the user enters the input or makes selections. By default, the Form Control is a input. Using other form components to construct the Form Control is supported. To do that, set the prop `as_child=True` on the Form Control.
A Form Root (`form.root`) contains all the parts of a form. The Form Field (`form.field`), Form Submit (`form.submit`), etc should all be inside a Form Root. A Form Field can contain a Form Label (`form.label`), a Form Control (`form.control`), and a Form Message (`form.message`). A Form Label is a label element. A Form Control is where the user enters the input or makes selections. By default, the Form Control is an input. It can instead render the control passed as its child: `rx.input`, `rx.text_area`, `rx.debounce_input`, `rx.el.input`, `rx.el.textarea` or `rx.el.select`. That control takes the `name` of its Form Field, and its validation messages, when neither the control nor the Form Control sets a `name`.

```md alert info
The current version of Radix Forms does not support composing **Form Control** with other Radix form primitives such as **Checkbox**, **Select**, etc.
The current version of Radix Forms does not support composing **Form Control** with other Radix form primitives such as **Checkbox**, **Select**, etc. Give those controls a `name` and include them directly under the Form Root or Form Field; they are submitted with the form but not validated.
```

The Form Message is a validation message which is automatically wired (functionality and accessibility). The `match` prop selects which [client side validation](#client-side-validation) failure displays the message. To perform [server side validation](#server-side-validation), **both** the `force_match` prop of the Form Message and the `server_invalid` prop of the Form Field are set together.
Expand All @@ -170,7 +170,7 @@ The `on_submit` prop of the Form Root accepts an event handler. It is called wit

## Data Submission

As previously mentioned, the various pieces of data in the form are submitted together as a dictionary. The form control or the input components must have the `name` attribute. This `name` is the key to get the value from the form data dictionary. A control rendered by a Form Control takes the `name` of its Form Field when neither the control nor the Form Control sets one. If no validation is needed, the form type components such as Checkbox, Radio Groups, TextArea can be included directly under the Form Root instead of inside a Form Control.
As previously mentioned, the various pieces of data in the form are submitted together as a dictionary. The form control or the input components must have the `name` attribute. This `name` is the key to get the value from the form data dictionary. A control rendered by a Form Control takes the `name` of its Form Field when neither the control nor the Form Control sets one. If no validation is needed, form components such as Checkbox, Radio Group or Select can be included directly under the Form Root with a `name` instead of inside a Form Control.

```python demo exec
import reflex as rx
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`rx.form.control` now accepts `rx.text_area`, `rx.el.input`, `rx.el.textarea` and `rx.el.select` as its child, besides `rx.input` and `rx.debounce_input`, and renders a child as the control without `as_child=True`.
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@
from reflex_base.utils.form import FormData as _FormData
from reflex_base.vars.base import Var
from reflex_components_core.core.debounce import DebounceInput
from reflex_components_core.el.elements.forms import BaseInput, Select, Textarea
from reflex_components_core.el.elements.forms import Form as HTMLForm

from reflex_components_radix.themes.components.text_field import TextFieldRoot

from .base import RadixPrimitiveComponentWithClassName

# Radix Form.Control composes with native form controls only: an input, select
# or textarea, or a component rendering one. Its other primitives, such as
# Checkbox and Select, do not compose with it.
_FORM_CONTROL_CHILD_TYPES = (BaseInput, Select, Textarea, DebounceInput)
Comment thread
FarhanAliRaza marked this conversation as resolved.
Resolved
Comment thread
FarhanAliRaza marked this conversation as resolved.
Resolved


class FormComponent(RadixPrimitiveComponentWithClassName):
"""Base class for all @radix-ui/react-form components."""
Expand Down Expand Up @@ -104,24 +108,38 @@ class FormControl(FormComponent):
def create(cls, *children, **props):
"""Create a Form Control component.

The control renders an input, or the native form control its child
renders, which then takes the enclosing form field's name and
validation messages.

Args:
*children: The children of the form.
**props: The properties of the form.
*children: The control to render, if any.
**props: The properties of the form control.

Returns:
The form control component.

Raises:
ValueError: If the number of children is greater than 1.
TypeError: If a child exists but it is not a TextFieldInput.
ValueError: If more than one child is given.
TypeError: If the child does not render a native form control.
"""
if len(children) > 1:
msg = f"FormControl can only have at most one child, got {len(children)} children"
raise ValueError(msg)
for child in children:
if not isinstance(child, (TextFieldRoot, DebounceInput)):
msg = "Only Radix TextFieldRoot and DebounceInput are allowed as children of FormControl"
if not isinstance(child, _FORM_CONTROL_CHILD_TYPES):
msg = (
"The child of rx.form.control must render a native form "
"control: rx.input, rx.text_area, rx.debounce_input, "
"rx.el.input, rx.el.select or rx.el.textarea, not "
f"{type(child).__name__}. Radix forms do not compose with "
"other controls such as rx.checkbox or rx.select; give those "
"a `name` and place them under rx.form.root or rx.form.field "
"instead."
)
raise TypeError(msg)
# Radix renders the child as the control only with asChild.
props["as_child"] = True
return super().create(*children, **props)


Expand Down
2 changes: 1 addition & 1 deletion pyi_hashes.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "c6500b29e90ca0c04e6bac65ad19936e",
"packages/reflex-components-radix/src/reflex_components_radix/primitives/dialog.pyi": "e67999e6a7433d0eb8c18f4dd185761b",
"packages/reflex-components-radix/src/reflex_components_radix/primitives/drawer.pyi": "172048f74ea5cb7b0e68829d5de3a1fa",
"packages/reflex-components-radix/src/reflex_components_radix/primitives/form.pyi": "e378658b5834e01ab3528c2f2f434eeb",
"packages/reflex-components-radix/src/reflex_components_radix/primitives/form.pyi": "6a8c38c136af4796436c0bf7170957df",
"packages/reflex-components-radix/src/reflex_components_radix/primitives/progress.pyi": "28496d25212d041a079116381b7d4d51",
"packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.pyi": "2846f40bff377ee688d46055c4f7390a",
"packages/reflex-components-radix/src/reflex_components_radix/themes/__init__.pyi": "6c7e1b9a0a7ca00f029bf6ba0c514fdb",
Expand Down
144 changes: 144 additions & 0 deletions tests/integration/tests_playwright/test_radix_form_control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Integration tests for the controls ``rx.form.control`` renders.

Radix ``Form.Control`` renders an input or, with ``as_child``, the native form
control its child renders, and passes it the enclosing field's ``name`` and
the ``id`` its label points to. Every control the Python wrapper accepts must
receive both, submit under the field's name and take part in validation.
"""

import json
from collections.abc import Generator

import pytest
from playwright.sync_api import Page, expect

from reflex.testing import AppHarness

FIELD_NAMES = (
Comment thread
FarhanAliRaza marked this conversation as resolved.
Resolved
"input",
"controlled",
"text_area",
"el_input",
"el_textarea",
"el_select",
)


def RadixFormControlApp():
"""A Radix form with one field per kind of control."""
import reflex as rx

class State(rx.State):
typed: rx.Field[str] = rx.field("")
submitted: rx.Field[dict[str, str]] = rx.field(default_factory=dict)

@rx.event
def set_typed(self, value: str):
self.typed = value

@rx.event
def on_submit(self, form_data: dict[str, str]):
self.submitted = form_data

def form_field(name: str, control: rx.Component) -> rx.Component:
return rx.form.field(
rx.form.label(name),
rx.form.control(control),
rx.form.message("Required", match="valueMissing", id=f"{name}-message"),
name=name,
)

@rx.page("/")
def index():
return rx.vstack(
rx.input(
value=State.router.session.client_token, read_only=True, id="token"
),
rx.form.root(
form_field("input", rx.input(default_value="input value")),
form_field(
"controlled",
rx.input(value=State.typed, on_change=State.set_typed),
),
form_field("text_area", rx.text_area(required=True)),
form_field("el_input", rx.el.input(default_value="native input")),
form_field(
"el_textarea", rx.el.textarea(default_value="native textarea")
),
form_field(
"el_select",
rx.el.select(
rx.el.option("Apple", value="apple"),
rx.el.option("Pear", value="pear"),
default_value="pear",
),
),
rx.form.submit(rx.button("Submit", id="submit"), as_child=True),
on_submit=State.on_submit,
),
rx.text(State.typed, id="typed"),
rx.text(State.submitted.to_string(), id="submitted"),
)

app = rx.App() # noqa: F841


@pytest.fixture(scope="module")
def form_control_app(tmp_path_factory) -> Generator[AppHarness, None, None]:
"""Start RadixFormControlApp via AppHarness.

Args:
tmp_path_factory: pytest fixture for creating temporary directories.

Yields:
Running AppHarness instance.
"""
with AppHarness.create(
root=tmp_path_factory.mktemp("radix_form_control"),
app_source=RadixFormControlApp,
) as harness:
assert harness.app_instance is not None, "app is not running"
yield harness


def test_form_controls_take_their_field_name(form_control_app: AppHarness, page: Page):
"""Each control carries its field's name, its label's id, and validates.

Args:
form_control_app: AppHarness running the test app.
page: Playwright page.
"""
assert form_control_app.frontend_url is not None

page_errors: list[str] = []
page.on("pageerror", lambda exc: page_errors.append(str(exc)))

page.goto(form_control_app.frontend_url)
expect(page.locator("#token")).not_to_have_value("")

# The label resolves through the id Radix gives the control.
for name in FIELD_NAMES:
expect(page.get_by_label(name, exact=True)).to_have_attribute("name", name)

# The required textarea blocks submission and shows its field's message.
page.click("#submit")
expect(page.locator("#text_area-message")).to_have_text("Required")
expect(page.locator("#submitted")).to_have_text("{}")

page.get_by_label("controlled", exact=True).fill("typed value")
expect(page.locator("#typed")).to_have_text("typed value")
page.get_by_label("text_area", exact=True).fill("textarea value")
page.get_by_label("el_select", exact=True).select_option("apple")
page.click("#submit")

expect(page.locator("#text_area-message")).to_have_count(0)
expect(page.locator("#submitted")).not_to_have_text("{}")
assert json.loads(page.locator("#submitted").text_content() or "") == {
"input": "input value",
"controlled": "typed value",
"text_area": "textarea value",
"el_input": "native input",
"el_textarea": "native textarea",
"el_select": "apple",
}
assert page_errors == []
44 changes: 44 additions & 0 deletions tests/units/components/forms/test_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,50 @@ def test_on_submit_warns_for_controls_not_named_by_a_form_field(
)


@pytest.mark.parametrize(
"control",
[
lambda: rx.input(),
lambda: rx.input(
value=Var(_js_expr="typed", _var_type=str), on_change=rx.console_log
),
lambda: rx.debounce_input(rx.input(on_change=rx.console_log)),
lambda: rx.text_area(),
lambda: rx.el.input(),
lambda: rx.el.textarea(),
lambda: rx.el.select(rx.el.option("Apple", value="apple")),
],
)
@pytest.mark.parametrize("props", [{}, {"as_child": True}, {"as_child": False}])
def test_form_control_renders_native_controls_as_child(control, props):
"""A form control renders a component backed by a native form control."""
form_control = rx.form.control(control(), **props)
assert form_control.render()["props"] == ["asChild:true", 'className:"Control "']


@pytest.mark.parametrize(
"control",
[
lambda: rx.checkbox(),
lambda: rx.select(["apple"]),
lambda: rx.switch(),
lambda: rx.box(),
],
)
def test_form_control_rejects_other_components(control):
"""A form control composes with nothing but native form controls."""
child = control()
with pytest.raises(TypeError) as excinfo:
rx.form.control(child)
assert str(excinfo.value) == (
"The child of rx.form.control must render a native form control: "
"rx.input, rx.text_area, rx.debounce_input, rx.el.input, rx.el.select or "
f"rx.el.textarea, not {type(child).__name__}. Radix forms do not compose "
"with other controls such as rx.checkbox or rx.select; give those a "
"`name` and place them under rx.form.root or rx.form.field instead."
)


def test_form_control_name_is_passed_to_radix_and_names_its_child():
"""A form control's own name reaches Radix and replaces the field's name."""

Expand Down
Loading