Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,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
3 changes: 3 additions & 0 deletions src/reactpy_django/forms/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def _django_form(
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))
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 +94,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 @@ -126,6 +128,7 @@ async def _on_change(_event):

form_props = {
"id": f"reactpy-{uuid}",
"key": f"reactpy-{uuid}-{render_count}",
# Intercept the form submission to prevent the browser from navigating
"onSubmit": event(lambda _: None, prevent_default=True),
}
Expand Down
11 changes: 9 additions & 2 deletions src/reactpy_django/forms/transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,15 @@ def infer_key_from_attributes(vdom_tree: VdomDict) -> VdomDict:

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>."""
Returns a list of the 'value' prop of each selected <option>.

.. note::
We intentionally do **not** remove the ``selected`` prop from the ``<option>`` elements.
The ``defaultValue`` attribute is already set on the ``<select>`` element for initial
mount in React/Preact, but it is only applied once (on mount). Keeping ``selected``
on the ``<option>`` elements ensures that selection state is correctly restored after
the form is re-rendered (e.g. after a form submission that does not trigger a full
Preact remount)."""
if not isinstance(vdom_node, dict):
return []

Expand All @@ -98,7 +106,6 @@ def _find_selected_options(vdom_node: Any) -> list[str]:
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)

for child in vdom_node.get("children", []):
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
13 changes: 13 additions & 0 deletions tests/test_app/tests/test_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,19 @@ 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.
assert self.page.locator("#id_multiple_choice_field").input_value() == ["2", "3"]
assert self.page.locator("#id_typed_multiple_choice_field").input_value() == ["1", "2"]

# Verify model multi-select field values survived the round-trip
model_choice_selected = self.page.locator("#id_model_multiple_choice_field").input_value()
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
122 changes: 122 additions & 0 deletions tests/test_app/tests/test_forms_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Unit tests for :func:`reactpy_django.forms.utils.convert_form_fields`."""

from __future__ import annotations

from unittest.mock import MagicMock

from django.forms import (
BooleanField,
CharField,
Form,
ModelMultipleChoiceField,
MultipleChoiceField,
NullBooleanField,
)
from django.test import TestCase

from reactpy_django.forms.utils import convert_form_fields


class ConvertFormFieldsTests(TestCase):
"""Test that ``convert_form_fields`` normalises form data to the format
Django expects."""

def _make_form(self, **fields):
"""Helper: return an initialised form whose fields dict can be
inspected without running full validation."""
form = MagicMock(spec=Form)
form.fields = fields
return form

# ── MultipleChoiceField / ModelMultipleChoiceField ────────────────

def test_multi_choice_field_none_becomes_empty_list(self):
"""When a MultipleChoiceField has no selection (value is None),
convert it to ``[]`` so Django's validator receives the type it expects."""
form = self._make_form(
choice=MultipleChoiceField(choices=[("1", "A"), ("2", "B")]),
)
data = {}
convert_form_fields(data, form)
assert data == {"choice": []}

def test_multi_choice_field_single_value_becomes_list(self):
"""When a MultipleChoiceField receives a single value (from the
client-side code before the duplicate-key fix), wrap it in a list."""
form = self._make_form(
choice=MultipleChoiceField(choices=[("1", "A"), ("2", "B")]),
)
data = {"choice": "1"}
convert_form_fields(data, form)
assert data == {"choice": ["1"]}

def test_multi_choice_field_already_list_stays_list(self):
"""When a MultipleChoiceField already has a list value (the normal
case after the client-side FormData fix), leave it unchanged."""
form = self._make_form(
choice=MultipleChoiceField(choices=[("1", "A"), ("2", "B")]),
)
data = {"choice": ["1", "2"]}
convert_form_fields(data, form)
assert data == {"choice": ["1", "2"]}

def test_model_multi_choice_field_none_becomes_empty_list(self):
"""ModelMultipleChoiceField with no selection: same None → [] rule."""
form = self._make_form(
model_choice=ModelMultipleChoiceField(queryset=MagicMock()),
)
data = {}
convert_form_fields(data, form)
assert data == {"model_choice": []}

def test_model_multi_choice_field_single_value_becomes_list(self):
"""ModelMultipleChoiceField with a single value: wrap in list."""
form = self._make_form(
model_choice=ModelMultipleChoiceField(queryset=MagicMock()),
)
data = {"model_choice": "1"}
convert_form_fields(data, form)
assert data == {"model_choice": ["1"]}

def test_model_multi_choice_field_already_list_stays_list(self):
"""ModelMultipleChoiceField with list: unchanged."""
form = self._make_form(
model_choice=ModelMultipleChoiceField(queryset=MagicMock()),
)
data = {"model_choice": ["1", "2"]}
convert_form_fields(data, form)
assert data == {"model_choice": ["1", "2"]}

# ── BooleanField ──────────────────────────────────────────────────

def test_boolean_field_checked_becomes_true(self):
"""A present key (browser sends it when checked) → True."""
form = self._make_form(flag=BooleanField())
data = {"flag": "on"}
convert_form_fields(data, form)
assert data == {"flag": True}

def test_boolean_field_unchecked_becomes_false(self):
"""An absent key (browser omits it when unchecked) → False."""
form = self._make_form(flag=BooleanField())
data = {}
convert_form_fields(data, form)
assert data == {"flag": False}

def test_null_boolean_field_not_touched(self):
"""NullBooleanField is *not* converted — it has three states and
should be handled by Django's own logic."""
form = self._make_form(flag=NullBooleanField())
data = {"flag": "unknown"}
convert_form_fields(data, form)
assert data == {"flag": "unknown"}

# ── Other field types (pass-through) ──────────────────────────────

def test_other_fields_passed_through(self):
"""Fields that aren't special-cased (e.g. CharField) are left
exactly as they arrived."""
form = self._make_form(chars=CharField())
data = {"chars": "hello"}
convert_form_fields(data, form)
assert data == {"chars": "hello"}
Loading