From 79388674f977307648dfd4068d8c64d61ecf7100 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 18 Sep 2023 23:00:21 -0700 Subject: [PATCH 01/28] `view_to_iframe` component --- docs/src/reference/components.md | 120 +++++++++++++++++++------------ src/reactpy_django/components.py | 58 ++++++++------- src/reactpy_django/config.py | 4 +- src/reactpy_django/http/urls.py | 4 +- src/reactpy_django/http/views.py | 13 ++-- src/reactpy_django/types.py | 14 ++-- 6 files changed, 130 insertions(+), 83 deletions(-) diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index d3f235da..fb4bd0b9 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -10,7 +10,9 @@ We supply some pre-designed that components can be used to help simplify develop ## View To Component -Convert any Django view into a ReactPy component by using this decorator. Compatible with [Function Based Views](https://docs.djangoproject.com/en/dev/topics/http/views/) and [Class Based Views](https://docs.djangoproject.com/en/dev/topics/class-based-views/). Views can be sync or async. +Automatically convert a Django view into a ReactPy component. + +Compatible with [Function Based Views](https://docs.djangoproject.com/en/dev/topics/http/views/) and [Class Based Views](https://docs.djangoproject.com/en/dev/topics/class-based-views/). These views can be sync or async. === "components.py" @@ -25,7 +27,7 @@ Convert any Django view into a ReactPy component by using this decorator. Compat | Name | Type | Description | Default | | --- | --- | --- | --- | | `#!python view` | `#!python Callable | View` | The view function or class to convert. | N/A | - | `#!python compatibility` | `#!python bool` | If `#!python True`, the component will be rendered in an iframe. When using compatibility mode `#!python tranforms`, `#!python strict_parsing`, `#!python request`, `#!python args`, and `#!python kwargs` arguments will be ignored. | `#!python False` | + | `#!python compatibility` | `#!python bool` | If `#!python True`, the component will be rendered in an `iframe`. When using compatibility mode `#!python tranforms`, `#!python strict_parsing`, `#!python request`, `#!python args`, and `#!python kwargs` arguments will be ignored. | `#!python False` | | `#!python transforms` | `#!python Sequence[Callable[[VdomDict], Any]]` | A list of functions that transforms the newly generated VDOM. The functions will be called on each VDOM node. | `#!python tuple` | | `#!python strict_parsing` | `#!python bool` | If `#!python True`, an exception will be generated if the HTML does not perfectly adhere to HTML5. | `#!python True` | @@ -35,35 +37,51 @@ Convert any Django view into a ReactPy component by using this decorator. Compat | --- | --- | | `#!python _ViewComponentConstructor` | A function that takes `#!python request, *args, key, **kwargs` and returns a ReactPy component. All parameters are directly provided to your view, besides `#!python key` which is used by ReactPy. | -??? Warning "Potential information exposure when using `#!python compatibility = True`" +??? info "Existing limitations" - When using `#!python compatibility` mode, ReactPy automatically exposes a URL to your view. + There are currently several limitations of using `#!python view_to_component` that may be resolved in a future version. - It is your responsibility to ensure privileged information is not leaked via this method. + - Requires manual intervention to change request methods beyond `GET`. + - ReactPy events cannot conveniently be attached to converted view HTML. + - Has no option to automatically intercept local anchor link (such as `#!html `) click events. - You must implement a method to ensure only authorized users can access your view. This can be done via directly writing conditionals into your view, or by adding decorators such as [`#!python user_passes_test`](https://docs.djangoproject.com/en/dev/topics/auth/default/#django.contrib.auth.decorators.user_passes_test) to your views. For example... +??? question "How do I customize this component's behavior?" - === "Function Based View" + This component accepts several `kwargs` that can be used to customize its behavior. - ```python - {% include "../../python/vtc-fbv-compat.py" %} - ``` + Below are all the `kwargs` that can be used. - === "Class Based View" + --- + + **`#!python strict_parsing`** + + By default, an exception will be generated if your view's HTML does not perfectly adhere to HTML5. + + However, there are some circumstances where you may not have control over the original HTML, so you may be unable to fix it. Or you may be relying on non-standard HTML tags such as `#!html Hello World `. + + In these scenarios, you may want to rely on best-fit parsing by setting the `#!python strict_parsing` parameter to `#!python False`. This uses `libxml2` recovery algorithm, which is designed to be similar to how web browsers would attempt to parse non-standard or broken HTML. + + === "components.py" ```python - {% include "../../python/vtc-cbv-compatibility.py" %} + {% include "../../python/vtc-strict-parsing.py" %} ``` -??? info "Existing limitations" + --- - There are currently several limitations of using `#!python view_to_component` that may be resolved in a future version. + **`#!python transforms`** - - Requires manual intervention to change request methods beyond `GET`. - - ReactPy events cannot conveniently be attached to converted view HTML. - - Has no option to automatically intercept local anchor link (such as `#!html `) click events. + After your view has been turned into [VDOM](https://reactpy.dev/docs/reference/specifications.html#vdom) (python dictionaries), `#!python view_to_component` will call your `#!python transforms` functions on every VDOM node. + + This allows you to modify your view prior to rendering. + + For example, if you are trying to modify the text of a node with a certain `#!python id`, you can create a transform like such: - _Please note these limitations do not exist when using `#!python compatibility` mode._ + === "components.py" + + ```python + {% include "../../python/vtc-transforms.py" %} + ``` ??? question "How do I use this for Class Based Views?" @@ -109,57 +127,71 @@ Convert any Django view into a ReactPy component by using this decorator. Compat {% include "../../python/vtc-args-kwargs.py" %} ``` -??? question "How do I use `#!python strict_parsing`, `#!python compatibility`, and `#!python transforms`?" +## View To Iframe - **`#!python strict_parsing`** +Automatically convert a Django view into an `iframe`. - By default, an exception will be generated if your view's HTML does not perfectly adhere to HTML5. +The contents of this `iframe` is handled entirely by traditional Django view rendering. While this solution is compatible with more views than `view_to_component`, it comes with different limitations. - However, there are some circumstances where you may not have control over the original HTML, so you may be unable to fix it. Or you may be relying on non-standard HTML tags such as `#!html Hello World `. +Compatible with [Function Based Views](https://docs.djangoproject.com/en/dev/topics/http/views/) and [Class Based Views](https://docs.djangoproject.com/en/dev/topics/class-based-views/). These views can be sync or async. - In these scenarios, you may want to rely on best-fit parsing by setting the `#!python strict_parsing` parameter to `#!python False`. +=== "components.py" - === "components.py" + ```python + {% include "../../python/vtc-compatibility.py" %} + ``` - ```python - {% include "../../python/vtc-strict-parsing.py" %} - ``` +??? example "See Interface" - _Note: Best-fit parsing is designed to be similar to how web browsers would handle non-standard or broken HTML._ + **Parameters** - --- + | Name | Type | Description | Default | + | --- | --- | --- | --- | - **`#!python compatibility`** + **Returns** - For views that rely on HTTP responses other than `GET` (such as `PUT`, `POST`, `PATCH`, etc), you should consider using compatibility mode to render your view within an iframe. + | Type | Description | + | --- | --- | + | `#!python _ViewComponentConstructor` | A function that takes `#!python request, *args, key, **kwargs` and returns a ReactPy component. All parameters are directly provided to your view, besides `#!python key` which is used by ReactPy. | - Any view can be rendered within compatibility mode. However, the `#!python transforms`, `#!python strict_parsing`, `#!python request`, `#!python args`, and `#!python kwargs` arguments do not apply to compatibility mode. +??? Warning "Potential information exposure when using this component" + When using this component, ReactPy automatically exposes a URL to your view. + It is your responsibility to ensure privileged information is not leaked via this method. - === "components.py" + You must implement a method to ensure only authorized users can access your view. This can be done via directly writing conditionals into your view, or by adding decorators such as [`#!python user_passes_test`](https://docs.djangoproject.com/en/dev/topics/auth/default/#django.contrib.auth.decorators.user_passes_test) to your views. For example... + + === "Function Based View" ```python - {% include "../../python/vtc-compatibility.py" %} + {% include "../../python/vtc-fbv-compat.py" %} ``` - _Note: By default the `#!python compatibility` iframe is unstyled, and thus won't look pretty until you add some CSS._ + === "Class Based View" - --- + ```python + {% include "../../python/vtc-cbv-compatibility.py" %} + ``` - **`#!python transforms`** +??? info "Existing limitations" - After your view has been turned into [VDOM](https://reactpy.dev/docs/reference/specifications.html#vdom) (python dictionaries), `#!python view_to_component` will call your `#!python transforms` functions on every VDOM node. + There are currently several limitations of using `#!python view_to_iframe` that may be resolved in a future version. - This allows you to modify your view prior to rendering. + - Inability to signal events back to the parent component. + - You must ensure all `view_to_iframe` components are manually loaded during Django startup to ensure multiprocessing compatibility. This usually involves import the file where you define your `view_to_iframe` functions within your `MyAppConfig.ready` method. + - The `iframe` component will always load **after** the parent component. + - CSS styling restrictions inherent to `iframe` elements. - For example, if you are trying to modify the text of a node with a certain `#!python id`, you can create a transform like such: +??? Question "Why do my converted components look ugly?" - === "components.py" + The `iframe` generated by this component is unstyled, and thus won't look pretty until you add some CSS. - ```python - {% include "../../python/vtc-transforms.py" %} - ``` + We recommend removing the `border`, and configuring a `height` and `width`. + +??? question "How do I provide `#!python args` and `#!python kwargs` to a view?" + + ... ## Django CSS diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 31df3e2e..f5b576d9 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -2,7 +2,8 @@ import json import os -from typing import Any, Callable, Protocol, Sequence, Union, cast, overload +from typing import Any, Callable, Sequence, Union, cast, overload +from warnings import warn from django.contrib.staticfiles.finders import find from django.core.cache import caches @@ -10,19 +11,12 @@ from django.urls import reverse from django.views import View from reactpy import component, hooks, html, utils -from reactpy.types import ComponentType, Key, VdomDict +from reactpy.types import Key, VdomDict -from reactpy_django.types import ViewComponentIframe +from reactpy_django.types import IframeComponent, ViewComponentConstructor from reactpy_django.utils import generate_obj_name, render_view -class _ViewComponentConstructor(Protocol): - def __call__( - self, request: HttpRequest | None = None, *args: Any, **kwargs: Any - ) -> ComponentType: - ... - - @component def _view_to_component( view: Callable | View, @@ -33,8 +27,6 @@ def _view_to_component( args: Sequence | None, kwargs: dict | None, ): - from reactpy_django.config import REACTPY_VIEW_COMPONENT_IFRAMES - converted_view, set_converted_view = hooks.use_state( cast(Union[VdomDict, None], None) ) @@ -72,17 +64,15 @@ async def async_render(): # Render in compatibility mode, if needed if compatibility: - dotted_path = f"{view.__module__}.{view.__name__}".replace("<", "").replace(">", "") # type: ignore - REACTPY_VIEW_COMPONENT_IFRAMES[dotted_path] = ViewComponentIframe( - view, _args, _kwargs - ) - return html.iframe( - { - "src": reverse("reactpy:view_to_component", args=[dotted_path]), - "loading": "lazy", - } + # Warn the user that compatibility mode is deprecated + warn( + "view_to_component(compatibility=True) is deprecated and will be removed in a future version. " + "Please use `view_to_iframe_component` instead.", + DeprecationWarning, ) + return view_to_iframe(view)(_args, **_kwargs) + # Return the view if it's been rendered via the `async_render` hook return converted_view @@ -96,7 +86,7 @@ def view_to_component( compatibility: bool = False, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> _ViewComponentConstructor: +) -> ViewComponentConstructor: ... @@ -108,7 +98,7 @@ def view_to_component( compatibility: bool = False, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> Callable[[Callable], _ViewComponentConstructor]: +) -> Callable[[Callable], ViewComponentConstructor]: ... @@ -119,7 +109,7 @@ def view_to_component( compatibility: bool = False, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> _ViewComponentConstructor | Callable[[Callable], _ViewComponentConstructor]: +) -> ViewComponentConstructor | Callable[[Callable], ViewComponentConstructor]: """Converts a Django view to a ReactPy component. Keyword Args: @@ -163,6 +153,26 @@ def wrapper( return decorator(view) if view else decorator +def view_to_iframe(view: Callable | View): + from reactpy_django.config import REACTPY_REGISTERED_IFRAMES + + dotted_path = f"{view.__module__}.{view.__name__}".replace("<", "").replace(">", "") # type: ignore + REACTPY_REGISTERED_IFRAMES[dotted_path] = IframeComponent(view) + + @component + def _view_to_iframe(*args: Any, **kwargs: Any): + return html.iframe( + { + "src": reverse( + "reactpy:view_to_iframe", args=[dotted_path, *args], kwargs=kwargs + ), + "loading": "lazy", + } + ) + + return _view_to_iframe + + @component def _django_css(static_path: str): return html.style(_cached_static_contents(static_path)) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 56011c83..fa4b75ae 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -10,8 +10,8 @@ from reactpy_django.types import ( AsyncPostprocessor, + IframeComponent, SyncPostprocessor, - ViewComponentIframe, ) from reactpy_django.utils import import_dotted_path @@ -19,7 +19,7 @@ REACTPY_DEBUG_MODE.set_current(getattr(settings, "DEBUG")) REACTPY_REGISTERED_COMPONENTS: dict[str, ComponentConstructor] = {} REACTPY_FAILED_COMPONENTS: set[str] = set() -REACTPY_VIEW_COMPONENT_IFRAMES: dict[str, ViewComponentIframe] = {} +REACTPY_REGISTERED_IFRAMES: dict[str, IframeComponent] = {} # Remove in a future release diff --git a/src/reactpy_django/http/urls.py b/src/reactpy_django/http/urls.py index 05bac8e5..8a466e3f 100644 --- a/src/reactpy_django/http/urls.py +++ b/src/reactpy_django/http/urls.py @@ -12,7 +12,7 @@ ), path( "iframe/", - views.view_to_component_iframe, # type: ignore[arg-type] - name="view_to_component", + views.view_to_iframe, # type: ignore[arg-type] + name="view_to_iframe", ), ] diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py index 12129791..5fbae810 100644 --- a/src/reactpy_django/http/views.py +++ b/src/reactpy_django/http/views.py @@ -39,20 +39,19 @@ async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse: return HttpResponse(file_contents, content_type="text/javascript") -async def view_to_component_iframe( - request: HttpRequest, view_path: str +async def view_to_iframe( + request: HttpRequest, view_path: str, *args, **kwargs ) -> HttpResponse: - """Returns a view that was registered by view_to_component. - This view is intended to be used as iframe, for compatibility purposes.""" - from reactpy_django.config import REACTPY_VIEW_COMPONENT_IFRAMES + """Returns a view that was registered by reactpy_django.components.view_to_iframe.""" + from reactpy_django.config import REACTPY_REGISTERED_IFRAMES # Get the view from REACTPY_REGISTERED_IFRAMES - iframe = REACTPY_VIEW_COMPONENT_IFRAMES.get(view_path) + iframe = REACTPY_REGISTERED_IFRAMES.get(view_path) if not iframe: return HttpResponseNotFound() # Render the view - response = await render_view(iframe.view, request, iframe.args, iframe.kwargs) + response = await render_view(iframe.view, request, args, kwargs) # Ensure page can be rendered as an iframe response["X-Frame-Options"] = "SAMEORIGIN" diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 3c77a1eb..83d29790 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -18,6 +18,7 @@ from django.db.models.query import QuerySet from django.http import HttpRequest from django.views.generic import View +from reactpy.types import ComponentType from reactpy.types import Connection as _Connection from typing_extensions import ParamSpec @@ -29,7 +30,7 @@ "Query", "Mutation", "Connection", - "ViewComponentIframe", + "IframeComponent", "AsyncPostprocessor", "SyncPostprocessor", "QueryOptions", @@ -75,10 +76,8 @@ class Mutation(Generic[_Params]): @dataclass -class ViewComponentIframe: +class IframeComponent: view: View | Callable - args: Sequence - kwargs: dict class AsyncPostprocessor(Protocol): @@ -134,3 +133,10 @@ class ComponentParams: args: Sequence kwargs: MutableMapping[str, Any] + + +class ViewComponentConstructor(Protocol): + def __call__( + self, request: HttpRequest | None = None, *args: Any, **kwargs: Any + ) -> ComponentType: + ... From 0ab58227ff58ae40f34978915f02844978355bff Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 18 Sep 2023 23:01:31 -0700 Subject: [PATCH 02/28] add changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f0ec63..62027875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,10 @@ Using the following categories, list your changes in this order: - Renamed undocumented utility function `reactpy_django.utils.ComponentPreloader` to `reactpy_django.utils.RootComponentFinder`. +### Deprecated + +- The `compatibility` argument on `reactpy_django.components.view_to_component` is deprecated. Use `view_to_iframe_component` instead. + ## [3.5.1] - 2023-09-07 ### Added From 79462f97a981957d074d889ff041b36aa593ff9b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 21 Sep 2023 18:45:14 -0700 Subject: [PATCH 03/28] functional view_to_iframe --- src/reactpy_django/checks.py | 2 +- src/reactpy_django/components.py | 17 +++++++++++++---- src/reactpy_django/http/views.py | 10 +++++++--- src/reactpy_django/types.py | 2 ++ tests/test_app/components.py | 11 ++++++++++- tests/test_app/templates/base.html | 5 ++++- tests/test_app/tests/test_components.py | 5 +++++ tests/test_app/views.py | 16 +++++++++++++++- 8 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 489888eb..77bb4153 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -39,7 +39,7 @@ def reactpy_warnings(app_configs, **kwargs): # ReactPy URLs exist try: reverse("reactpy:web_modules", kwargs={"file": "example"}) - reverse("reactpy:view_to_component", kwargs={"view_path": "example"}) + reverse("reactpy:view_to_iframe", kwargs={"view_path": "example"}) except Exception: warnings.append( Warning( diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 09bca072..009ded3a 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -3,6 +3,7 @@ import json import os from typing import Any, Callable, Sequence, Union, cast, overload +from urllib.parse import urlencode from warnings import warn from django.contrib.staticfiles.finders import find @@ -71,7 +72,7 @@ async def async_render(): DeprecationWarning, ) - return view_to_iframe(view)(_args, **_kwargs) + return view_to_iframe(view)(*_args, **_kwargs) # Return the view if it's been rendered via the `async_render` hook return converted_view @@ -161,11 +162,19 @@ def view_to_iframe(view: Callable | View): @component def _view_to_iframe(*args: Any, **kwargs: Any): + query_string = "" + query = {} + if args: + query["_args"] = args + if kwargs: + query.update(kwargs) + if args or kwargs: + query_string = f"?{urlencode(query, doseq=True)}" + return html.iframe( { - "src": reverse( - "reactpy:view_to_iframe", args=[dotted_path, *args], kwargs=kwargs - ), + "src": reverse("reactpy:view_to_iframe", args=[dotted_path]) + + query_string, "loading": "lazy", } ) diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py index 5fbae810..4e4424bd 100644 --- a/src/reactpy_django/http/views.py +++ b/src/reactpy_django/http/views.py @@ -1,4 +1,5 @@ import os +from urllib.parse import parse_qs from aiofile import async_open from django.core.cache import caches @@ -39,9 +40,7 @@ async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse: return HttpResponse(file_contents, content_type="text/javascript") -async def view_to_iframe( - request: HttpRequest, view_path: str, *args, **kwargs -) -> HttpResponse: +async def view_to_iframe(request: HttpRequest, view_path: str) -> HttpResponse: """Returns a view that was registered by reactpy_django.components.view_to_iframe.""" from reactpy_django.config import REACTPY_REGISTERED_IFRAMES @@ -50,6 +49,11 @@ async def view_to_iframe( if not iframe: return HttpResponseNotFound() + # Get args and kwargs from the request + query = request.META.get("QUERY_STRING", "") + kwargs = {k: v if len(v) > 1 else v[0] for k, v in parse_qs(query).items()} + args = kwargs.pop("_args", []) + # Render the view response = await render_view(iframe.view, request, args, kwargs) diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 83d29790..7fb47166 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -77,6 +77,8 @@ class Mutation(Generic[_Params]): @dataclass class IframeComponent: + """Views registered by `view_to_iframe`.""" + view: View | Callable diff --git a/tests/test_app/components.py b/tests/test_app/components.py index ec53c031..db118343 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -7,7 +7,7 @@ from django.http import HttpRequest from django.shortcuts import render from reactpy import component, hooks, html, web -from reactpy_django.components import view_to_component +from reactpy_django.components import view_to_component, view_to_iframe from reactpy_django.types import QueryOptions from test_app.models import ( @@ -468,6 +468,7 @@ async def on_change(event): _view_to_component_template_view_class_compatibility = view_to_component( views.ViewToComponentTemplateViewClassCompatibility, compatibility=True ) +_view_to_iframe_args = view_to_iframe(views.view_to_iframe_args) view_to_component_script = view_to_component(views.view_to_component_script) _view_to_component_request = view_to_component(views.view_to_component_request) _view_to_component_args = view_to_component(views.view_to_component_args) @@ -514,6 +515,14 @@ def view_to_component_template_view_class_compatibility(): ) +@component +def view_to_iframe_args(): + return html.div( + {"id": inspect.currentframe().f_code.co_name}, # type: ignore + _view_to_iframe_args("Arg1", "Arg2", kwarg1="Kwarg1", kwarg2="Kwarg2"), + ) + + @component def view_to_component_request(): request, set_request = hooks.use_state(None) diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index 03dd3ba3..f2042fd4 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -81,6 +81,8 @@

