Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
168a727
Client side form handler
Archmonger Dec 3, 2024
98ba450
misc changelog bump
Archmonger Dec 3, 2024
14c9dde
Functional client code
Archmonger Dec 5, 2024
ebd87bf
First draft of form conversion
Archmonger Dec 5, 2024
164e3a3
format
Archmonger Dec 5, 2024
cf08add
Move code to forms module
Archmonger Dec 5, 2024
f0702d0
Squash some bugs with multi choice and boolean fields
Archmonger Dec 6, 2024
63e23d5
Remove auto submit from the base form
Archmonger Dec 6, 2024
66fa334
Create form test
Archmonger Dec 6, 2024
3863eb2
Add bootstrap form
Archmonger Dec 6, 2024
4565df0
add events for form component
Archmonger Dec 6, 2024
24cc64b
Add on_change event
Archmonger Dec 6, 2024
edca217
simplify ensure_input_elements_are_controlled
Archmonger Dec 6, 2024
8e2913f
Support model choice fields
Archmonger Dec 6, 2024
cc4fd22
Prep work for DB backed form
Archmonger Dec 6, 2024
f0b21c7
Full support for database backed forms
Archmonger Dec 6, 2024
851b113
Fix render loop bug
Archmonger Dec 6, 2024
6bf8c24
quick self review
Archmonger Dec 6, 2024
4077a06
Add changelog
Archmonger Dec 6, 2024
08034fa
Simplify transforms
Archmonger Dec 7, 2024
24ad84c
Add extra transforms arg
Archmonger Dec 7, 2024
9360e51
REACTPY_DEFAULT_FORM_TEMPLATE
Archmonger Dec 7, 2024
7371a45
better input transform
Archmonger Dec 7, 2024
8c9990f
var name cleanup for _find_selected_options
Archmonger Dec 7, 2024
a54f035
cleanup in transform_value_prop_on_input_element
Archmonger Dec 7, 2024
78fdbed
simplify convert_multiple_choice_fields
Archmonger Dec 7, 2024
c8c71c4
Remove unsupported fields comment
Archmonger Dec 7, 2024
282e542
Rename cancel btn to reset
Archmonger Dec 7, 2024
bd58a1b
Move extra props arg
Archmonger Dec 7, 2024
d446161
Set default attributes in transforms
Archmonger Dec 7, 2024
aee08da
Fix edge case where error is thrown on empty choice field
Archmonger Dec 7, 2024
9c28f97
First cut at docs
Archmonger Dec 8, 2024
88ba295
Refactoring related to new docs
Archmonger Dec 8, 2024
a44591f
use local bootstrap for tests
Archmonger Dec 8, 2024
16c04bc
Remove default values from test form
Archmonger Dec 8, 2024
d561c88
self review
Archmonger Dec 8, 2024
d9416d9
Add tests
Archmonger Dec 9, 2024
208ec17
Add readme
Archmonger Dec 9, 2024
602be6b
Try dynamically selecting options for file_path_field
Archmonger Dec 9, 2024
1d3d095
Update todo comments
Archmonger Dec 9, 2024
b6fb5c4
Increase sleep on async relational query test
Archmonger Dec 9, 2024
608acee
Add another check in query tests
Archmonger Dec 9, 2024
3f834ca
Fix default on admin middleware
Archmonger Dec 9, 2024
73967b4
New ensure_async util function
Archmonger Dec 9, 2024
e32fc67
fix bad import
Archmonger Dec 9, 2024
3ed84db
Add thread_sensitive arg to ensure_async func
Archmonger Dec 10, 2024
55daaa2
Add new tests for form events
Archmonger Dec 10, 2024
5eb5818
run formatter
Archmonger Dec 10, 2024
998041a
another simplification of middleware
Archmonger Dec 10, 2024
78d5f38
Fix bug where input elements were dismounted prematurely
Archmonger Dec 11, 2024
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Move code to forms module
  • Loading branch information
Archmonger committed Dec 5, 2024
commit cf08add5754491fe6259bb000b5168cdcba9f493
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
Loading