Skip to content

Commit ebd87bf

Browse files
committed
First draft of form conversion
1 parent 14c9dde commit ebd87bf

File tree

4 files changed

+696
-3
lines changed

4 files changed

+696
-3
lines changed

src/reactpy_django/components.py

+134-2
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,28 @@
22

33
import json
44
import os
5-
from typing import TYPE_CHECKING, Any, Callable, Union, cast
5+
from pathlib import Path
6+
from typing import TYPE_CHECKING, Any, Callable, Type, Union, cast
67
from urllib.parse import urlencode
78
from uuid import uuid4
89

910
from django.contrib.staticfiles.finders import find
1011
from django.core.cache import caches
12+
from django.forms import BooleanField, ChoiceField, Form, MultipleChoiceField
1113
from django.http import HttpRequest
1214
from django.urls import reverse
1315
from reactpy import component, hooks, html, utils
1416
from reactpy.types import ComponentType, Key, VdomDict
17+
from reactpy.web import export, module_from_file
1518

1619
from reactpy_django.exceptions import ViewNotRegisteredError
1720
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+
)
1827
from reactpy_django.utils import (
1928
generate_obj_name,
2029
import_module,
@@ -28,6 +37,11 @@
2837

2938
from django.views import View
3039

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

3246
def view_to_component(
3347
view: Callable | View | str,
@@ -114,6 +128,25 @@ def django_js(static_path: str, key: Key | None = None):
114128
return _django_js(static_path=static_path, key=key)
115129

116130

131+
def django_form(
132+
form: Type[Form],
133+
*,
134+
top_children: Sequence = (),
135+
bottom_children: Sequence = (),
136+
auto_submit: bool = False,
137+
auto_submit_wait: int = 3,
138+
key: Key | None = None,
139+
):
140+
return _django_form(
141+
form=form,
142+
top_children=top_children,
143+
bottom_children=bottom_children,
144+
auto_submit=auto_submit,
145+
auto_submit_wait=auto_submit_wait,
146+
key=key,
147+
)
148+
149+
117150
def pyscript_component(
118151
*file_paths: str,
119152
initial: str | VdomDict | ComponentType = "",
@@ -230,6 +263,102 @@ def _django_js(static_path: str):
230263
return html.script(_cached_static_contents(static_path))
231264

232265

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+
raise ValueError("Dynamically changing the number of top or bottom children is not allowed.")
288+
289+
# Try to initialize the form with the provided data
290+
try:
291+
initialized_form = form(data=submitted_data)
292+
except Exception as e:
293+
if not isinstance(form, type(Form)):
294+
raise ValueError(
295+
"The provided form must be an uninitialized Django Form. "
296+
"Do NOT initialize your form by calling it (ex. `MyForm()`)."
297+
) from e
298+
raise e
299+
300+
# Run the form validation, if data was provided
301+
if submitted_data:
302+
initialized_form.full_clean()
303+
304+
def on_submit_callback(new_data: dict[str, Any]):
305+
choice_field_map = {
306+
field_name: {choice_value: choice_key for choice_key, choice_value in field.choices}
307+
for field_name, field in initialized_form.fields.items()
308+
if isinstance(field, ChoiceField)
309+
}
310+
multi_choice_fields = {
311+
field_name
312+
for field_name, field in initialized_form.fields.items()
313+
if isinstance(field, MultipleChoiceField)
314+
}
315+
boolean_fields = {
316+
field_name for field_name, field in initialized_form.fields.items() if isinstance(field, BooleanField)
317+
}
318+
319+
# Choice fields submit their values as text, but Django choice keys are not always equal to their values.
320+
# Due to this, we need to convert the text into keys that Django would be happy with
321+
for choice_field_name, choice_map in choice_field_map.items():
322+
if choice_field_name in new_data:
323+
submitted_value = new_data[choice_field_name]
324+
if isinstance(submitted_value, list):
325+
new_data[choice_field_name] = [
326+
choice_map.get(submitted_value_item, submitted_value_item)
327+
for submitted_value_item in submitted_value
328+
]
329+
elif choice_field_name in multi_choice_fields:
330+
new_data[choice_field_name] = [choice_map.get(submitted_value, submitted_value)]
331+
else:
332+
new_data[choice_field_name] = choice_map.get(submitted_value, submitted_value)
333+
334+
# Convert boolean field text into actual booleans
335+
for boolean_field_name in boolean_fields:
336+
new_data[boolean_field_name] = boolean_field_name in new_data
337+
338+
# TODO: ReactPy's use_state hook really should be de-duplicating this by itself. Needs upstream fix.
339+
if submitted_data != new_data:
340+
set_submitted_data(new_data)
341+
342+
async def on_change(event): ...
343+
344+
rendered_form = utils.html_to_vdom(
345+
initialized_form.render(),
346+
standardize_prop_names,
347+
convert_textarea_children_to_prop,
348+
convert_option_props,
349+
ensure_controlled_inputs(on_change),
350+
strict=False,
351+
)
352+
353+
return html.form(
354+
{"id": f"reactpy-{uuid}"},
355+
DjangoForm({"onSubmitCallback": on_submit_callback, "formId": f"reactpy-{uuid}"}),
356+
*top_children,
357+
html.div({"key": uuid4().hex}, rendered_form),
358+
*bottom_children,
359+
)
360+
361+
233362
def _cached_static_contents(static_path: str) -> str:
234363
from reactpy_django.config import REACTPY_CACHE
235364

@@ -238,6 +367,8 @@ def _cached_static_contents(static_path: str) -> str:
238367
if not abs_path:
239368
msg = f"Could not find static file {static_path} within Django's static files."
240369
raise FileNotFoundError(msg)
370+
if isinstance(abs_path, (list, tuple)):
371+
abs_path = abs_path[0]
241372

242373
# Fetch the file from cache, if available
243374
last_modified_time = os.stat(abs_path).st_mtime
@@ -259,7 +390,8 @@ def _pyscript_component(
259390
root: str = "root",
260391
):
261392
rendered, set_rendered = hooks.use_state(False)
262-
uuid = uuid4().hex.replace("-", "")
393+
uuid_ref = hooks.use_ref(uuid4().hex.replace("-", ""))
394+
uuid = uuid_ref.current
263395
initial = vdom_or_component_to_string(initial, uuid=uuid)
264396
executor = render_pyscript_template(file_paths, uuid, root)
265397

0 commit comments

Comments
 (0)