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/components.py b/src/reactpy_django/components.py index ab02e996..76999ceb 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.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: @@ -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. @@ -82,6 +90,7 @@ def constructor( args=args, kwargs=kwargs, key=key, + loading_placeholder=loading_placeholder, ) return constructor @@ -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. @@ -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( @@ -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, ) @@ -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 @@ -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)) 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, ) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 10df2df9..dad6131a 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, +) -> 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. @@ -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)) 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"), + )