Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support rendering Django forms within ReactPy #212

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Description

A summary of the changes.
<!-- A summary of the changes. -->

## Checklist:

Expand Down
9 changes: 7 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,14 @@ Using the following categories, list your changes in this order:
- Prettier WebSocket URLs for components that do not have sessions.
- Template tag will now only validate `args`/`kwargs` if `settings.py:DEBUG` is enabled.
- Bumped the minimum `@reactpy/client` version to `0.3.1`
- Bumped the minimum Django version to `4.2`.
- Use TypeScript instead of JavaScript for this repository.
- Note: ReactPy-Django will continue bumping minimum Django requirements to versions that increase async support. This "latest-only" trend will continue until Django has all async features that ReactPy benefits from. After this point, ReactPy-Django will begin supporting all maintained Django versions.
- Bumped the minimum Django version to `4.2`.

???+ note "Django 4.2+ is required"

ReactPy-Django will continue bumping minimum Django requirements to versions that increase async support.

This "latest-only" trend will continue until Django has all async features that ReactPy benefits from. After this point, ReactPy-Django will begin supporting all maintained Django versions.

### Removed

Expand Down
89 changes: 82 additions & 7 deletions src/reactpy_django/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,21 @@

from django.contrib.staticfiles.finders import find
from django.core.cache import caches
from django.forms import Form
from django.http import HttpRequest
from django.urls import reverse
from django.views import View
from reactpy import component, hooks, html, utils
from reactpy.types import Key, VdomDict
from reactpy import component, event, hooks, html
from reactpy.types import ComponentType, Key, VdomDict
from reactpy.utils import _ModelTransform, del_html_head_body_transform, html_to_vdom

from reactpy_django.exceptions import ViewNotRegisteredError
from reactpy_django.utils import generate_obj_name, import_module, render_view
from reactpy_django.utils import (
generate_obj_name,
import_module,
render_form,
render_view,
)


# Type hints for:
Expand Down Expand Up @@ -48,6 +55,7 @@ def view_to_component(
compatibility: bool = False,
transforms: Sequence[Callable[[VdomDict], Any]] = (),
strict_parsing: bool = True,
loading_placeholder: ComponentType | VdomDict | str | None = None,
) -> Any | Callable[[Callable], Any]:
"""Converts a Django view to a ReactPy component.

Expand Down Expand Up @@ -82,6 +90,7 @@ def constructor(
args=args,
kwargs=kwargs,
key=key,
loading_placeholder=loading_placeholder,
)

return constructor
Expand Down Expand Up @@ -122,6 +131,12 @@ def constructor(
return constructor


@component
def django_form():
"""TODO: Write this definition."""
...


def django_css(static_path: str, key: Key | None = None):
"""Fetches a CSS static file for use within ReactPy. This allows for deferred CSS loading.

