Skip to content

Commit cf08add

Browse files
committed
Move code to forms module
1 parent 164e3a3 commit cf08add

File tree

5 files changed

+154
-117
lines changed

5 files changed

+154
-117
lines changed

src/reactpy_django/components.py

+2-112
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,20 @@
22

33
import json
44
import os
5-
from pathlib import Path
65
from typing import TYPE_CHECKING, Any, Callable, Union, cast
76
from urllib.parse import urlencode
87
from uuid import uuid4
98

109
from django.contrib.staticfiles.finders import find
1110
from django.core.cache import caches
12-
from django.forms import BooleanField, ChoiceField, Form, MultipleChoiceField
1311
from django.http import HttpRequest
1412
from django.urls import reverse
1513
from reactpy import component, hooks, html, utils
1614
from reactpy.types import ComponentType, Key, VdomDict
17-
from reactpy.web import export, module_from_file
1815

1916
from reactpy_django.exceptions import ViewNotRegisteredError
17+
from reactpy_django.forms.components import _django_form
2018
from reactpy_django.html import pyscript
21-
from reactpy_django.transforms import (
22-
convert_option_props,
23-
convert_textarea_children_to_prop,
24-
ensure_controlled_inputs,
25-
standardize_prop_names,
26-
)
2719
from reactpy_django.utils import (
2820
generate_obj_name,
2921
import_module,
@@ -35,13 +27,9 @@
3527
if TYPE_CHECKING:
3628
from collections.abc import Sequence
3729

30+
from django.forms import Form
3831
from django.views import View
3932

40-
DjangoForm = export(
41-
module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "reactpy_django" / "client.js"),
42-
("DjangoForm"),
43-
)
44-
4533

