diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index b2a11a7c..7c372c64 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -2,28 +2,20 @@ import json import os -from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Union, cast from urllib.parse import urlencode from uuid import uuid4 from django.contrib.staticfiles.finders import find from django.core.cache import caches -from django.forms import BooleanField, ChoiceField, Form, MultipleChoiceField from django.http import HttpRequest from django.urls import reverse from reactpy import component, hooks, html, utils from reactpy.types import ComponentType, Key, VdomDict -from reactpy.web import export, module_from_file from reactpy_django.exceptions import ViewNotRegisteredError +from reactpy_django.forms.components import _django_form from reactpy_django.html import pyscript -from reactpy_django.transforms import ( - convert_option_props, - convert_textarea_children_to_prop, - ensure_controlled_inputs, - standardize_prop_names, -) from reactpy_django.utils import ( generate_obj_name, import_module, @@ -35,13 +27,9 @@ if TYPE_CHECKING: from collections.abc import Sequence + from django.forms import Form from django.views import View -DjangoForm = export( - module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "reactpy_django" / "client.js"), - ("DjangoForm"), -) - def view_to_component( view: Callable | View | str, @@ -263,104 +251,6 @@ def _django_js(static_path: str): return html.script(_cached_static_contents(static_path)) -@component -def _django_form( - form: type[Form], top_children: Sequence, bottom_children: Sequence, auto_submit: bool, auto_submit_wait: int -): - # TODO: Implement form restoration on page reload. Probably want to create a new setting called - # form_restoration_method that can be set to "URL", "CLIENT_STORAGE", "SERVER_SESSION", or None. - # Or maybe just recommend pre-rendering to have the browser handle it. - # Be clear that URL mode will limit you to one form per page. - # TODO: Test this with django-bootstrap forms and see how errors behave - # TODO: Test this with django-colorfield and django-ace - # TODO: Add pre-submit and post-submit hooks - # TODO: Add auto-save option for database-backed forms - uuid_ref = hooks.use_ref(uuid4().hex.replace("-", "")) - top_children_count = hooks.use_ref(len(top_children)) - bottom_children_count = hooks.use_ref(len(bottom_children)) - submitted_data, set_submitted_data = hooks.use_state({} or None) - - uuid = uuid_ref.current - - # Don't allow the count of top and bottom children to change - 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) - - # Try to initialize the form with the provided data - try: - initialized_form = form(data=submitted_data) - except Exception as e: - if not isinstance(form, type(Form)): - msg = ( - "The provided form must be an uninitialized Django Form. " - "Do NOT initialize your form by calling it (ex. `MyForm()`)." - ) - raise TypeError(msg) from e - raise - - # Run the form validation, if data was provided - if submitted_data: - initialized_form.full_clean() - - def on_submit_callback(new_data: dict[str, Any]): - choice_field_map = { - field_name: {choice_value: choice_key for choice_key, choice_value in field.choices} - for field_name, field in initialized_form.fields.items() - if isinstance(field, ChoiceField) - } - multi_choice_fields = { - field_name - for field_name, field in initialized_form.fields.items() - if isinstance(field, MultipleChoiceField) - } - boolean_fields = { - field_name for field_name, field in initialized_form.fields.items() if isinstance(field, BooleanField) - } - - # Choice fields submit their values as text, but Django choice keys are not always equal to their values. - # Due to this, we need to convert the text into keys that Django would be happy with - for choice_field_name, choice_map in choice_field_map.items(): - if choice_field_name in new_data: - submitted_value = new_data[choice_field_name] - if isinstance(submitted_value, list): - new_data[choice_field_name] = [ - choice_map.get(submitted_value_item, submitted_value_item) - for submitted_value_item in submitted_value - ] - elif choice_field_name in multi_choice_fields: - new_data[choice_field_name] = [choice_map.get(submitted_value, submitted_value)] - else: - new_data[choice_field_name] = choice_map.get(submitted_value, submitted_value) - - # Convert boolean field text into actual booleans - for boolean_field_name in boolean_fields: - new_data[boolean_field_name] = boolean_field_name in new_data - - # TODO: ReactPy's use_state hook really should be de-duplicating this by itself. Needs upstream fix. - if submitted_data != new_data: - set_submitted_data(new_data) - - async def on_change(event): ... - - rendered_form = utils.html_to_vdom( - initialized_form.render(), - standardize_prop_names, - convert_textarea_children_to_prop, - convert_option_props, - ensure_controlled_inputs(on_change), - strict=False, - ) - - return html.form( - {"id": f"reactpy-{uuid}"}, - DjangoForm({"onSubmitCallback": on_submit_callback, "formId": f"reactpy-{uuid}"}), - *top_children, - html.div({"key": uuid4().hex}, rendered_form), - *bottom_children, - ) - - def _cached_static_contents(static_path: str) -> str: from reactpy_django.config import REACTPY_CACHE diff --git a/src/reactpy_django/forms/__init__.py b/src/reactpy_django/forms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/reactpy_django/forms/components.py b/src/reactpy_django/forms/components.py new file mode 100644 index 00000000..12fc767c --- /dev/null +++ b/src/reactpy_django/forms/components.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from django.forms import Form +from django.utils import timezone +from reactpy import component, hooks, html, utils +from reactpy.core.events import event +from reactpy.web import export, module_from_file + +from reactpy_django.forms.transforms import ( + convert_html_props_to_reactjs, + convert_textarea_children_to_prop, + ensure_input_elements_are_controlled, + set_value_prop_on_select_element, +) +from reactpy_django.forms.utils import convert_boolean_fields, convert_choice_fields + +if TYPE_CHECKING: + from collections.abc import Sequence + +DjangoForm = export( + module_from_file("reactpy-django", file=Path(__file__).parent.parent / "static" / "reactpy_django" / "client.js"), + ("DjangoForm"), +) + + +# DjangoFormAutoSubmit = export( +# module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "reactpy_django" / "client.js"), +# ("DjangoFormAutoSubmit"), +# ) + + +@component +def _django_form( + form: type[Form], top_children: Sequence, bottom_children: Sequence, auto_submit: bool, auto_submit_wait: int +): + # TODO: Implement form restoration on page reload. Probably want to create a new setting called + # form_restoration_method that can be set to "URL", "CLIENT_STORAGE", "SERVER_SESSION", or None. + # Or maybe just recommend pre-rendering to have the browser handle it. + # Be clear that URL mode will limit you to one form per page. + # TODO: Test this with django-bootstrap, django-colorfield, django-ace, django-crispy-forms + # TODO: Add pre-submit and post-submit hooks + # TODO: Add auto-save option for database-backed forms + uuid_ref = hooks.use_ref(uuid4().hex.replace("-", "")) + top_children_count = hooks.use_ref(len(top_children)) + bottom_children_count = hooks.use_ref(len(bottom_children)) + submitted_data, set_submitted_data = hooks.use_state({} or None) + last_changed = hooks.use_ref(timezone.now()) + + uuid = uuid_ref.current + + # Don't allow the count of top and bottom children to change + 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) + + # Try to initialize the form with the provided data + try: + initialized_form = form(data=submitted_data) + except Exception as e: + if not isinstance(form, type(Form)): + msg = ( + "The provided form must be an uninitialized Django Form. " + "Do NOT initialize your form by calling it (ex. `MyForm()`)." + ) + raise TypeError(msg) from e + raise + + # Run the form validation, if data was provided + if submitted_data: + initialized_form.full_clean() + + @event(prevent_default=True) + def on_submit(_event): + """The server was notified that a form was submitted. Note that actual submission behavior is handled by `on_submit_callback`.""" + last_changed.set_current(timezone.now()) + + def on_submit_callback(new_data: dict[str, Any]): + convert_choice_fields(new_data, initialized_form) + convert_boolean_fields(new_data, initialized_form) + + # TODO: ReactPy's use_state hook really should be de-duplicating this by itself. Needs upstream fix. + if submitted_data != new_data: + set_submitted_data(new_data) + + async def on_change(_event): + last_changed.set_current(timezone.now()) + + rendered_form = utils.html_to_vdom( + initialized_form.render(), + convert_html_props_to_reactjs, + convert_textarea_children_to_prop, + set_value_prop_on_select_element, + ensure_input_elements_are_controlled(on_change), + strict=False, + ) + + return html.form( + {"id": f"reactpy-{uuid}", "onSubmit": on_submit}, + DjangoForm({"onSubmitCallback": on_submit_callback, "formId": f"reactpy-{uuid}"}), + *top_children, + rendered_form, + *bottom_children, + ) diff --git a/src/reactpy_django/transforms.py b/src/reactpy_django/forms/transforms.py similarity index 99% rename from src/reactpy_django/transforms.py rename to src/reactpy_django/forms/transforms.py index 4879cbae..69c29fb0 100644 --- a/src/reactpy_django/transforms.py +++ b/src/reactpy_django/forms/transforms.py @@ -12,7 +12,7 @@ UNSUPPORTED_PROPS = {"children", "ref", "aria-*", "data-*"} -def standardize_prop_names(vdom_tree: VdomDict) -> VdomDict: +def convert_html_props_to_reactjs(vdom_tree: VdomDict) -> VdomDict: """Transformation that standardizes the prop names to be used in the component.""" if not isinstance(vdom_tree, dict): @@ -23,7 +23,7 @@ def standardize_prop_names(vdom_tree: VdomDict) -> VdomDict: vdom_tree["attributes"] = {_normalize_prop_name(k): v for k, v in vdom_tree["attributes"].items()} for child in vdom_tree.get("children", []): - standardize_prop_names(child) + convert_html_props_to_reactjs(child) return vdom_tree @@ -63,7 +63,7 @@ def _find_selected_options(vdom_tree: VdomDict, mutation: Callable) -> list[Vdom return selected_options -def convert_option_props(vdom_tree: VdomDict) -> VdomDict: +def set_value_prop_on_select_element(vdom_tree: VdomDict) -> VdomDict: """Use the `value` prop on , remove 'selected' prop from any