Expand Down Expand Up @@ -152,11 +167,12 @@ def django_js(static_path: str, key: Key | None = None):
def _view_to_component(
view: Callable | View | str,
compatibility: bool,
transforms: Sequence[Callable[[VdomDict], Any]],
transforms: Sequence[_ModelTransform],
strict_parsing: bool,
request: HttpRequest | None,
args: Sequence | None,
kwargs: dict | None,
loading_placeholder: ComponentType | VdomDict | str | None = None,
):
"""The actual component. Used to prevent pollution of acceptable kwargs keys."""
converted_view, set_converted_view = hooks.use_state(
Expand Down Expand Up @@ -187,9 +203,9 @@ async def async_render():
# Render the view
response = await render_view(resolved_view, _request, _args, _kwargs)
set_converted_view(
utils.html_to_vdom(
html_to_vdom(
response.content.decode("utf-8").strip(),
utils.del_html_head_body_transform,
del_html_head_body_transform,
*transforms,
strict=strict_parsing,
)
Expand All @@ -207,7 +223,7 @@ async def async_render():
return view_to_iframe(resolved_view)(*_args, **_kwargs)

# Return the view if it's been rendered via the `async_render` hook
return converted_view
return converted_view or loading_placeholder


@component
Expand Down Expand Up @@ -249,6 +265,65 @@ def _view_to_iframe(
)


@component
def _django_form(
form: Form,
/,
top_children: Sequence = (),
bottom_children: Sequence = (),
template_name: str | None = None,
context: dict | None = None,
transforms: Sequence[_ModelTransform] = (),
strict_parsing: bool = True,
loading_placeholder: ComponentType | VdomDict | str | None = None,
):
convertered_form, set_converted_form = hooks.use_state(
cast(Union[VdomDict, None], None)
)
render_needed, set_render_needed = hooks.use_state(True)
request, set_request = hooks.use_state(HttpRequest())

@hooks.use_effect
async def async_render():
"""Render the form in an async hook to avoid blocking the main thread."""
if render_needed:
if not request.method:
request.method = "GET"
# TODO: Maybe I need to use FormView here instead? At that point may as well also use view_to_component.
form_html = await render_form(
form,
template_name=template_name,
context=context,
request=request,
)
set_converted_form(
html.form(
{"on_submit": on_submit},
*top_children or "",
html_to_vdom(form_html, *transforms, strict=strict_parsing),
*bottom_children or "",
)
)
# TODO: When ReactPy starts serializing the `name` field of input elements,
# we will need to make sure all inputs have a name attribute here
set_render_needed(False)

@event(prevent_default=True)
async def on_submit(event):
"""Event handler attached to the rendered form to intercept submission."""
# Create a synthetic request object.
request_obj = HttpRequest()
request_obj.method = "POST"
# FIXME: Need to figure out how to get the form data from the event.
setattr(request_obj, "_body", event["target"])
set_request(request_obj)

# Queue a re-render of the form
set_render_needed(True)

return convertered_form or loading_placeholder


@component
def _django_css(static_path: str):
return html.style(_cached_static_contents(static_path))
Expand Down
2 changes: 1 addition & 1 deletion src/reactpy_django/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def auth_required(

warn(
"auth_required is deprecated and will be removed in the next major version. "
"An equivalent to this decorator's default is @user_passes_test('is_active').",
"An equivalent to this decorator's default is @user_passes_test(lambda user: user.is_active).",
DeprecationWarning,
)

Expand Down
42 changes: 42 additions & 0 deletions src/reactpy_django/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from django.db.models import ManyToManyField, ManyToOneRel, prefetch_related_objects
from django.db.models.base import Model
from django.db.models.query import QuerySet
from django.forms import Form
from django.http import HttpRequest, HttpResponse
from django.template import engines
from django.utils import timezone
Expand Down Expand Up @@ -74,6 +75,18 @@ async def render_view(
return response


async def render_form(
form: Form,
template_name: str | None,
context: dict | None,
request: HttpRequest | None = None,
) -> str:
"""Renders a Django form asynchronously."""
return await database_sync_to_async(form.renderer.render)(
template_name=template_name, context=context or {}, request=request
)


def register_component(component: ComponentConstructor | str):
"""Adds a component to the list of known registered components.

Expand Down Expand Up @@ -393,3 +406,32 @@ def get_user_pk(user, serialize=False):
"""Returns the primary key value for a user model instance."""
pk = getattr(user, user._meta.pk.name)
return pickle.dumps(pk) if serialize else pk


def combine_event_and_form(target_elements: list[dict], form: Form) -> dict[str, str]:
"""
Serialized DOM elements currently do not send over their `name` attribute. They only contain
`tagName` and `value` attributes.

However, since DOM elements are rendered in order, so we can reconstruct the form data by
generating a list of `name` attribute from the form and matching them with the `value` attributes
from the serialized DOM elements.

FIXME: https://github.com/reactive-python/reactpy/issues/1186
FIXME: https://github.com/reactive-python/reactpy/issues/1188
"""
# TODO: FieldSet breaks this logic, will need to find a workaround
# Generate a list of names from the form
input_field_names = [field.name for field in form]

# Generate a list of values from the serialized DOM
input_field_values = [element["value"] for element in target_elements]

# Verify if something went wrong
if len(input_field_names) != len(input_field_values):
raise ValueError(
"The number of input fields in the form does not match the number of values in the serialized DOM elements."
)

# Combine the two lists into a dictionary
return dict(zip(input_field_names, input_field_values))
29 changes: 28 additions & 1 deletion tests/test_app/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from django.contrib.auth import get_user_model
from django.http import HttpRequest
from django.shortcuts import render
from reactpy import component, hooks, html, web
from reactpy import component, event, hooks, html, web
from reactpy_django.components import view_to_component, view_to_iframe
from reactpy_django.types import QueryOptions

Expand Down Expand Up @@ -809,3 +809,30 @@ async def on_submit(event):
)
),
)


@component
def example():
@event(prevent_default=True)
def on_submit(event):
...

return html.form(
{"on_submit": on_submit},
html.input({"type": "text"}),
html.div(
html.input({"type": "text"}),
),
html.input({"type": "text", "disabled": True}),
html.textarea("Hello World"),
html.select(
html.option("Hello"),
html.option("World"),
),
html.input({"type": "checkbox"}),
html.fieldset(
html.input({"type": "radio", "name": "radio"}),
html.input({"type": "radio", "name": "radio"}),
),
html.button({"type": "submit"}, "Submit"),
)