22
33import json
44import 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
67from urllib .parse import urlencode
78from uuid import uuid4
89
910from django .contrib .staticfiles .finders import find
1011from django .core .cache import caches
12+ from django .forms import BooleanField , ChoiceField , Form , MultipleChoiceField
1113from django .http import HttpRequest
1214from django .urls import reverse
1315from reactpy import component , hooks , html , utils
1416from reactpy .types import ComponentType , Key , VdomDict
17+ from reactpy .web import export , module_from_file
1518
1619from reactpy_django .exceptions import ViewNotRegisteredError
1720from 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+ )
1827from reactpy_django .utils import (
1928 generate_obj_name ,
2029 import_module ,
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
3246def 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+
117150def 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+
233362def _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