Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Don't forget to remove deprecated code on each major release!
- Replaced `web.module_from_file`/`web.export` with `reactjs.component_from_file`.
- Replaced `reactpy.backend.types` and `reactpy.core.types` imports with `reactpy.types`.
- Renamed `Location.pathname` to `Location.path` and `Location.search` to `Location.query_string`.
- Improved `django_form` submission handling to support multi-value form fields (e.g., `MultipleChoiceField`, `MultiValueField`) by fixing a client-side data serialization issue.

### Removed

Expand Down
22 changes: 19 additions & 3 deletions src/js/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,31 @@ export class DjangoForm extends React.Component<DjangoFormProps> {
event.preventDefault();
const formData = new FormData(form);

// Convert the FormData object to a plain object
const formObject = Object.fromEntries(formData.entries());
// Accumulate duplicate keys into arrays to support multi-select fields
// (e.g. MultipleChoiceField). Object.fromEntries would silently drop
// duplicate entries, keeping only the last value per key.
const formObject: Record<
string,
FormDataEntryValue | FormDataEntryValue[]
> = {};
for (const [key, value] of formData.entries()) {
if (Object.prototype.hasOwnProperty.call(formObject, key)) {
const existing = formObject[key];
if (Array.isArray(existing)) {
existing.push(value);
} else {
formObject[key] = [existing, value];
}
} else {
formObject[key] = value;
}
}

onSubmitCallback(formObject);
};

