2
2
3
3
import json
4
4
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
6
7
from urllib .parse import urlencode
7
8
from uuid import uuid4
8
9
9
10
from django .contrib .staticfiles .finders import find
10
11
from django .core .cache import caches
12
+ from django .forms import BooleanField , ChoiceField , Form , MultipleChoiceField
11
13
from django .http import HttpRequest
12
14
from django .urls import reverse
13
15
from reactpy import component , hooks , html , utils
14
16
from reactpy .types import ComponentType , Key , VdomDict
17
+ from reactpy .web import export , module_from_file
15
18
16
19
from reactpy_django .exceptions import ViewNotRegisteredError
17
20
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
+ )
18
27
from reactpy_django .utils import (
19
28
generate_obj_name ,
20
29
import_module ,
28
37
29
38
from django .views import View
30
39
40
+ DjangoForm = export (
41
+ module_from_file ("reactpy-django" , file = Path (__file__ ).parent / "static" / "reactpy_django" / "client.js" ),
42
+ ("DjangoForm" ),
43
+ )
44
+
31
45
32
46
def view_to_component (
33
47
view : Callable | View | str ,
@@ -114,6 +128,25 @@ def django_js(static_path: str, key: Key | None = None):
114
128
return _django_js (static_path = static_path , key = key )
115
129
116
130
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
+
117
150
def pyscript_component (
118
151
* file_paths : str ,
119
152
initial : str | VdomDict | ComponentType = "" ,
@@ -230,6 +263,102 @@ def _django_js(static_path: str):
230
263
return html .script (_cached_static_contents (static_path ))
231
264
232
265
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
+
233
362
def _cached_static_contents (static_path : str ) -> str :
234
363
from reactpy_django .config import REACTPY_CACHE
235
364
@@ -238,6 +367,8 @@ def _cached_static_contents(static_path: str) -> str:
238
367
if not abs_path :
239
368
msg = f"Could not find static file { static_path } within Django's static files."
240
369
raise FileNotFoundError (msg )
370
+ if isinstance (abs_path , (list , tuple )):
371
+ abs_path = abs_path [0 ]
241
372
242
373
# Fetch the file from cache, if available
243
374
last_modified_time = os .stat (abs_path ).st_mtime
@@ -259,7 +390,8 @@ def _pyscript_component(
259
390
root : str = "root" ,
260
391
):
261
392
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
263
395
initial = vdom_or_component_to_string (initial , uuid = uuid )
264
396
executor = render_pyscript_template (file_paths , uuid , root )
265
397
0 commit comments