ReactPy Test Page


{% component "test_app.components.view_to_component_template_view_class_compatibility" %}
+ {% component "test_app.components.view_to_iframe_args" %} +
{% component "test_app.components.view_to_component_decorator" %}
{% component "test_app.components.view_to_component_decorator_args" %} @@ -92,7 +94,8 @@

ReactPy Test Page

{% component "test_app.components.hello_world" host="https://example.com/" %}

- {% component "test_app.components.broken_postprocessor_query" %}
+ {% component "test_app.components.broken_postprocessor_query" %} +
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 36f3f164..d6b49793 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -264,6 +264,11 @@ def test_view_to_component_template_view_class_compatibility(self): "#ViewToComponentTemplateViewClassCompatibility[data-success=true]" ).wait_for() + def test_view_to_iframe_args(self): + self.page.frame_locator("#view_to_iframe_args > iframe").locator( + "#view_to_iframe_args[data-success=Success]" + ).wait_for() + def test_view_to_component_decorator(self): self.page.locator("#view_to_component_decorator[data-success=true]").wait_for() diff --git a/tests/test_app/views.py b/tests/test_app/views.py index 689d8f8c..2bd06747 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -124,6 +124,21 @@ def get_context_data(self, **kwargs): return {"test_name": self.__class__.__name__} +def view_to_iframe_args(request, arg1, arg2, kwarg1=None, kwarg2=None): + success = ( + arg1 == "Arg1" and arg2 == "Arg2" and kwarg1 == "Kwarg1" and kwarg2 == "Kwarg2" + ) + + return render( + request, + "view_to_component.html", + { + "test_name": inspect.currentframe().f_code.co_name, # type:ignore + "status": "Success" if success else "false", + }, + ) + + def view_to_component_script(request): return render( request, @@ -149,7 +164,6 @@ def view_to_component_request(request): { "test_name": inspect.currentframe().f_code.co_name, # type:ignore "status": "false", - "success": "false", }, ) From 68e15a05d0ad85e5cc92064c811d0f8f3f720aa4 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 21 Sep 2023 19:21:56 -0700 Subject: [PATCH 04/28] ViewToIframeConstructor type --- docs/src/reference/components.md | 4 ++-- src/reactpy_django/components.py | 14 +++++++------- src/reactpy_django/config.py | 5 +++-- src/reactpy_django/http/views.py | 2 +- src/reactpy_django/types.py | 16 ++++++---------- src/reactpy_django/utils.py | 5 +++++ 6 files changed, 24 insertions(+), 22 deletions(-) diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index 2f7d7015..b2eb4fe0 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -35,7 +35,7 @@ Compatible with [Function Based Views](https://docs.djangoproject.com/en/dev/top | Type | Description | | --- | --- | - | `#!python _ViewComponentConstructor` | A function that takes `#!python request, *args, key, **kwargs` and returns a ReactPy component. All parameters are directly provided to your view, besides `#!python key` which is used by ReactPy. | + | `#!python ViewToComponentConstructor` | A function that takes `#!python request, *args, key, **kwargs` and returns a ReactPy component. All parameters are directly provided to your view, besides `#!python key` which is used by ReactPy. | ??? info "Existing limitations" @@ -152,7 +152,7 @@ Compatible with [Function Based Views](https://docs.djangoproject.com/en/dev/top | Type | Description | | --- | --- | - | `#!python _ViewComponentConstructor` | A function that takes `#!python request, *args, key, **kwargs` and returns a ReactPy component. All parameters are directly provided to your view, besides `#!python key` which is used by ReactPy. | + | `#!python ViewToComponentConstructor` | A function that takes `#!python request, *args, key, **kwargs` and returns a ReactPy component. All parameters are directly provided to your view, besides `#!python key` which is used by ReactPy. | ??? Warning "Potential information exposure when using this component" diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 009ded3a..991c1b36 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -14,7 +14,7 @@ from reactpy import component, hooks, html, utils from reactpy.types import Key, VdomDict -from reactpy_django.types import IframeComponent, ViewComponentConstructor +from reactpy_django.types import ViewToComponentConstructor, ViewToIframeConstructor from reactpy_django.utils import generate_obj_name, render_view @@ -87,7 +87,7 @@ def view_to_component( compatibility: bool = False, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> ViewComponentConstructor: +) -> ViewToComponentConstructor: ... @@ -99,7 +99,7 @@ def view_to_component( compatibility: bool = False, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> Callable[[Callable], ViewComponentConstructor]: +) -> Callable[[Callable], ViewToComponentConstructor]: ... @@ -110,7 +110,7 @@ def view_to_component( compatibility: bool = False, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> ViewComponentConstructor | Callable[[Callable], ViewComponentConstructor]: +) -> ViewToComponentConstructor | Callable[[Callable], ViewToComponentConstructor]: """Converts a Django view to a ReactPy component. Keyword Args: @@ -154,11 +154,11 @@ def wrapper( return decorator(view) if view else decorator -def view_to_iframe(view: Callable | View): +def view_to_iframe(view: Callable | View) -> ViewToIframeConstructor: from reactpy_django.config import REACTPY_REGISTERED_IFRAMES - dotted_path = f"{view.__module__}.{view.__name__}".replace("<", "").replace(">", "") # type: ignore - REACTPY_REGISTERED_IFRAMES[dotted_path] = IframeComponent(view) + dotted_path = generate_obj_name(view).replace("<", "").replace(">", "") + REACTPY_REGISTERED_IFRAMES[dotted_path] = view @component def _view_to_iframe(*args: Any, **kwargs: Any): diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index fa4b75ae..c0d8524d 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -1,16 +1,17 @@ from __future__ import annotations from itertools import cycle +from typing import Callable from django.conf import settings from django.core.cache import DEFAULT_CACHE_ALIAS from django.db import DEFAULT_DB_ALIAS +from django.views import View from reactpy.config import REACTPY_DEBUG_MODE from reactpy.core.types import ComponentConstructor from reactpy_django.types import ( AsyncPostprocessor, - IframeComponent, SyncPostprocessor, ) from reactpy_django.utils import import_dotted_path @@ -19,7 +20,7 @@ REACTPY_DEBUG_MODE.set_current(getattr(settings, "DEBUG")) REACTPY_REGISTERED_COMPONENTS: dict[str, ComponentConstructor] = {} REACTPY_FAILED_COMPONENTS: set[str] = set() -REACTPY_REGISTERED_IFRAMES: dict[str, IframeComponent] = {} +REACTPY_REGISTERED_IFRAMES: dict[str, Callable | View] = {} # Remove in a future release diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py index 4e4424bd..87c1a837 100644 --- a/src/reactpy_django/http/views.py +++ b/src/reactpy_django/http/views.py @@ -55,7 +55,7 @@ async def view_to_iframe(request: HttpRequest, view_path: str) -> HttpResponse: args = kwargs.pop("_args", []) # Render the view - response = await render_view(iframe.view, request, args, kwargs) + response = await render_view(iframe, request, args, kwargs) # Ensure page can be rendered as an iframe response["X-Frame-Options"] = "SAMEORIGIN" diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 7fb47166..8ccb1790 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -17,7 +17,6 @@ from django.db.models.base import Model from django.db.models.query import QuerySet from django.http import HttpRequest -from django.views.generic import View from reactpy.types import ComponentType from reactpy.types import Connection as _Connection from typing_extensions import ParamSpec @@ -30,7 +29,6 @@ "Query", "Mutation", "Connection", - "IframeComponent", "AsyncPostprocessor", "SyncPostprocessor", "QueryOptions", @@ -75,13 +73,6 @@ class Mutation(Generic[_Params]): reset: Callable[[], None] -@dataclass -class IframeComponent: - """Views registered by `view_to_iframe`.""" - - view: View | Callable - - class AsyncPostprocessor(Protocol): async def __call__(self, data: Any) -> Any: ... @@ -137,8 +128,13 @@ class ComponentParams: kwargs: MutableMapping[str, Any] -class ViewComponentConstructor(Protocol): +class ViewToComponentConstructor(Protocol): def __call__( self, request: HttpRequest | None = None, *args: Any, **kwargs: Any ) -> ComponentType: ... + + +class ViewToIframeConstructor(Protocol): + def __call__(self, *args: Any, **kwargs: Any) -> ComponentType: + ... diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index e06df1ce..143f69ff 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -225,14 +225,19 @@ def register_components(self, components: set[str]) -> None: def generate_obj_name(object: Any) -> str: """Makes a best effort to create a name for an object. Useful for JSON serialization of Python objects.""" + + # Attempt to use dunder methods to create a name if hasattr(object, "__module__"): if hasattr(object, "__name__"): return f"{object.__module__}.{object.__name__}" if hasattr(object, "__class__"): return f"{object.__module__}.{object.__class__.__name__}" + # First fallback: String representation with contextlib.suppress(Exception): return str(object) + + # Last fallback: Empty string return "" From 46dc39e0ec2b34f72cceaa212832fbddc2953f2a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 21 Sep 2023 19:28:57 -0700 Subject: [PATCH 05/28] temporarily remove section index plugin --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 35bb6bb0..5c308d00 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -74,7 +74,6 @@ markdown_extensions: plugins: - search - include-markdown - - section-index - git-authors - minify: minify_html: true @@ -87,6 +86,7 @@ plugins: known_words: dictionary.txt allow_unicode: no ignore_code: yes + # - section-index extra: generator: false From b5085893b21f8cb8aa8dd216d295a6e3b72b6077 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 23 Sep 2023 01:36:04 -0700 Subject: [PATCH 06/28] better API --- src/reactpy_django/components.py | 65 +++++++++++++++++++------------- src/reactpy_django/types.py | 5 --- tests/test_app/components.py | 5 ++- 3 files changed, 42 insertions(+), 33 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 991c1b36..b6cd9837 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -12,9 +12,9 @@ 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.types import ComponentType, Key, VdomDict -from reactpy_django.types import ViewToComponentConstructor, ViewToIframeConstructor +from reactpy_django.types import ViewToComponentConstructor from reactpy_django.utils import generate_obj_name, render_view @@ -68,11 +68,11 @@ async def async_render(): # Warn the user that compatibility mode is deprecated warn( "view_to_component(compatibility=True) is deprecated and will be removed in a future version. " - "Please use `view_to_iframe_component` instead.", + "Please use `view_to_iframe` instead.", DeprecationWarning, ) - return view_to_iframe(view)(*_args, **_kwargs) + return view_to_iframe(view, *_args, **_kwargs) # Return the view if it's been rendered via the `async_render` hook return converted_view @@ -103,8 +103,6 @@ def view_to_component( ... -# TODO: Might want to intercept href clicks and form submit events. -# Form events will probably be accomplished through the upcoming DjangoForm. def view_to_component( view: Callable | View | None = None, compatibility: bool = False, @@ -151,35 +149,50 @@ def wrapper( return wrapper + if not view: + warn( + "Using `view_to_component` as a decorator is deprecated. " + "This functionality will be removed in a future version.", + DeprecationWarning, + ) + return decorator(view) if view else decorator -def view_to_iframe(view: Callable | View) -> ViewToIframeConstructor: +@component +def _view_to_iframe( + view: Callable | View, *args, extra_props: dict | None = None, **kwargs +) -> VdomDict: from reactpy_django.config import REACTPY_REGISTERED_IFRAMES dotted_path = generate_obj_name(view).replace("<", "").replace(">", "") REACTPY_REGISTERED_IFRAMES[dotted_path] = view + extra_props = extra_props or {} + extra_props.pop("src", None) + + query = kwargs.copy() + if args: + query["_args"] = args + + query_string = f"?{urlencode(query, doseq=True)}" if args or kwargs else "" + + return html.iframe( + { + "src": reverse("reactpy:view_to_iframe", args=[dotted_path]) + query_string, + "style": {"border": "none", "width": "100%", "height": "auto"}, + "loading": "lazy", + } + | extra_props + ) - @component - def _view_to_iframe(*args: Any, **kwargs: Any): - query_string = "" - query = {} - if args: - query["_args"] = args - if kwargs: - query.update(kwargs) - if args or kwargs: - query_string = f"?{urlencode(query, doseq=True)}" - - return html.iframe( - { - "src": reverse("reactpy:view_to_iframe", args=[dotted_path]) - + query_string, - "loading": "lazy", - } - ) - return _view_to_iframe +def view_to_iframe( + view: Callable | View, *args, extra_props: dict | None = None, **kwargs +) -> ComponentType: + """ + TODO: Fill this out + """ + return _view_to_iframe(view, *args, extra_props=extra_props, **kwargs) @component diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 8ccb1790..2be95e7a 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -133,8 +133,3 @@ def __call__( self, request: HttpRequest | None = None, *args: Any, **kwargs: Any ) -> ComponentType: ... - - -class ViewToIframeConstructor(Protocol): - def __call__(self, *args: Any, **kwargs: Any) -> ComponentType: - ... diff --git a/tests/test_app/components.py b/tests/test_app/components.py index db118343..444d49d8 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -468,7 +468,6 @@ async def on_change(event): _view_to_component_template_view_class_compatibility = view_to_component( views.ViewToComponentTemplateViewClassCompatibility, compatibility=True ) -_view_to_iframe_args = view_to_iframe(views.view_to_iframe_args) view_to_component_script = view_to_component(views.view_to_component_script) _view_to_component_request = view_to_component(views.view_to_component_request) _view_to_component_args = view_to_component(views.view_to_component_args) @@ -519,7 +518,9 @@ def view_to_component_template_view_class_compatibility(): def view_to_iframe_args(): return html.div( {"id": inspect.currentframe().f_code.co_name}, # type: ignore - _view_to_iframe_args("Arg1", "Arg2", kwarg1="Kwarg1", kwarg2="Kwarg2"), + view_to_iframe( + views.view_to_iframe_args, "Arg1", "Arg2", kwarg1="Kwarg1", kwarg2="Kwarg2" + ), ) From afe31ac8aa84bbddd49a0998caa18b018bb373dd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 23 Sep 2023 01:37:18 -0700 Subject: [PATCH 07/28] update docs --- CHANGELOG.md | 4 +- docs/python/hello_world_args_kwargs.py | 5 + docs/python/hello_world_cbv.py | 7 ++ docs/python/hello_world_fbv.py | 5 + docs/python/hello_world_fbv_with_id.py | 5 + docs/python/views.py | 7 ++ docs/python/vtc-args-kwargs.py | 21 ---- docs/python/vtc-args.py | 23 ++++ docs/python/vtc-cbv-compatibility.py | 10 -- docs/python/vtc-cbv.py | 12 +- docs/python/vtc-compatibility.py | 15 --- docs/python/vtc-fbv-compat.py | 8 -- docs/python/vtc-func.py | 12 -- docs/python/vtc-request.py | 20 --- docs/python/vtc-strict-parsing.py | 8 +- docs/python/vtc-transforms.py | 13 +- docs/python/vtc.py | 8 +- docs/python/vti-args.py | 17 +++ docs/python/vti-cbv.py | 11 ++ docs/python/vti-extra-props.py | 11 ++ docs/python/vti.py | 11 ++ docs/src/reference/components.md | 167 +++++++++++++++---------- 22 files changed, 223 insertions(+), 177 deletions(-) create mode 100644 docs/python/hello_world_args_kwargs.py create mode 100644 docs/python/hello_world_cbv.py create mode 100644 docs/python/hello_world_fbv.py create mode 100644 docs/python/hello_world_fbv_with_id.py create mode 100644 docs/python/views.py delete mode 100644 docs/python/vtc-args-kwargs.py create mode 100644 docs/python/vtc-args.py delete mode 100644 docs/python/vtc-cbv-compatibility.py delete mode 100644 docs/python/vtc-compatibility.py delete mode 100644 docs/python/vtc-fbv-compat.py delete mode 100644 docs/python/vtc-func.py delete mode 100644 docs/python/vtc-request.py create mode 100644 docs/python/vti-args.py create mode 100644 docs/python/vti-cbv.py create mode 100644 docs/python/vti-extra-props.py create mode 100644 docs/python/vti.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 62027875..423940d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Using the following categories, list your changes in this order: - ReactPy components can now use SEO compatible rendering! - `settings.py:REACTPY_PRERENDER` can be set to `True` to enable this behavior by default - Or, you can enable it on individual components via the template tag: `{% component "..." prerender="True" %}` +- `reactpy_django.components.view_to_iframe` component has been added, which uses an `