if (form) {
form.addEventListener("submit", onSubmitEvent);
// Store cleanup function in instance
(this as any)._cleanup = () => {
form.removeEventListener("submit", onSubmitEvent);
};
Expand Down
18 changes: 12 additions & 6 deletions src/reactpy_django/forms/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
from asyncio import iscoroutinefunction
from logging import getLogger
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Union, cast
from typing import TYPE_CHECKING, Any, Callable, cast
from uuid import uuid4

from django.forms import Form, ModelForm
from reactpy import component, hooks, html, utils
from reactpy.core.events import event
from reactpy.reactjs import component_from_file

from reactpy_django.forms.transforms import (
Expand Down Expand Up @@ -57,7 +56,8 @@ def _django_form(
top_children_count = hooks.use_ref(len(top_children))
bottom_children_count = hooks.use_ref(len(bottom_children))
submitted_data, set_submitted_data = hooks.use_state({} or None)
rendered_form, set_rendered_form = hooks.use_state(cast("Union[str, None]", None))
rendered_form, set_rendered_form = hooks.use_state(cast("str | None", None))
render_count, set_render_count = hooks.use_state(0)

# Initialize the form with the provided data
validate_form_args(top_children, top_children_count, bottom_children, bottom_children_count, form)
Expand Down Expand Up @@ -93,6 +93,7 @@ async def render_form():
await ensure_async(initialized_form.save)()
set_submitted_data(None)

set_render_count(render_count + 1)
set_rendered_form(
await ensure_async(initialized_form.render)(form_template or config.REACTPY_DEFAULT_FORM_TEMPLATE)
)
Expand Down Expand Up @@ -124,10 +125,15 @@ async def _on_change(_event):
if not rendered_form:
return None

form_props = {
# Note: `key` is intentionally left stable (does not include `render_count`) so the
# client-side `DjangoForm` component is not torn down and re-mounted on every render.
# The `DjangoForm` registers a native `submit` listener that calls `preventDefault()`
# and forwards the submitted FormData via `onSubmitCallback`; keeping that component
# (and its listener) alive across re-renders guarantees the browser never navigates
# away natively, while still letting each submission reach the server.
form_props: dict[str, Any] = {
"id": f"reactpy-{uuid}",
# Intercept the form submission to prevent the browser from navigating
"onSubmit": event(lambda _: None, prevent_default=True),
"key": f"reactpy-{uuid}",
}
if on_change:
form_props["onChange"] = _on_change
Expand Down
60 changes: 38 additions & 22 deletions src/reactpy_django/forms/transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,32 @@ def convert_textarea_children_to_prop(vdom_tree: VdomDict) -> VdomDict:


def set_value_prop_on_select_element(vdom_tree: VdomDict) -> VdomDict:
"""Use the `value` prop on <select> instead of setting `selected` on <option>."""
# If the current tag is <select>, remove 'selected' prop from any <option> children and
# instead set the 'value' prop on the <select> tag.
"""Set the correct selection props on a <select> and its <option> children.

ReactPy's built-in ``RequiredTransforms.select_element_to_reactjs`` (which runs during
``string_to_reactpy``, before this transform) removes the ``selected`` attribute from
each ``<option>`` and records the selected value(s) as ``defaultValue`` on the parent
``<select>`` element. That works with React, but Preact does **not** apply a ``<select>``
element's ``defaultValue`` prop to its ``<option>`` children. As a result the selection is
lost when a form remounts/``<option>`` nodes are re-created (e.g. after a submission).

To preserve selection for Preact, this transform reads the ``defaultValue`` already set on
the ``<select>`` and sets the ``selected`` prop directly on each matching ``<option>``
element, which Preact applies as the option's DOM ``selected`` property.
"""
if vdom_tree["tagName"] == "select" and "children" in vdom_tree:
vdom_tree.setdefault("attributes", {})
selected_options = _find_selected_options(vdom_tree)
multiple_choice = vdom_tree["attributes"]["multiple"] = bool(vdom_tree["attributes"].get("multiple"))
if selected_options and not multiple_choice:
vdom_tree["attributes"]["defaultValue"] = selected_options[0]
if selected_options and multiple_choice:
vdom_tree["attributes"]["defaultValue"] = selected_options
attributes = vdom_tree["attributes"]
attributes["multiple"] = bool(attributes.get("multiple"))

# Preact ignores a <select>'s `defaultValue`, so propagate the selected value(s)
# (already stored on the <select> by reactpy's builtin transform) onto the
# matching <option> elements as `selected`.
selected_values = attributes.get("defaultValue")
if isinstance(selected_values, str):
selected_values = [selected_values]
if selected_values:
_set_selected_on_options(vdom_tree, set(selected_values))

return vdom_tree

Expand Down Expand Up @@ -87,24 +102,25 @@ def infer_key_from_attributes(vdom_tree: VdomDict) -> VdomDict:
return vdom_tree


def _find_selected_options(vdom_node: Any) -> list[str]:
"""Recursively iterate through the tree to find all <option> tags with the 'selected' prop.
Removes the 'selected' prop and returns a list of the 'value' prop of each selected <option>."""
def _set_selected_on_options(vdom_node: Any, selected_values: set[str]) -> None:
"""Recursively mark the matching <option> elements as selected.

We set the ``selected`` prop on the <option> element. Preact applies ``selected``
as a DOM property on ``HTMLOptionElement``, which updates the option's ``selected``
state on every re-render. This is required because Preact does not apply a
<select>'s ``defaultValue`` prop to its <option> children, so selection is otherwise
lost when a form remounts (e.g. on submit).
"""
if not isinstance(vdom_node, dict):
return []
return

selected_options = []
if vdom_node["tagName"] == "option" and "attributes" in vdom_node:
value = vdom_node["attributes"].setdefault("value", vdom_node["children"][0])

if "selected" in vdom_node["attributes"]:
vdom_node["attributes"].pop("selected")
selected_options.append(value)
value = vdom_node["attributes"].get("value")
if value in selected_values:
vdom_node["attributes"]["selected"] = True

for child in vdom_node.get("children", []):
selected_options.extend(_find_selected_options(child))

return selected_options
_set_selected_on_options(child, selected_values)


def _normalize_prop_name(prop_name: str) -> str:
Expand Down
31 changes: 27 additions & 4 deletions src/reactpy_django/forms/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@

from typing import TYPE_CHECKING, Any

from django.forms import BooleanField, Form, ModelForm, ModelMultipleChoiceField, MultipleChoiceField, NullBooleanField
from django.forms import (
BooleanField,
Form,
ModelForm,
ModelMultipleChoiceField,
MultipleChoiceField,
NullBooleanField,
)

if TYPE_CHECKING:
from collections.abc import Sequence
Expand All @@ -11,11 +18,28 @@


def convert_form_fields(data: dict[str, Any], initialized_form: Form | ModelForm) -> None:
"""Convert submitted form data into the format expected by Django fields.

This handles the mismatch between browser FormData serialisation and
Django's field-level expectations:

* ``MultipleChoiceField`` / ``ModelMultipleChoiceField`` – always stored as a
list. When no option is selected the key may be absent or ``None``; we
normalise to an empty list.
* ``BooleanField`` (non-null) – represented by the browser as "key present
= checked". We convert to ``True``/``False``.
* All other fields (including ``MultiValueField``, ``SplitDateTimeField``)
are passed through unchanged. Their sub-widgets already produce unique
``_0``, ``_1``, … keys so no re-shaping is needed.
"""
for field_name, field in initialized_form.fields.items():
value = data.get(field_name)

if isinstance(field, (MultipleChoiceField, ModelMultipleChoiceField)) and value is not None:
data[field_name] = value if isinstance(value, list) else [value]
if isinstance(field, (MultipleChoiceField, ModelMultipleChoiceField)):
if value is None:
data[field_name] = []
elif not isinstance(value, list):
data[field_name] = [value]

elif isinstance(field, BooleanField) and not isinstance(field, NullBooleanField):
data[field_name] = field_name in data
Expand All @@ -28,7 +52,6 @@ def validate_form_args(
bottom_children_count: Ref[int],
form: type[Form | ModelForm],
) -> None:
# Validate the provided arguments
if len(top_children) != top_children_count.current or len(bottom_children) != bottom_children_count.current:
msg = "Dynamically changing the number of top or bottom children is not allowed."
raise ValueError(msg)
Expand Down
56 changes: 48 additions & 8 deletions tests/test_app/tests/test_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,24 @@ def test_form_basic(self):
# Make sure no errors remain
assert len(self.page.query_selector_all(".errorlist")) == 0

# Verify multi-select field values survived the round-trip
# After successful submission, the re-rendered form should have
# the same options selected, proving the FormData duplicate-key fix worked.
# NOTE: `input_value()` on a multi-select returns only the first selected
# value as a string, so we read the selected <option> values directly.
def _selected_values(selector: str) -> list[str]:
return self.page.locator(f"{selector} option:checked").evaluate_all("els => els.map(e => e.value)")

assert sorted(_selected_values("#id_multiple_choice_field")) == ["2", "3"]
assert sorted(_selected_values("#id_typed_multiple_choice_field")) == ["1", "2"]

# Verify model multi-select field values survived the round-trip
model_choice_selected = _selected_values("#id_model_multiple_choice_field")
assert sorted(model_choice_selected) == sorted([
model_choice_field_values[1],
model_choice_field_values[2],
])

@navigate_to_page("/form/bootstrap/")
def test_form_bootstrap(self):
try:
Expand Down Expand Up @@ -880,6 +898,28 @@ def test_form_orm_model(self):
finally:
os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE")

def _retry_until_true(self, selector: str) -> None:
"""Poll for `#success[data-value='true']` after submitting the filled form.

Filling the `char_field` triggers the form's `onChange` handler, which causes an
async server re-render that reconciles (and can transiently remount) the submit
button. A single Playwright click on `input[type=submit]` may therefore land on
the form element instead of the button, swallowing the `submit` event and losing
the valid submission. This helper re-fills the field and re-clicks submit until
the `on_success` callback actually takes effect, making the interaction
deterministic instead of racing the reconciliation.
"""
for _ in range(3):
self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY)
self.page.wait_for_selector("input[type=submit]").click(delay=DELAY)
try:
self.page.wait_for_selector(selector, timeout=5000)
return
except PlaywrightTimeoutError:
continue
# Let the final wait raise the real failure if it still never succeeds.
self.page.wait_for_selector(selector)

@navigate_to_page("/form/sync_event/")
def test_form_sync_events(self):
self.page.wait_for_selector("form")
Expand All @@ -899,12 +939,12 @@ def test_form_sync_events(self):
self.page.wait_for_selector("#receive_data[data-value='true']")
self.page.wait_for_selector("#change[data-value='false']")

# Fill out the form and re-submit
self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY)
self.page.wait_for_selector("input[type=submit]").click(delay=DELAY)
# Fill out the form and re-submit. The `onChange`-triggered async re-render can
# transiently remount the submit button, so retry (re-filling + re-clicking) until
# the `on_success` callback visibly takes effect rather than racing the reconcile.
self._retry_until_true("#success[data-value='true']")

# Form should have been successfully submitted
self.page.wait_for_selector("#success[data-value='true']")
self.page.wait_for_selector("#error[data-value='true']")
self.page.wait_for_selector("#receive_data[data-value='true']")
self.page.wait_for_selector("#change[data-value='true']")
Expand All @@ -928,12 +968,12 @@ def test_form_async_events(self):
self.page.wait_for_selector("#receive_data[data-value='true']")
self.page.wait_for_selector("#change[data-value='false']")

# Fill out the form and re-submit
self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY)
self.page.wait_for_selector("input[type=submit]").click(delay=DELAY)
# Fill out the form and re-submit. The `onChange`-triggered async re-render can
# transiently remount the submit button, so retry (re-filling + re-clicking) until
# the `on_success` callback visibly takes effect rather than racing the reconcile.
self._retry_until_true("#success[data-value='true']")

# Form should have been successfully submitted
self.page.wait_for_selector("#success[data-value='true']")
self.page.wait_for_selector("#error[data-value='true']")
self.page.wait_for_selector("#receive_data[data-value='true']")
self.page.wait_for_selector("#change[data-value='true']")
Loading
Loading