From 7dd59f13b21a436a4dda8bf50382342a9227487f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 11 Jan 2024 01:11:25 -0800 Subject: [PATCH 1/5] fix some typos --- .github/pull_request_template.md | 2 +- CHANGELOG.md | 9 +++++++-- src/reactpy_django/decorators.py | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7e988149..8dcbd244 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,6 @@ ## Description -A summary of the changes. + ## Checklist: diff --git a/CHANGELOG.md b/CHANGELOG.md index 004520c7..9f924a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/reactpy_django/decorators.py b/src/reactpy_django/decorators.py index d06162a5..59c110b3 100644 --- a/src/reactpy_django/decorators.py +++ b/src/reactpy_django/decorators.py @@ -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, ) From 2766058db3fa662887e8bece7ed72e3b9db2c17e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 11 Jan 2024 02:15:39 -0800 Subject: [PATCH 2/5] skeleton code for django form support --- src/reactpy_django/components.py | 70 ++++++++++++++++++++++++++++++-- src/reactpy_django/utils.py | 13 ++++++ 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index ab02e996..5d1fb88b 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -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 import component, event, hooks, html from reactpy.types import Key, VdomDict +from reactpy.utils import 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: @@ -187,9 +194,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, ) @@ -249,6 +256,61 @@ def _view_to_iframe( ) +@component +def django_form( + form: Form, + /, + top_children: Sequence | None = None, + bottom_children: Sequence | None = None, + template_name: str | None = None, + context: dict | None = None, +): + rendered_form, set_rendered_form = hooks.use_state("") + 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" + + set_rendered_form( + await render_form( + form, + template_name=template_name, + context=context, + request=request, + ) + ) + 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 ( + html.form( + {"on_submit": on_submit}, + *top_children or "", + html_to_vdom(rendered_form), + *bottom_children or "", + ) + if rendered_form + else None + ) + + @component def _django_css(static_path: str): return html.style(_cached_static_contents(static_path)) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 10df2df9..95ff783e 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -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 @@ -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, +): + """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. From 1e937efb4aedf19a5c0590ca787121edf8f855fa Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 12 Jan 2024 18:51:37 -0800 Subject: [PATCH 3/5] minor changes --- src/reactpy_django/components.py | 61 +++++++++++++++++++------------- src/reactpy_django/utils.py | 2 +- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 5d1fb88b..805aab6b 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -13,8 +13,8 @@ from django.urls import reverse from django.views import View from reactpy import component, event, hooks, html -from reactpy.types import Key, VdomDict -from reactpy.utils import del_html_head_body_transform, html_to_vdom +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 ( @@ -55,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. @@ -89,6 +90,7 @@ def constructor( args=args, kwargs=kwargs, key=key, + loading_placeholder=loading_placeholder, ) return constructor @@ -129,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. @@ -159,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( @@ -214,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 @@ -257,15 +266,20 @@ def _view_to_iframe( @component -def django_form( +def _django_form( form: Form, /, - top_children: Sequence | None = None, - bottom_children: Sequence | None = None, + 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, ): - rendered_form, set_rendered_form = hooks.use_state("") + 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()) @@ -275,13 +289,19 @@ async def async_render(): if render_needed: if not request.method: request.method = "GET" - - set_rendered_form( - await render_form( - form, - template_name=template_name, - context=context, - request=request, + # 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 "", ) ) set_render_needed(False) @@ -299,16 +319,7 @@ async def on_submit(event): # Queue a re-render of the form set_render_needed(True) - return ( - html.form( - {"on_submit": on_submit}, - *top_children or "", - html_to_vdom(rendered_form), - *bottom_children or "", - ) - if rendered_form - else None - ) + return convertered_form or loading_placeholder @component diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 95ff783e..edd52924 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -80,7 +80,7 @@ async def render_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 From defbfde435ad5a50dcbb679a7ae1f084ab8875be Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 13 Jan 2024 16:39:08 -0800 Subject: [PATCH 4/5] Add serialization primitive --- src/reactpy_django/components.py | 2 ++ src/reactpy_django/utils.py | 31 ++++++++++++++++++++++++++++++- tests/test_app/components.py | 29 ++++++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 805aab6b..76999ceb 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -304,6 +304,8 @@ async def async_render(): *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) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index edd52924..b706ae69 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -24,7 +24,7 @@ from django.utils.encoding import smart_str from django.views import View from reactpy.core.layout import Layout -from reactpy.types import ComponentConstructor +from reactpy.types import ComponentConstructor, VdomDict from reactpy_django.exceptions import ( ComponentDoesNotExistError, @@ -406,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)) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index dbe9bd8f..07df199f 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -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 @@ -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"), + ) From 474e5457b227257f6f10c630e5556261f06a28b6 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 13 Jan 2024 16:43:22 -0800 Subject: [PATCH 5/5] remove unused import --- src/reactpy_django/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index b706ae69..dad6131a 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -24,7 +24,7 @@ from django.utils.encoding import smart_str from django.views import View from reactpy.core.layout import Layout -from reactpy.types import ComponentConstructor, VdomDict +from reactpy.types import ComponentConstructor from reactpy_django.exceptions import ( ComponentDoesNotExistError,