4634
def view_to_component(
4735
view: Callable | View | str,
@@ -263,104 +251,6 @@ def _django_js(static_path: str):
263251
return html.script(_cached_static_contents(static_path))
264252

265253

266-
@component
267-
def _django_form(
268-
form: type[Form], top_children: Sequence, bottom_children: Sequence, auto_submit: bool, auto_submit_wait: int
269-
):
270-
# TODO: Implement form restoration on page reload. Probably want to create a new setting called
271-
# form_restoration_method that can be set to "URL", "CLIENT_STORAGE", "SERVER_SESSION", or None.
272-
# Or maybe just recommend pre-rendering to have the browser handle it.
273-
# Be clear that URL mode will limit you to one form per page.
274-
# TODO: Test this with django-bootstrap forms and see how errors behave
275-
# TODO: Test this with django-colorfield and django-ace
276-
# TODO: Add pre-submit and post-submit hooks
277-
# TODO: Add auto-save option for database-backed forms
278-
uuid_ref = hooks.use_ref(uuid4().hex.replace("-", ""))
279-
top_children_count = hooks.use_ref(len(top_children))
280-
bottom_children_count = hooks.use_ref(len(bottom_children))
281-
submitted_data, set_submitted_data = hooks.use_state({} or None)
282-
283-
uuid = uuid_ref.current
284-
285-
# Don't allow the count of top and bottom children to change
286-
if len(top_children) != top_children_count.current or len(bottom_children) != bottom_children_count.current:
287-
msg = "Dynamically changing the number of top or bottom children is not allowed."
288-
raise ValueError(msg)
289-
290-
# Try to initialize the form with the provided data
291-
try:
292-
initialized_form = form(data=submitted_data)
293-
except Exception as e:
294-
if not isinstance(form, type(Form)):
295-
msg = (
296-
"The provided form must be an uninitialized Django Form. "
297-
"Do NOT initialize your form by calling it (ex. `MyForm()`)."
298-
)
299-
raise TypeError(msg) from e
300-
raise
301-
302-
# Run the form validation, if data was provided
303-
if submitted_data:
304-
initialized_form.full_clean()
305-
306-
def on_submit_callback(new_data: dict[str, Any]):
307-
choice_field_map = {
308-
field_name: {choice_value: choice_key for choice_key, choice_value in field.choices}
309-
for field_name, field in initialized_form.fields.items()
310-
if isinstance(field, ChoiceField)
311-
}
312-
multi_choice_fields = {
313-
field_name
314-
for field_name, field in initialized_form.fields.items()
315-
if isinstance(field, MultipleChoiceField)
316-
}
317-
boolean_fields = {
318-
field_name for field_name, field in initialized_form.fields.items() if isinstance(field, BooleanField)
319-
}
320-
321-
# Choice fields submit their values as text, but Django choice keys are not always equal to their values.
322-
# Due to this, we need to convert the text into keys that Django would be happy with
323-
for choice_field_name, choice_map in choice_field_map.items():
324-
if choice_field_name in new_data:
325-
submitted_value = new_data[choice_field_name]
326-
if isinstance(submitted_value, list):
327-
new_data[choice_field_name] = [
328-
choice_map.get(submitted_value_item, submitted_value_item)
329-
for submitted_value_item in submitted_value
330-
]
331-
elif choice_field_name in multi_choice_fields:
332-
new_data[choice_field_name] = [choice_map.get(submitted_value, submitted_value)]
333-
else:
334-
new_data[choice_field_name] = choice_map.get(submitted_value, submitted_value)
335-
336-
# Convert boolean field text into actual booleans
337-
for boolean_field_name in boolean_fields:
338-
new_data[boolean_field_name] = boolean_field_name in new_data
339-
340-
# TODO: ReactPy's use_state hook really should be de-duplicating this by itself. Needs upstream fix.
341-
if submitted_data != new_data:
342-
set_submitted_data(new_data)
343-
344-
async def on_change(event): ...
345-
346-
rendered_form = utils.html_to_vdom(
347-
initialized_form.render(),
348-
standardize_prop_names,
349-
convert_textarea_children_to_prop,
350-
convert_option_props,
351-
ensure_controlled_inputs(on_change),
352-
strict=False,
353-
)
354-
355-
return html.form(
356-
{"id": f"reactpy-{uuid}"},
357-
DjangoForm({"onSubmitCallback": on_submit_callback, "formId": f"reactpy-{uuid}"}),
358-
*top_children,
359-
html.div({"key": uuid4().hex}, rendered_form),
360-
*bottom_children,
361-
)
362-
363-
364254
def _cached_static_contents(static_path: str) -> str:
365255
from reactpy_django.config import REACTPY_CACHE
366256

src/reactpy_django/forms/__init__.py

Whitespace-only changes.
+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
from typing import TYPE_CHECKING, Any
5+
from uuid import uuid4
6+
7+
from django.forms import Form
8+
from django.utils import timezone
9+
from reactpy import component, hooks, html, utils
10+
from reactpy.core.events import event
11+
from reactpy.web import export, module_from_file
12+
13+
from reactpy_django.forms.transforms import (
14+
convert_html_props_to_reactjs,
15+
convert_textarea_children_to_prop,
16+
ensure_input_elements_are_controlled,
17+
set_value_prop_on_select_element,
18+
)
19+
from reactpy_django.forms.utils import convert_boolean_fields, convert_choice_fields
20+
21+
if TYPE_CHECKING:
22+
from collections.abc import Sequence
23+
24+
DjangoForm = export(
25+
module_from_file("reactpy-django", file=Path(__file__).parent.parent / "static" / "reactpy_django" / "client.js"),
26+
("DjangoForm"),
27+
)
28+
29+
30+
# DjangoFormAutoSubmit = export(
31+
# module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "reactpy_django" / "client.js"),
32+
# ("DjangoFormAutoSubmit"),
33+
# )
34+
35+
36+
@component
37+
def _django_form(
38+
form: type[Form], top_children: Sequence, bottom_children: Sequence, auto_submit: bool, auto_submit_wait: int
39+
):
40+
# TODO: Implement form restoration on page reload. Probably want to create a new setting called
41+
# form_restoration_method that can be set to "URL", "CLIENT_STORAGE", "SERVER_SESSION", or None.
42+
# Or maybe just recommend pre-rendering to have the browser handle it.
43+
# Be clear that URL mode will limit you to one form per page.
44+
# TODO: Test this with django-bootstrap, django-colorfield, django-ace, django-crispy-forms
45+
# TODO: Add pre-submit and post-submit hooks
46+
# TODO: Add auto-save option for database-backed forms
47+
uuid_ref = hooks.use_ref(uuid4().hex.replace("-", ""))
48+
top_children_count = hooks.use_ref(len(top_children))
49+
bottom_children_count = hooks.use_ref(len(bottom_children))
50+
submitted_data, set_submitted_data = hooks.use_state({} or None)
51+
last_changed = hooks.use_ref(timezone.now())
52+
53+
uuid = uuid_ref.current
54+
55+
# Don't allow the count of top and bottom children to change
56+
if len(top_children) != top_children_count.current or len(bottom_children) != bottom_children_count.current:
57+
msg = "Dynamically changing the number of top or bottom children is not allowed."
58+
raise ValueError(msg)
59+
60+
# Try to initialize the form with the provided data
61+
try:
62+
initialized_form = form(data=submitted_data)
63+
except Exception as e:
64+
if not isinstance(form, type(Form)):
65+
msg = (
66+
"The provided form must be an uninitialized Django Form. "
67+
"Do NOT initialize your form by calling it (ex. `MyForm()`)."
68+
)
69+
raise TypeError(msg) from e
70+
raise
71+
72+
# Run the form validation, if data was provided
73+
if submitted_data:
74+
initialized_form.full_clean()
75+
76+
@event(prevent_default=True)
77+
def on_submit(_event):
78+
"""The server was notified that a form was submitted. Note that actual submission behavior is handled by `on_submit_callback`."""
79+
last_changed.set_current(timezone.now())
80+
81+
def on_submit_callback(new_data: dict[str, Any]):
82+
convert_choice_fields(new_data, initialized_form)
83+
convert_boolean_fields(new_data, initialized_form)
84+
85+
# TODO: ReactPy's use_state hook really should be de-duplicating this by itself. Needs upstream fix.
86+
if submitted_data != new_data:
87+
set_submitted_data(new_data)
88+
89+
async def on_change(_event):
90+
last_changed.set_current(timezone.now())
91+
92+
rendered_form = utils.html_to_vdom(
93+
initialized_form.render(),
94+
convert_html_props_to_reactjs,
95+
convert_textarea_children_to_prop,
96+
set_value_prop_on_select_element,
97+
ensure_input_elements_are_controlled(on_change),
98+
strict=False,
99+
)
100+
101+
return html.form(
102+
{"id": f"reactpy-{uuid}", "onSubmit": on_submit},
103+
DjangoForm({"onSubmitCallback": on_submit_callback, "formId": f"reactpy-{uuid}"}),
104+
*top_children,
105+
rendered_form,
106+
*bottom_children,
107+
)

src/reactpy_django/transforms.py renamed to src/reactpy_django/forms/transforms.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
UNSUPPORTED_PROPS = {"children", "ref", "aria-*", "data-*"}
1313

1414

15-
def standardize_prop_names(vdom_tree: VdomDict) -> VdomDict:
15+
def convert_html_props_to_reactjs(vdom_tree: VdomDict) -> VdomDict:
1616
"""Transformation that standardizes the prop names to be used in the component."""
1717

1818
if not isinstance(vdom_tree, dict):
@@ -23,7 +23,7 @@ def standardize_prop_names(vdom_tree: VdomDict) -> VdomDict:
2323
vdom_tree["attributes"] = {_normalize_prop_name(k): v for k, v in vdom_tree["attributes"].items()}
2424

2525
for child in vdom_tree.get("children", []):
26-
standardize_prop_names(child)
26+
convert_html_props_to_reactjs(child)
2727

2828
return vdom_tree
2929

@@ -63,14 +63,15 @@ def _find_selected_options(vdom_tree: VdomDict, mutation: Callable) -> list[Vdom
6363
return selected_options
6464

6565

66-
def convert_option_props(vdom_tree: VdomDict) -> VdomDict:
66+
def set_value_prop_on_select_element(vdom_tree: VdomDict) -> VdomDict:
6767
"""Use the `value` prop on <select> instead of setting `selected` on <option>."""
6868

6969
if not isinstance(vdom_tree, dict):
7070
return vdom_tree
7171

7272
# If the current tag is <select>, remove 'selected' prop from any <option> children and
7373
# instead set the 'value' prop on the <select> tag.
74+
# TODO: Fix this, is broken
7475
if vdom_tree["tagName"] == "select" and "children" in vdom_tree:
7576
vdom_tree.setdefault("eventHandlers", {})
7677
vdom_tree["eventHandlers"]["onChange"] = EventHandler(to_event_handler_function(do_nothing_event))
@@ -82,7 +83,7 @@ def convert_option_props(vdom_tree: VdomDict) -> VdomDict:
8283
vdom_tree["attributes"]["value"] = [option["children"][0] for option in selected_options]
8384

8485
for child in vdom_tree.get("children", []):
85-
convert_option_props(child)
86+
set_value_prop_on_select_element(child)
8687

8788
return vdom_tree
8889

@@ -129,7 +130,7 @@ def _add_on_change_event(event_func, vdom_tree: VdomDict) -> VdomDict:
129130
return vdom_tree
130131

131132

132-
def ensure_controlled_inputs(event_func: Callable | None = None) -> Callable:
133+
def ensure_input_elements_are_controlled(event_func: Callable | None = None) -> Callable:
133134
"""Adds an onChange handler on form <input> elements, since ReactJS doesn't like uncontrolled inputs."""
134135

135136
def mutation(vdom_tree: VdomDict) -> VdomDict:

src/reactpy_django/forms/utils.py

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from typing import Any
2+
3+
from django.forms import BooleanField, ChoiceField, Form, MultipleChoiceField
4+
5+
6+
def convert_choice_fields(data: dict[str, Any], initialized_form: Form) -> None:
7+
choice_field_map = {
8+
field_name: {choice_value: choice_key for choice_key, choice_value in field.choices}
9+
for field_name, field in initialized_form.fields.items()
10+
if isinstance(field, ChoiceField)
11+
}
12+
multi_choice_fields = {
13+
field_name for field_name, field in initialized_form.fields.items() if isinstance(field, MultipleChoiceField)
14+
}
15+
16+
# Choice fields submit their values as text, but Django choice keys are not always equal to their values.
17+
# Due to this, we need to convert the text into keys that Django would be happy with
18+
for choice_field_name, choice_map in choice_field_map.items():
19+
if choice_field_name in data:
20+
submitted_value = data[choice_field_name]
21+
if isinstance(submitted_value, list):
22+
data[choice_field_name] = [
23+
choice_map.get(submitted_value_item, submitted_value_item)
24+
for submitted_value_item in submitted_value
25+
]
26+
elif choice_field_name in multi_choice_fields:
27+
data[choice_field_name] = [choice_map.get(submitted_value, submitted_value)]
28+
else:
29+
data[choice_field_name] = choice_map.get(submitted_value, submitted_value)
30+
31+
32+
def convert_boolean_fields(data: dict[str, Any], initialized_form: Form) -> None:
33+
boolean_fields = {
34+
field_name for field_name, field in initialized_form.fields.items() if isinstance(field, BooleanField)
35+
}
36+
37+
# Convert boolean field text into actual booleans
38+
for boolean_field_name in boolean_fields:
39+
data[boolean_field_name] = boolean_field_name in data

0 commit comments

Comments
 (0)