diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a85b93..caf95b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,12 +28,14 @@ Using the following categories, list your changes in this order: ### Changed +- `view_to_component` now returns a `Callable`, instead of directly returning a `Component`. Check the docs for new usage info. - `use_mutation` and `use_query` will now log any query failures. ### Fixed - Allow `use_mutation` to have `refetch=None`, as the docs suggest is possible. - `use_query` will now prefetch all fields to prevent `SynchronousOnlyOperation` exceptions. +- `view_to_component`, `django_css`, and `django_js` type hints will now display like normal functions. ## [1.2.0] - 2022-09-19 diff --git a/docs/includes/examples.md b/docs/includes/examples.md index 3ed273fd..0edbf273 100644 --- a/docs/includes/examples.md +++ b/docs/includes/examples.md @@ -1,27 +1,3 @@ - - -```python linenums="1" -from django.http import HttpResponse - -def hello_world_view(request, *args, **kwargs): - return HttpResponse("Hello World!") -``` - - - - - -```python linenums="1" -from django.http import HttpResponse -from django.views import View - -class HelloWorldView(View): - def get(self, request, *args, **kwargs): - return HttpResponse("Hello World!") -``` - - - ```python linenums="1" diff --git a/docs/src/features/components.md b/docs/src/features/components.md index 5886fc23..aee6f60d 100644 --- a/docs/src/features/components.md +++ b/docs/src/features/components.md @@ -10,20 +10,20 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible ```python linenums="1" from idom import component, html + from django.http import HttpResponse from django_idom.components import view_to_component - from .views import hello_world_view + + @view_to_component + def hello_world_view(request): + return HttpResponse("Hello World!") @component def my_component(): return html.div( - view_to_component(hello_world_view), + hello_world_view(), ) ``` -=== "views.py" - - {% include-markdown "../../includes/examples.md" start="" end="" %} - ??? example "See Interface" **Parameters** @@ -31,73 +31,128 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible | Name | Type | Description | Default | | --- | --- | --- | --- | | view | `Callable | View` | The view function or class to convert. | N/A | - | compatibility | `bool` | If True, the component will be rendered in an iframe. When using compatibility mode `tranforms`, `strict_parsing`, and `request` arguments will be ignored. | `False` | - | transforms | `Iterable[Callable[[VdomDict], Any]]` | A list of functions that transforms the newly generated VDOM. The functions will be called on each VDOM node. | `tuple` | + | compatibility | `bool` | If True, the component will be rendered in an iframe. When using compatibility mode `tranforms`, `strict_parsing`, `request`, `args`, and `kwargs` arguments will be ignored. | `False` | + | transforms | `Sequence[Callable[[VdomDict], Any]]` | A list of functions that transforms the newly generated VDOM. The functions will be called on each VDOM node. | `tuple` | | strict_parsing | `bool` | If True, an exception will be generated if the HTML does not perfectly adhere to HTML5. | `True` | - | request | `HttpRequest | None` | Request object to provide to the view. | `None` | - | args | `Iterable` | The positional arguments to pass to the view. | `tuple` | - | kwargs | `Dict | None` | The keyword arguments to pass to the view. | `None` | **Returns** | Type | Description | | --- | --- | - | `Component` | An IDOM component. | - | `None` | No component render. | + | `_ViewComponentConstructor` | A function that takes `request: HttpRequest | None, *args: Any, **kwargs: Any` and returns an IDOM component. | + +??? warning "Existing limitations" + + There are currently several limitations of using `view_to_component` that may be resolved in a future version of `django_idom`. + + - Requires manual intervention to change request methods beyond `GET`. + - Does not currently load any HTML contained with a `` tag + - Has no option to automatically intercept local anchor link (ex. `#!html `) click events + + _Please note these limitations do not exist when using `compatibility` mode._ ??? question "How do I use this for Class Based Views?" - You can simply pass your Class Based View directly into this function. + You can simply pass your Class Based View directly into `view_to_component`. === "components.py" ```python linenums="1" from idom import component, html + from django.http import HttpResponse + from django.views import View from django_idom.components import view_to_component - from .views import HelloWorldView + + @view_to_component + class HelloWorldView(View): + def get(self, request): + return HttpResponse("Hello World!") @component def my_component(): return html.div( - view_to_component(HelloWorldView), + HelloWorldView(), ) ``` - === "views.py" +??? question "How do I transform views from external libraries?" + + === "components.py" - {% include-markdown "../../includes/examples.md" start="" end="" %} + In order to convert external views, you can utilize `view_to_component` as a function, rather than a decorator. -??? question "How do I pass arguments into the view?" + ```python linenums="1" + from idom import component, html + from django.http import HttpResponse + from django_idom.components import view_to_component + from some_library.views import example_view - You can use the `args` and `kwargs` parameters to pass arguments to the view. + converted_view = view_to_component(example_view) + + @component + def my_component(): + return html.div( + converted_view(), + ) + ``` + +??? question "How do I provide `args` and `kwargs` to a view?" + + You can use the `args` and `kwargs` parameters to provide positional and keyworded arguments to a view. === "components.py" ```python linenums="1" from idom import component, html + from django.http import HttpResponse from django_idom.components import view_to_component - from .views import hello_world_view + + @view_to_component + def hello_world_view(request, arg1, arg2, key1=None, key2=None): + return HttpResponse(f"Hello World! {arg1} {arg2} {key1} {key2}") @component def my_component(): return html.div( - view_to_component( - hello_world_view, + hello_world_view( args=["value_1", "value_2"], - kwargs={"key_1": "value_1", "key_2": "value_2"}, + kwargs={"key1": "abc", "key2": "123"}, ), ) ``` - === "views.py" +??? question "How do I provide a custom `request` object to a view?" - {% include-markdown "../../includes/examples.md" start="" end="" %} + You can use the `request` parameter to provide the view a custom request object. + + === "components.py" + + ```python linenums="1" + from idom import component, html + from django.http import HttpResponse, HttpRequest + from django_idom.components import view_to_component + + example_request = HttpRequest() + example_request.method = "PUT" + + @view_to_component + def hello_world_view(request): + return HttpResponse(f"Hello World! {request.method}") + + @component + def my_component(): + return html.div( + hello_world_view( + request=example_request, + ), + ) + ``` ??? question "What is `compatibility` mode?" 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. - Any view can be rendered within compatibility mode. However, the `transforms`, `strict_parsing`, and `request` arguments do not apply to compatibility mode. + Any view can be rendered within compatibility mode. However, the `transforms`, `strict_parsing`, `request`, `args`, and `kwargs` arguments do not apply to compatibility mode. Please note that by default the iframe is unstyled, and thus won't look pretty until you add some CSS. @@ -105,20 +160,20 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible ```python linenums="1" from idom import component, html + from django.http import HttpResponse from django_idom.components import view_to_component - from .views import hello_world_view + + @view_to_component(compatibility=True) + def hello_world_view(request): + return HttpResponse("Hello World!") @component def my_component(): return html.div( - view_to_component(hello_world_view, compatibility=True), + hello_world_view(), ) ``` - === "views.py" - - {% include-markdown "../../includes/examples.md" start="" end="" %} - ??? question "What is `strict_parsing`?" By default, an exception will be generated if your view's HTML does not perfectly adhere to HTML5. @@ -131,20 +186,20 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible ```python linenums="1" from idom import component, html + from django.http import HttpResponse from django_idom.components import view_to_component - from .views import hello_world_view + + @view_to_component(strict_parsing=False) + def hello_world_view(request): + return HttpResponse(" Hello World ") @component def my_component(): return html.div( - view_to_component(hello_world_view, strict_parsing=False), + hello_world_view(), ) ``` - === "views.py" - - {% include-markdown "../../includes/examples.md" start="" end="" %} - Note that best-fit parsing is very similar to how web browsers will handle broken HTML. ??? question "What is `transforms`?" @@ -159,32 +214,25 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible ```python linenums="1" from idom import component, html + from django.http import HttpResponse from django_idom.components import view_to_component - from .views import hello_world_view def example_transform(vdom): attributes = vdom.get("attributes") - if attributes and attributes.get("id") == "hello-world": vdom["children"][0] = "Good Bye World!" + @view_to_component(transforms=[example_transform]) + def hello_world_view(request): + return HttpResponse("
Hello World!
") + @component def my_component(): - return view_to_component( - hello_world_view, - transforms=[example_transform], + return html.div( + hello_world_view(), ) ``` - === "views.py" - - ```python linenums="1" - from django.http import HttpResponse - - def hello_world_view(request, *args, **kwargs): - return HttpResponse("
Hello World!
") - ``` - ## Django CSS Allows you to defer loading a CSS stylesheet until a component begins rendering. This stylesheet must be stored within [Django's static files](https://docs.djangoproject.com/en/dev/howto/static-files/). diff --git a/src/django_idom/components.py b/src/django_idom/components.py index 0a91c5c0..3aacdfa4 100644 --- a/src/django_idom/components.py +++ b/src/django_idom/components.py @@ -3,7 +3,7 @@ import json import os from inspect import iscoroutinefunction -from typing import Any, Callable, Dict, Iterable +from typing import Any, Callable, Dict, Protocol, Sequence from channels.db import database_sync_to_async from django.contrib.staticfiles.finders import find @@ -11,48 +11,45 @@ from django.urls import reverse from django.views import View from idom import component, hooks, html, utils -from idom.types import VdomDict +from idom.types import ComponentType, VdomDict from django_idom.config import IDOM_CACHE, IDOM_VIEW_COMPONENT_IFRAMES from django_idom.types import ViewComponentIframe from django_idom.utils import _generate_obj_name -# TODO: Might want to intercept href clicks and form submit events. -# Form events will probably be accomplished through the upcoming DjangoForm. -@component -def view_to_component( - view: Callable | View, - compatibility: bool = False, - transforms: Iterable[Callable[[VdomDict], Any]] = (), - strict_parsing: bool = True, - request: HttpRequest | None = None, - args: Iterable = (), - kwargs: Dict | None = None, -) -> VdomDict | None: - """Converts a Django view to an IDOM component. +class _ViewComponentConstructor(Protocol): + def __call__( + self, request: HttpRequest | None, *args: Any, **kwargs: Any + ) -> ComponentType: + ... - Args: - view: The view function or class to convert. - Keyword Args: - compatibility: If True, the component will be rendered in an iframe. - When using compatibility mode `tranforms`, `strict_parsing`, and `request` - arguments will be ignored. - transforms: A list of functions that transforms the newly generated VDOM. - The functions will be called on each VDOM node. - strict_parsing: If True, an exception will be generated if the HTML does not - perfectly adhere to HTML5. - request: Request object to provide to the view. - args: The positional arguments to pass to the view. - kwargs: The keyword arguments to pass to the view. - """ +@component +def _view_to_component( + view: Callable | View, + compatibility: bool, + transforms: Sequence[Callable[[VdomDict], Any]], + strict_parsing: bool, + request: HttpRequest | None, + args: Sequence | None, + kwargs: Dict | None, +): + converted_view, set_converted_view = hooks.use_state(None) + args = args or () kwargs = kwargs or {} - rendered_view, set_rendered_view = hooks.use_state(None) request_obj = request - if not request: + if not request_obj: request_obj = HttpRequest() request_obj.method = "GET" + if compatibility: + dotted_path = f"{view.__module__}.{view.__name__}" # type: ignore[union-attr] + dotted_path = dotted_path.replace("<", "").replace(">", "") + IDOM_VIEW_COMPONENT_IFRAMES[dotted_path] = ViewComponentIframe( + view, args, kwargs + ) + else: + dotted_path = None # Render the view render within a hook @hooks.use_effect( @@ -61,18 +58,11 @@ def view_to_component( json.dumps([args, kwargs], default=lambda x: _generate_obj_name(x)), ] ) - async def async_renderer(): + async def async_render(): """Render the view in an async hook to avoid blocking the main thread.""" # Render Check 1: Compatibility mode if compatibility: - dotted_path = f"{view.__module__}.{view.__name__}" - dotted_path = dotted_path.replace("<", "").replace(">", "") - IDOM_VIEW_COMPONENT_IFRAMES[dotted_path] = ViewComponentIframe( - view, args, kwargs - ) - - # Signal that the view has been rendered - set_rendered_view( + set_converted_view( html.iframe( { "src": reverse("idom:view_to_component", args=[dotted_path]), @@ -84,43 +74,96 @@ async def async_renderer(): # Render Check 2: Async function view elif iscoroutinefunction(view): - render = await view(request_obj, *args, **kwargs) + view_html = await view(request_obj, *args, **kwargs) # Render Check 3: Async class view elif getattr(view, "view_is_async", False): view_or_template_view = await view.as_view()(request_obj, *args, **kwargs) if getattr(view_or_template_view, "render", None): # TemplateView - render = await view_or_template_view.render() + view_html = await view_or_template_view.render() else: # View - render = view_or_template_view + view_html = view_or_template_view # Render Check 4: Sync class view elif getattr(view, "as_view", None): async_cbv = database_sync_to_async(view.as_view()) view_or_template_view = await async_cbv(request_obj, *args, **kwargs) if getattr(view_or_template_view, "render", None): # TemplateView - render = await database_sync_to_async(view_or_template_view.render)() + view_html = await database_sync_to_async(view_or_template_view.render)() else: # View - render = view_or_template_view + view_html = view_or_template_view # Render Check 5: Sync function view else: - render = await database_sync_to_async(view)(request_obj, *args, **kwargs) + view_html = await database_sync_to_async(view)(request_obj, *args, **kwargs) # Signal that the view has been rendered - set_rendered_view( + set_converted_view( utils.html_to_vdom( - render.content.decode("utf-8").strip(), + view_html.content.decode("utf-8").strip(), *transforms, strict=strict_parsing, ) ) - # Return the view if it's been rendered via the `async_renderer` hook - return rendered_view + # Return the view if it's been rendered via the `async_render` hook + return converted_view + + +# 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, # type: ignore[assignment] + compatibility: bool = False, + transforms: Sequence[Callable[[VdomDict], Any]] = (), + strict_parsing: bool = True, +) -> _ViewComponentConstructor: + """Converts a Django view to an IDOM component. + + Keyword Args: + view: The view function or class to convert. + compatibility: If True, the component will be rendered in an iframe. + When using compatibility mode `tranforms`, `strict_parsing`, `request`, + `args, and `kwargs` arguments will be ignored. + transforms: A list of functions that transforms the newly generated VDOM. + The functions will be called on each VDOM node. + strict_parsing: If True, an exception will be generated if the HTML does not + perfectly adhere to HTML5. + + Returns: + Callable: A function that takes `request: HttpRequest | None, *args: Any, **kwargs: Any` + and returns an IDOM component. + """ + + def decorator(view: Callable | View): + if not view: + raise ValueError("A view must be provided to `view_to_component`") + + def wrapper( + request: HttpRequest | None = None, + *args: Any, + **kwargs: Any, + ): + return _view_to_component( + view=view, + compatibility=compatibility, + transforms=transforms, + strict_parsing=strict_parsing, + request=request, + args=args, + kwargs=kwargs, + ) + + return wrapper + + return decorator(view) if view else decorator @component +def _django_css(static_path: str): + return html.style(_cached_static_contents(static_path)) + + def django_css(static_path: str): """Fetches a CSS static file for use within IDOM. This allows for deferred CSS loading. @@ -128,10 +171,15 @@ def django_css(static_path: str): static_path: The path to the static file. This path is identical to what you would use on a `static` template tag. """ - return html.style(_cached_static_contents(static_path)) + + return _django_css(static_path=static_path) @component +def _django_js(static_path: str): + return html.script(_cached_static_contents(static_path)) + + def django_js(static_path: str): """Fetches a JS static file for use within IDOM. This allows for deferred JS loading. @@ -139,7 +187,8 @@ def django_js(static_path: str): static_path: The path to the static file. This path is identical to what you would use on a `static` template tag. """ - return html.script(_cached_static_contents(static_path)) + + return _django_js(static_path=static_path) def _cached_static_contents(static_path: str): diff --git a/src/django_idom/types.py b/src/django_idom/types.py index 1f4bd406..a13bbe8f 100644 --- a/src/django_idom/types.py +++ b/src/django_idom/types.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Awaitable, Callable, Generic, Iterable, Optional, TypeVar, Union +from typing import Any, Awaitable, Callable, Generic, Optional, Sequence, TypeVar, Union from django.db.models.base import Model from django.db.models.query import QuerySet @@ -49,5 +49,5 @@ class Mutation(Generic[_Params]): @dataclass class ViewComponentIframe: view: View | Callable - args: Iterable + args: Sequence kwargs: dict diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 40fbd7d6..b4140b6f 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -1,6 +1,7 @@ import inspect from django.http import HttpRequest +from django.shortcuts import render from idom import component, hooks, html, web from test_app.models import TodoItem @@ -247,38 +248,39 @@ def _render_items(items, toggle_item): ) -@component -def view_to_component_sync_func(): - return view_to_component(views.view_to_component_sync_func) - - -@component -def view_to_component_async_func(): - return view_to_component(views.view_to_component_async_func) - - -@component -def view_to_component_sync_class(): - return view_to_component(views.ViewToComponentSyncClass) - - -@component -def view_to_component_async_class(): - return view_to_component(views.ViewToComponentAsyncClass) - - -@component -def view_to_component_template_view_class(): - return view_to_component(views.ViewToComponentTemplateViewClass) +view_to_component_sync_func = view_to_component(views.view_to_component_sync_func) +view_to_component_async_func = view_to_component(views.view_to_component_async_func) +view_to_component_sync_class = view_to_component(views.ViewToComponentSyncClass) +view_to_component_async_class = view_to_component(views.ViewToComponentAsyncClass) +view_to_component_template_view_class = view_to_component( + views.ViewToComponentTemplateViewClass +) +_view_to_component_sync_func_compatibility = view_to_component( + views.view_to_component_sync_func_compatibility, compatibility=True +) +_view_to_component_async_func_compatibility = view_to_component( + views.view_to_component_async_func_compatibility, compatibility=True +) +_view_to_component_sync_class_compatibility = view_to_component( + views.ViewToComponentSyncClassCompatibility, compatibility=True +) +_view_to_component_async_class_compatibility = view_to_component( + views.ViewToComponentAsyncClassCompatibility, compatibility=True +) +_view_to_component_template_view_class_compatibility = view_to_component( + views.ViewToComponentTemplateViewClassCompatibility, compatibility=True +) +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) +_view_to_component_kwargs = view_to_component(views.view_to_component_kwargs) @component def view_to_component_sync_func_compatibility(): return html.div( {"id": inspect.currentframe().f_code.co_name}, - view_to_component( - views.view_to_component_sync_func_compatibility, compatibility=True - ), + _view_to_component_sync_func_compatibility(), html.hr(), ) @@ -287,9 +289,7 @@ def view_to_component_sync_func_compatibility(): def view_to_component_async_func_compatibility(): return html.div( {"id": inspect.currentframe().f_code.co_name}, - view_to_component( - views.view_to_component_async_func_compatibility, compatibility=True - ), + _view_to_component_async_func_compatibility(), html.hr(), ) @@ -298,9 +298,7 @@ def view_to_component_async_func_compatibility(): def view_to_component_sync_class_compatibility(): return html.div( {"id": inspect.currentframe().f_code.co_name}, - view_to_component( - views.ViewToComponentSyncClassCompatibility, compatibility=True - ), + _view_to_component_sync_class_compatibility(), html.hr(), ) @@ -309,9 +307,7 @@ def view_to_component_sync_class_compatibility(): def view_to_component_async_class_compatibility(): return html.div( {"id": inspect.currentframe().f_code.co_name}, - view_to_component( - views.ViewToComponentAsyncClassCompatibility, compatibility=True - ), + _view_to_component_async_class_compatibility(), html.hr(), ) @@ -320,18 +316,11 @@ def view_to_component_async_class_compatibility(): def view_to_component_template_view_class_compatibility(): return html.div( {"id": inspect.currentframe().f_code.co_name}, - view_to_component( - views.ViewToComponentTemplateViewClassCompatibility, compatibility=True - ), + _view_to_component_template_view_class_compatibility(), html.hr(), ) -@component -def view_to_component_script(): - return view_to_component(views.view_to_component_script) - - @component def view_to_component_request(): request, set_request = hooks.use_state(None) @@ -346,37 +335,55 @@ def on_click(_): {"id": f"{inspect.currentframe().f_code.co_name}_btn", "onClick": on_click}, "Click me", ), - view_to_component(views.view_to_component_request, request=request), + _view_to_component_request(request=request), ) @component def view_to_component_args(): - params, set_params = hooks.use_state("false") + success, set_success = hooks.use_state("false") def on_click(_): - set_params("") + set_success("") return html._( html.button( {"id": f"{inspect.currentframe().f_code.co_name}_btn", "onClick": on_click}, "Click me", ), - view_to_component(views.view_to_component_args, args=[params]), + _view_to_component_args(None, success), ) @component def view_to_component_kwargs(): - params, set_params = hooks.use_state("false") + success, set_success = hooks.use_state("false") def on_click(_): - set_params("") + set_success("") return html._( html.button( {"id": f"{inspect.currentframe().f_code.co_name}_btn", "onClick": on_click}, "Click me", ), - view_to_component(views.view_to_component_kwargs, kwargs={"success": params}), + _view_to_component_kwargs(success=success), + ) + + +@view_to_component +def view_to_component_decorator(request): + return render( + request, + "view_to_component.html", + {"test_name": inspect.currentframe().f_code.co_name}, + ) + + +@view_to_component(strict_parsing=False) +def view_to_component_decorator_args(request): + return render( + request, + "view_to_component.html", + {"test_name": inspect.currentframe().f_code.co_name}, ) diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index 8f610e96..812f1bda 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -47,6 +47,8 @@

IDOM Test Page

{% component "test_app.components.view_to_component_sync_class_compatibility" %}
{% component "test_app.components.view_to_component_async_class_compatibility" %}
{% component "test_app.components.view_to_component_template_view_class_compatibility" %}
+
{% component "test_app.components.view_to_component_decorator" %}
+
{% component "test_app.components.view_to_component_decorator_args" %}
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 923e3e93..c11e5b33 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -182,3 +182,11 @@ def test_view_to_component_template_view_class_compatibility(self): ).locator( "#ViewToComponentTemplateViewClassCompatibility[data-success=true]" ).wait_for() + + def test_view_to_component_decorator(self): + self.page.locator("#view_to_component_decorator[data-success=true]").wait_for() + + def test_view_to_component_decorator_args(self): + self.page.locator( + "#view_to_component_decorator_args[data-success=true]" + ).wait_for()