Skip to content

Commit

Permalink
Move code to forms module
Browse files Browse the repository at this point in the history
  • Loading branch information
Archmonger committed Dec 5, 2024
1 parent 164e3a3 commit cf08add
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 117 deletions.
114 changes: 2 additions & 112 deletions src/reactpy_django/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand Down
Empty file.
107 changes: 107 additions & 0 deletions src/reactpy_django/forms/components.py
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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

Expand Down Expand Up @@ -63,14 +63,15 @@ 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 <select> instead of setting `selected` on <option>."""

if not isinstance(vdom_tree, dict):
return vdom_tree

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

for child in vdom_tree.get("children", []):
convert_option_props(child)
set_value_prop_on_select_element(child)

return vdom_tree

Expand Down Expand Up @@ -129,7 +130,7 @@ def _add_on_change_event(event_func, vdom_tree: VdomDict) -> VdomDict:
return vdom_tree


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

def mutation(vdom_tree: VdomDict) -> VdomDict:
Expand Down
39 changes: 39 additions & 0 deletions src/reactpy_django/forms/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from typing import Any

from django.forms import BooleanField, ChoiceField, Form, MultipleChoiceField


def convert_choice_fields(data: dict[str, Any], initialized_form: Form) -> None:
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)
}

# 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 data:
submitted_value = data[choice_field_name]
if isinstance(submitted_value, list):
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:
data[choice_field_name] = [choice_map.get(submitted_value, submitted_value)]
else:
data[choice_field_name] = choice_map.get(submitted_value, submitted_value)


def convert_boolean_fields(data: dict[str, Any], initialized_form: Form) -> None:
boolean_fields = {
field_name for field_name, field in initialized_form.fields.items() if isinstance(field, BooleanField)
}

# Convert boolean field text into actual booleans
for boolean_field_name in boolean_fields:
data[boolean_field_name] = boolean_field_name in data

0 comments on commit cf08add

Please sign in to comment.