diff --git a/CHANGELOG.md b/CHANGELOG.md index c8e1bb14..471f872e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,14 @@ Using the following categories, list your changes in this order: ### Added -- `auth_required` decorator to prevent your components from rendered to unauthenticated users. +- `auth_required` decorator to prevent your components from rendering to unauthenticated users. - `use_query` hook for fetching database values. - `use_mutation` hook for modifying database values. +- `view_to_component` utility to convert legacy Django views to IDOM components. + +### Changed + +- Bumped the minimum IDOM version to 0.40.2 ### Fixed diff --git a/docs/changelog/index.md b/docs/changelog/index.md deleted file mode 100644 index a6e2f878..00000000 --- a/docs/changelog/index.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -hide: - - navigation - - toc ---- - -!!! note "Attribution" - - {% include-markdown "../../CHANGELOG.md" start="" end="" %} - -{% include-markdown "../../CHANGELOG.md" start="" %} diff --git a/docs/contribute/running-tests.md b/docs/contribute/running-tests.md deleted file mode 100644 index 287b9301..00000000 --- a/docs/contribute/running-tests.md +++ /dev/null @@ -1,11 +0,0 @@ -This repo uses [Nox](https://nox.thea.codes/en/stable/) to run scripts which can be found in `noxfile.py`. For a full test of available scripts run `nox -l`. To run the full test suite simple execute: - -``` -nox -s test -``` - -If you want to run the tests in the background (headless): - -``` -nox -s test -- --headless -``` diff --git a/docs/features/components.md b/docs/features/components.md deleted file mode 100644 index fa6b45f0..00000000 --- a/docs/features/components.md +++ /dev/null @@ -1,121 +0,0 @@ -## 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/). - -```python title="components.py" -from idom import component, html -from django_idom.components import django_css - -@component -def my_component(): - return html.div( - django_css("css/buttons.css"), - html.button("My Button!"), - ) -``` - -??? question "Should I put `django_css` at the top of my component?" - - Yes, if the stylesheet contains styling for your component. - -??? question "Can I load static CSS using `html.link` instead?" - - While you can load stylesheets with `html.link`, keep in mind that loading this way **does not** ensure load order. Thus, your stylesheet will be loaded after your component is displayed. This would likely cause some visual jankiness, so use this at your own discretion. - - Here's an example on what you should avoid doing for Django static files: - - ```python - from idom import component, html - from django.templatetags.static import static - - @component - def my_component(): - return html.div( - html.link({"rel": "stylesheet", "href": static("css/buttons.css")}), - html.button("My Button!"), - ) - ``` - -??? question "How do I load external CSS?" - - `django_css` can only be used with local static files. - - For external CSS, substitute `django_css` with `html.link`. - - ```python - from idom import component, html - - @component - def my_component(): - return html.div( - html.link({"rel": "stylesheet", "href": "https://example.com/external-styles.css"}), - html.button("My Button!"), - ) - ``` - -??? question "Why not load my CSS in `#!html `?" - - Traditionally, stylesheets are loaded in your `#!html ` using the `#!jinja {% load static %}` template tag. - - To help improve webpage load times, you can use the `django_css` component to defer loading your stylesheet until it is needed. - -## Django JS - -Allows you to defer loading JavaScript until a component begins rendering. This JavaScript must be stored within [Django's static files](https://docs.djangoproject.com/en/dev/howto/static-files/). - -```python title="components.py" -from idom import component, html -from django_idom.components import django_js - -@component -def my_component(): - return html.div( - html.button("My Button!"), - django_js("js/scripts.js"), - ) -``` - -??? question "Should I put `django_js` at the bottom of my component?" - - Yes, if your scripts are reliant on the contents of the component. - -??? question "Can I load static JavaScript using `html.script` instead?" - - While you can load JavaScript with `html.script`, keep in mind that loading this way **does not** ensure load order. Thus, your JavaScript will likely be loaded at an arbitrary time after your component is displayed. - - Here's an example on what you should avoid doing for Django static files: - - ```python - from idom import component, html - from django.templatetags.static import static - - @component - def my_component(): - return html.div( - html.script({"src": static("js/scripts.js")}), - html.button("My Button!"), - ) - ``` - -??? question "How do I load external JS?" - - `django_js` can only be used with local static files. - - For external JavaScript, substitute `django_js` with `html.script`. - - ```python - from idom import component, html - - @component - def my_component(): - return html.div( - html.script({"src": "https://example.com/external-scripts.js"}), - html.button("My Button!"), - ) - ``` - -??? question "Why not load my JS in `#!html `?" - - Traditionally, JavaScript is loaded in your `#!html ` using the `#!jinja {% load static %}` template tag. - - To help improve webpage load times, you can use the `django_js` component to defer loading your JavaScript until it is needed. diff --git a/docs/getting-started/learn-more.md b/docs/getting-started/learn-more.md deleted file mode 100644 index abbc1099..00000000 --- a/docs/getting-started/learn-more.md +++ /dev/null @@ -1,11 +0,0 @@ -# :confetti_ball: Congratulations :confetti_ball: - -If you followed the previous steps, you've now created a "Hello World" component! - -The docs you are reading only covers our Django integration. - -To learn more about our advanced features, such as interactive events and hooks, check out the [IDOM Core Documentation](https://idom-docs.herokuapp.com/docs/guides/creating-interfaces/index.html)! - -| Learn More | -| --- | -| [Django-IDOM — Exclusive Features](../features/hooks.md){ .md-button } [IDOM Core — Hooks, Events, and More](https://idom-docs.herokuapp.com/docs/guides/creating-interfaces/index.html){ .md-button } | diff --git a/docs/includes/examples.md b/docs/includes/examples.md new file mode 100644 index 00000000..623e60c3 --- /dev/null +++ b/docs/includes/examples.md @@ -0,0 +1,23 @@ + + +```python +from django.http import HttpResponse + +def hello_world_view(request, *args, **kwargs): + return HttpResponse("Hello World!") +``` + + + + + +```python +from django.http import HttpResponse +from django.views import View + +class HelloWorldView(View): + def get(self, request, *args, **kwargs): + return HttpResponse("Hello World!") +``` + + \ No newline at end of file diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 9b11f2dc..00000000 --- a/docs/index.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -hide: - - navigation - - toc ---- - -{% include-markdown "../README.md" start="" end="" %} - -## ReactJS for Django Developers. - ---- - -{% include-markdown "../README.md" start="" end="" %} - -## Resources - -{% include-markdown "../README.md" start="" end="" %} diff --git a/docs/src/changelog/index.md b/docs/src/changelog/index.md new file mode 100644 index 00000000..a4a0f241 --- /dev/null +++ b/docs/src/changelog/index.md @@ -0,0 +1,11 @@ +--- +hide: + - navigation + - toc +--- + +!!! note "Attribution" + + {% include-markdown "../../../CHANGELOG.md" start="" end="" %} + +{% include-markdown "../../../CHANGELOG.md" start="" %} diff --git a/docs/contribute/django-idom.md b/docs/src/contribute/django-idom.md similarity index 100% rename from docs/contribute/django-idom.md rename to docs/src/contribute/django-idom.md diff --git a/docs/contribute/docs.md b/docs/src/contribute/docs.md similarity index 100% rename from docs/contribute/docs.md rename to docs/src/contribute/docs.md diff --git a/docs/src/contribute/running-tests.md b/docs/src/contribute/running-tests.md new file mode 100644 index 00000000..ece8d1a7 --- /dev/null +++ b/docs/src/contribute/running-tests.md @@ -0,0 +1,15 @@ +This repo uses [Nox](https://nox.thea.codes/en/stable/) to run scripts which can be found in `noxfile.py`. For a full test of available scripts run `nox -l`. To run the full test suite simple execute: + +``` +nox -s test +``` + +If you do not want to run the tests in the background: + +``` +nox -s test -- --headed +``` + +!!! warning "Most tests will not run on Windows" + + Due to [bugs within Django Channels](https://github.com/django/channels/issues/1207), functional tests are not run on Windows. In order for Windows users to test Django-IDOM functionality, you will need to run tests via [Windows Subsystem for Linux](https://code.visualstudio.com/docs/remote/wsl). diff --git a/docs/src/features/components.md b/docs/src/features/components.md new file mode 100644 index 00000000..cc8d9bf4 --- /dev/null +++ b/docs/src/features/components.md @@ -0,0 +1,304 @@ +## View To Component + +Convert any Django view into a IDOM component by usng this decorator. Compatible with sync/async [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/). + +=== "components.py" + + ```python + from idom import component, html + from django_idom.components import view_to_component + from .views import hello_world_view + + @component + def my_component(): + return html.div( + view_to_component(hello_world_view), + ) + ``` + +=== "views.py" + + {% include-markdown "../../includes/examples.md" start="" end="" %} + +??? example "See Interface" + + **Parameters** + + | 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. Strict parsing does not apply to compatibility mode. | `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` | + | 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. Custom request objects cannot be used in compatibility mode. | `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. | + +??? question "How do I use this for Class Based Views?" + + You can simply pass your Class Based View directly into this function. + + === "components.py" + + ```python + from idom import component, html + from django_idom.components import view_to_component + from .views import HelloWorldView + + @component + def my_component(): + return html.div( + view_to_component(HelloWorldView), + ) + ``` + + === "views.py" + + {% include-markdown "../../includes/examples.md" start="" end="" %} + +??? question "How do I pass arguments into the view?" + + You can use the `args` and `kwargs` parameters to pass arguments to the view. + + === "components.py" + + ```python + from idom import component, html + from django_idom.components import view_to_component + from .views import hello_world_view + + @component + def my_component(): + return html.div( + view_to_component( + hello_world_view, + args=["value_1", "value_2"], + kwargs={"key_1": "value_1", "key_2": "value_2"}, + ), + ) + ``` + + === "views.py" + + {% include-markdown "../../includes/examples.md" start="" end="" %} + +??? 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 `strict_parsing` argument does 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. + + === "components.py" + + ```python + from idom import component, html + from django_idom.components import view_to_component + from .views import hello_world_view + + @component + def my_component(): + return html.div( + view_to_component(hello_world_view, compatibility=True), + ) + ``` + + === "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. + + 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 `strict_parsing` parameter to `False`. + + === "components.py" + + ```python + from idom import component, html + from django_idom.components import view_to_component + from .views import hello_world_view + + @component + def my_component(): + return html.div( + view_to_component(hello_world_view, strict_parsing=False), + ) + ``` + + === "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`?" + + After your view has been turned into [VDOM](https://idom-docs.herokuapp.com/docs/reference/specifications.html#vdom) (python dictionaries), `view_to_component` will call your `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 `id`, you can create a transform like such: + + === "components.py" + + ```python + from idom import component, html + 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!" + + @component + def my_component(): + return view_to_component( + hello_world_view, + transforms=[example_transform], + ) + ``` + + === "views.py" + + ```python + 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/). + +```python title="components.py" +from idom import component, html +from django_idom.components import django_css + +@component +def my_component(): + return html.div( + django_css("css/buttons.css"), + html.button("My Button!"), + ) +``` + +??? question "Should I put `django_css` at the top of my component?" + + Yes, if the stylesheet contains styling for your component. + +??? question "Can I load static CSS using `html.link` instead?" + + While you can load stylesheets with `html.link`, keep in mind that loading this way **does not** ensure load order. Thus, your stylesheet will be loaded after your component is displayed. This would likely cause some visual jankiness, so use this at your own discretion. + + Here's an example on what you should avoid doing for Django static files: + + ```python + from idom import component, html + from django.templatetags.static import static + + @component + def my_component(): + return html.div( + html.link({"rel": "stylesheet", "href": static("css/buttons.css")}), + html.button("My Button!"), + ) + ``` + +??? question "How do I load external CSS?" + + `django_css` can only be used with local static files. + + For external CSS, substitute `django_css` with `html.link`. + + ```python + from idom import component, html + + @component + def my_component(): + return html.div( + html.link({"rel": "stylesheet", "href": "https://example.com/external-styles.css"}), + html.button("My Button!"), + ) + ``` + +??? question "Why not load my CSS in `#!html `?" + + Traditionally, stylesheets are loaded in your `#!html ` using the `#!jinja {% load static %}` template tag. + + To help improve webpage load times, you can use the `django_css` component to defer loading your stylesheet until it is needed. + +## Django JS + +Allows you to defer loading JavaScript until a component begins rendering. This JavaScript must be stored within [Django's static files](https://docs.djangoproject.com/en/dev/howto/static-files/). + +```python title="components.py" +from idom import component, html +from django_idom.components import django_js + +@component +def my_component(): + return html.div( + html.button("My Button!"), + django_js("js/scripts.js"), + ) +``` + +??? question "Should I put `django_js` at the bottom of my component?" + + Yes, if your scripts are reliant on the contents of the component. + +??? question "Can I load static JavaScript using `html.script` instead?" + + While you can load JavaScript with `html.script`, keep in mind that loading this way **does not** ensure load order. Thus, your JavaScript will likely be loaded at an arbitrary time after your component is displayed. + + Here's an example on what you should avoid doing for Django static files: + + ```python + from idom import component, html + from django.templatetags.static import static + + @component + def my_component(): + return html.div( + html.script({"src": static("js/scripts.js")}), + html.button("My Button!"), + ) + ``` + +??? question "How do I load external JS?" + + `django_js` can only be used with local static files. + + For external JavaScript, substitute `django_js` with `html.script`. + + ```python + from idom import component, html + + @component + def my_component(): + return html.div( + html.script({"src": "https://example.com/external-scripts.js"}), + html.button("My Button!"), + ) + ``` + +??? question "Why not load my JS in `#!html `?" + + Traditionally, JavaScript is loaded in your `#!html ` using the `#!jinja {% load static %}` template tag. + + To help improve webpage load times, you can use the `django_js` component to defer loading your JavaScript until it is needed. diff --git a/docs/features/decorators.md b/docs/src/features/decorators.md similarity index 100% rename from docs/features/decorators.md rename to docs/src/features/decorators.md diff --git a/docs/features/hooks.md b/docs/src/features/hooks.md similarity index 99% rename from docs/features/hooks.md rename to docs/src/features/hooks.md index 1951f43d..09c8daa9 100644 --- a/docs/features/hooks.md +++ b/docs/src/features/hooks.md @@ -1,6 +1,6 @@ ???+ tip "Looking for more hooks?" - Check out the [IDOM Core docs](https://idom-docs.herokuapp.com/docs/reference/hooks-api.html?highlight=hooks) on hooks! + Check out the [IDOM Core docs](https://idom-docs.herokuapp.com/docs/reference/hooks-api.html#basic-hooks) on hooks! ## Use Query diff --git a/docs/features/settings.md b/docs/src/features/settings.md similarity index 100% rename from docs/features/settings.md rename to docs/src/features/settings.md diff --git a/docs/features/templatetag.md b/docs/src/features/templatetag.md similarity index 94% rename from docs/features/templatetag.md rename to docs/src/features/templatetag.md index 5f917f92..43910f4c 100644 --- a/docs/features/templatetag.md +++ b/docs/src/features/templatetag.md @@ -1,6 +1,6 @@ Integrated within Django IDOM, we bundle a template tag. Within this tag, you can pass in keyword arguments directly into your component. -{% include-markdown "../../README.md" start="" end="" %} +{% include-markdown "../../../README.md" start="" end="" %} @@ -81,6 +81,6 @@ Integrated within Django IDOM, we bundle a template tag. Within this tag, you ca Keep in mind, in order to use the `#!jinja {% component ... %}` tag, you'll need to first call `#!jinja {% load idom %}` to gain access to it. - {% include-markdown "../../README.md" start="" end="" %} + {% include-markdown "../../../README.md" start="" end="" %} diff --git a/docs/getting-started/create-component.md b/docs/src/getting-started/create-component.md similarity index 71% rename from docs/getting-started/create-component.md rename to docs/src/getting-started/create-component.md index ca6e2a18..29281051 100644 --- a/docs/getting-started/create-component.md +++ b/docs/src/getting-started/create-component.md @@ -4,9 +4,9 @@ --- -{% include-markdown "../../README.md" start="" end="" %} +{% include-markdown "../../../README.md" start="" end="" %} -{% include-markdown "../../README.md" start="" end="" %} +{% include-markdown "../../../README.md" start="" end="" %} ??? question "What should I name my IDOM files and functions?" diff --git a/docs/getting-started/initial-steps.md b/docs/src/getting-started/initial-steps.md similarity index 100% rename from docs/getting-started/initial-steps.md rename to docs/src/getting-started/initial-steps.md diff --git a/docs/src/getting-started/learn-more.md b/docs/src/getting-started/learn-more.md new file mode 100644 index 00000000..31449ea9 --- /dev/null +++ b/docs/src/getting-started/learn-more.md @@ -0,0 +1,11 @@ +# :confetti_ball: Congratulations :confetti_ball: + +If you followed the previous steps, you've now created a "Hello World" component! + +The docs you are reading only covers our Django integration. To learn more about features, such as interactive events and hooks, check out the [IDOM Core Documentation](https://idom-docs.herokuapp.com/docs/guides/creating-interfaces/index.html)! + +Additionally, the vast majority of tutorials/guides you find for React can be applied to IDOM. + +| Learn More | +| --- | +| [Django-IDOM Exclusive Features](../features/hooks.md){ .md-button } [IDOM Hooks, Events, and More](https://idom-docs.herokuapp.com/docs/guides/creating-interfaces/index.html){ .md-button } [Ask Questions on GitHub Discussions](https://github.com/idom-team/idom/discussions){ .md-button .md-button--primary } | diff --git a/docs/getting-started/reference-component.md b/docs/src/getting-started/reference-component.md similarity index 83% rename from docs/getting-started/reference-component.md rename to docs/src/getting-started/reference-component.md index af7dd77c..db5c4a5e 100644 --- a/docs/getting-started/reference-component.md +++ b/docs/src/getting-started/reference-component.md @@ -4,9 +4,9 @@ --- -{% include-markdown "../../README.md" start="" end="" %} +{% include-markdown "../../../README.md" start="" end="" %} -{% include-markdown "../../README.md" start="" end="" %} +{% include-markdown "../../../README.md" start="" end="" %} {% include-markdown "../features/templatetag.md" start="" end="" %} diff --git a/docs/getting-started/render-view.md b/docs/src/getting-started/render-view.md similarity index 100% rename from docs/getting-started/render-view.md rename to docs/src/getting-started/render-view.md diff --git a/docs/src/index.md b/docs/src/index.md new file mode 100644 index 00000000..07ac4bb5 --- /dev/null +++ b/docs/src/index.md @@ -0,0 +1,17 @@ +--- +hide: + - navigation + - toc +--- + +{% include-markdown "../../README.md" start="" end="" %} + +## ReactJS for Django Developers. + +--- + +{% include-markdown "../../README.md" start="" end="" %} + +## Resources + +{% include-markdown "../../README.md" start="" end="" %} diff --git a/docs/installation/index.md b/docs/src/installation/index.md similarity index 100% rename from docs/installation/index.md rename to docs/src/installation/index.md diff --git a/docs/stylesheets/extra.css b/docs/src/stylesheets/extra.css similarity index 100% rename from docs/stylesheets/extra.css rename to docs/src/stylesheets/extra.css diff --git a/mkdocs.yml b/mkdocs.yml index 60ece595..3d7287b9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,7 +8,7 @@ nav: - 3. Use the Template Tag: getting-started/reference-component.md - 4. Render Your View: getting-started/render-view.md - 5. Learn More: getting-started/learn-more.md - - Exclusive Features: + - Usage: - Components: features/components.md - Hooks: features/hooks.md - Decorators: features/decorators.md @@ -86,3 +86,4 @@ repo_url: https://github.com/idom-team/django-idom site_url: https://idom-team.github.io/django-idom repo_name: idom-team/django-idom edit_uri: edit/docs +docs_dir: docs/src diff --git a/noxfile.py b/noxfile.py index f382980d..c0c63855 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,10 +1,7 @@ from __future__ import annotations -import os import re -import subprocess from pathlib import Path -from typing import List, Tuple import nox from nox.sessions import Session diff --git a/src/django_idom/__init__.py b/src/django_idom/__init__.py index 631913a9..c34467fc 100644 --- a/src/django_idom/__init__.py +++ b/src/django_idom/__init__.py @@ -1,4 +1,4 @@ -from django_idom import components, decorators, hooks, types +from django_idom import components, decorators, hooks, types, utils from django_idom.types import IdomWebsocket from django_idom.websocket.paths import IDOM_WEBSOCKET_PATH @@ -6,9 +6,10 @@ __version__ = "1.1.0" __all__ = [ "IDOM_WEBSOCKET_PATH", - "types", "IdomWebsocket", "hooks", "components", "decorators", + "types", + "utils", ] diff --git a/src/django_idom/components.py b/src/django_idom/components.py index 1433d30a..9c5aae6c 100644 --- a/src/django_idom/components.py +++ b/src/django_idom/components.py @@ -1,9 +1,122 @@ +from __future__ import annotations + +import json import os +from inspect import iscoroutinefunction +from typing import Any, Callable, Dict, Iterable +from channels.db import database_sync_to_async from django.contrib.staticfiles.finders import find -from idom import component, html +from django.http import HttpRequest +from django.urls import reverse +from django.views import View +from idom import component, hooks, html, utils +from idom.types import VdomDict -from django_idom.config import IDOM_CACHE +from django_idom.config import IDOM_CACHE, IDOM_VIEW_COMPONENT_IFRAMES +from django_idom.types import ViewComponentIframe + + +# 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. + + Args: + view: The view function or class to convert. + + Keyword Args: + compatibility: If True, the component will be rendered in an iframe. + Strict parsing does not apply to compatibility mode. + 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. + Custom request objects cannot be used in compatibility mode. + args: The positional arguments to pass to the view. + kwargs: The keyword arguments to pass to the view. + """ + kwargs = kwargs or {} + rendered_view, set_rendered_view = hooks.use_state(None) + request_obj = request + if not request: + request_obj = HttpRequest() + request_obj.method = "GET" + + # Render the view render within a hook + @hooks.use_effect( + dependencies=[ + json.dumps(vars(request_obj), default=lambda x: _generate_obj_name(x)), + json.dumps([args, kwargs], default=lambda x: _generate_obj_name(x)), + ] + ) + async def async_renderer(): + """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( + html.iframe( + { + "src": reverse("idom:view_to_component", args=[dotted_path]), + "loading": "lazy", + } + ) + ) + return + + # Render Check 2: Async function view + elif iscoroutinefunction(view): + render = 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() + else: # View + render = 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)() + else: # View + render = view_or_template_view + + # Render Check 5: Sync function view + else: + render = await database_sync_to_async(view)(request_obj, *args, **kwargs) + + # Signal that the view has been rendered + set_rendered_view( + utils.html_to_vdom( + render.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 @component @@ -14,7 +127,7 @@ 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._(html.style(_cached_static_contents(static_path))) + return html.style(_cached_static_contents(static_path)) @component @@ -40,13 +153,24 @@ def _cached_static_contents(static_path: str): # Cache is preferrable to `use_memo` due to multiprocessing capabilities last_modified_time = os.stat(abs_path).st_mtime cache_key = f"django_idom:static_contents:{static_path}" - file_contents = IDOM_CACHE.get(cache_key, version=last_modified_time) + file_contents = IDOM_CACHE.get(cache_key, version=int(last_modified_time)) if file_contents is None: with open(abs_path, encoding="utf-8") as static_file: file_contents = static_file.read() IDOM_CACHE.delete(cache_key) IDOM_CACHE.set( - cache_key, file_contents, timeout=None, version=last_modified_time + cache_key, file_contents, timeout=None, version=int(last_modified_time) ) return file_contents + + +def _generate_obj_name(object: Any) -> str | None: + """Makes a best effort to create a name for an object. + Useful for JSON serialization of Python objects.""" + 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__}" + return None diff --git a/src/django_idom/config.py b/src/django_idom/config.py index cb037ed1..b87fb7e9 100644 --- a/src/django_idom/config.py +++ b/src/django_idom/config.py @@ -1,11 +1,14 @@ from typing import Dict from django.conf import settings -from django.core.cache import DEFAULT_CACHE_ALIAS, caches +from django.core.cache import DEFAULT_CACHE_ALIAS, BaseCache, caches from idom.core.types import ComponentConstructor +from django_idom.types import ViewComponentIframe + IDOM_REGISTERED_COMPONENTS: Dict[str, ComponentConstructor] = {} +IDOM_VIEW_COMPONENT_IFRAMES: Dict[str, ViewComponentIframe] = {} IDOM_WEBSOCKET_URL = getattr(settings, "IDOM_WEBSOCKET_URL", "idom/") IDOM_WS_MAX_RECONNECT_TIMEOUT = getattr( @@ -13,7 +16,8 @@ ) # Determine if using Django caching or LRU cache -if "idom" in getattr(settings, "CACHES", {}): - IDOM_CACHE = caches["idom"] -else: - IDOM_CACHE = caches[DEFAULT_CACHE_ALIAS] +IDOM_CACHE: BaseCache = ( + caches["idom"] + if "idom" in getattr(settings, "CACHES", {}) + else caches[DEFAULT_CACHE_ALIAS] +) diff --git a/src/django_idom/decorators.py b/src/django_idom/decorators.py index 0659938a..5800d220 100644 --- a/src/django_idom/decorators.py +++ b/src/django_idom/decorators.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from functools import wraps -from typing import Callable, Union +from typing import Callable from idom.core.types import ComponentType, VdomDict @@ -7,9 +9,9 @@ def auth_required( - component: Union[Callable, None] = None, + component: Callable | None = None, auth_attribute: str = "is_active", - fallback: Union[ComponentType, VdomDict, None] = None, + fallback: ComponentType | VdomDict | None = None, ) -> Callable: """If the user passes authentication criteria, the decorated component will be rendered. Otherwise, the fallback component will be rendered. @@ -29,16 +31,9 @@ def _wrapped_func(*args, **kwargs): if getattr(websocket.scope["user"], auth_attribute): return component(*args, **kwargs) - - if callable(fallback): - return fallback(*args, **kwargs) - return fallback + return fallback(*args, **kwargs) if callable(fallback) else fallback return _wrapped_func - # Return for @authenticated(...) - if component is None: - return decorator - - # Return for @authenticated - return decorator(component) + # Return for @authenticated(...) and @authenticated respectively + return decorator if component is None else decorator(component) diff --git a/src/django_idom/http/urls.py b/src/django_idom/http/urls.py index 6f7021b7..50b0c4ec 100644 --- a/src/django_idom/http/urls.py +++ b/src/django_idom/http/urls.py @@ -10,5 +10,10 @@ "web_module/", views.web_modules_file, # type: ignore[arg-type] name="web_modules", - ) + ), + path( + "iframe/", + views.view_to_component_iframe, # type: ignore[arg-type] + name="view_to_component", + ), ] diff --git a/src/django_idom/http/views.py b/src/django_idom/http/views.py index 4134c058..033e9ffd 100644 --- a/src/django_idom/http/views.py +++ b/src/django_idom/http/views.py @@ -1,11 +1,13 @@ import os +from inspect import iscoroutinefunction from aiofile import async_open +from channels.db import database_sync_to_async from django.core.exceptions import SuspiciousOperation from django.http import HttpRequest, HttpResponse from idom.config import IDOM_WED_MODULES_DIR -from django_idom.config import IDOM_CACHE +from django_idom.config import IDOM_CACHE, IDOM_VIEW_COMPONENT_IFRAMES async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse: @@ -23,12 +25,47 @@ async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse: # Fetch the file from cache, if available last_modified_time = os.stat(path).st_mtime cache_key = f"django_idom:web_module:{str(path).lstrip(str(web_modules_dir))}" - response = await IDOM_CACHE.aget(cache_key, version=last_modified_time) + response = await IDOM_CACHE.aget(cache_key, version=last_modified_time) # type: ignore[attr-defined] if response is None: async with async_open(path, "r") as fp: response = HttpResponse(await fp.read(), content_type="text/javascript") - await IDOM_CACHE.adelete(cache_key) - await IDOM_CACHE.aset( + await IDOM_CACHE.adelete(cache_key) # type: ignore[attr-defined] + await IDOM_CACHE.aset( # type: ignore[attr-defined] cache_key, response, timeout=None, version=last_modified_time ) return response + + +async def view_to_component_iframe( + request: HttpRequest, view_path: str +) -> HttpResponse: + """Returns a view that was registered by view_to_component. + This view is intended to be used as iframe, for compatibility purposes.""" + # Get the view from IDOM_REGISTERED_IFRAMES + iframe = IDOM_VIEW_COMPONENT_IFRAMES.get(view_path) + if not iframe: + raise ValueError(f"No view registered for component {view_path}.") + + # Render Check 1: Async function view + if iscoroutinefunction(iframe.view): + response = await iframe.view(request, *iframe.args, **iframe.kwargs) # type: ignore[operator] + + # Render Check 2: Async class view + elif getattr(iframe.view, "view_is_async", False): + response = await iframe.view.as_view()(request, *iframe.args, **iframe.kwargs) # type: ignore[misc, union-attr] + + # Render Check 3: Sync class view + elif getattr(iframe.view, "as_view", None): + response = await database_sync_to_async(iframe.view.as_view())( # type: ignore[union-attr] + request, *iframe.args, **iframe.kwargs + ) + + # Render Check 4: Sync function view + else: + response = await database_sync_to_async(iframe.view)( + request, *iframe.args, **iframe.kwargs + ) + + # Ensure page can be rendered as an iframe + response["X-Frame-Options"] = "SAMEORIGIN" + return response diff --git a/src/django_idom/types.py b/src/django_idom/types.py index 88f9c32f..1f4bd406 100644 --- a/src/django_idom/types.py +++ b/src/django_idom/types.py @@ -1,10 +1,11 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Awaitable, Callable, Generic, Optional, TypeVar, Union +from typing import Any, Awaitable, Callable, Generic, Iterable, Optional, TypeVar, Union from django.db.models.base import Model from django.db.models.query import QuerySet +from django.views.generic import View from typing_extensions import ParamSpec @@ -43,3 +44,10 @@ class Mutation(Generic[_Params]): loading: bool error: Exception | None reset: Callable[[], None] + + +@dataclass +class ViewComponentIframe: + view: View | Callable + args: Iterable + kwargs: dict diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py index 27d106a4..2f6782ab 100644 --- a/src/django_idom/utils.py +++ b/src/django_idom/utils.py @@ -6,6 +6,7 @@ import re from fnmatch import fnmatch from importlib import import_module +from typing import Callable from django.template import engines from django.utils.encoding import smart_str @@ -28,11 +29,17 @@ ) -def _register_component(full_component_name: str) -> None: - if full_component_name in IDOM_REGISTERED_COMPONENTS: +def _register_component(dotted_path: str) -> None: + if dotted_path in IDOM_REGISTERED_COMPONENTS: return - module_name, component_name = full_component_name.rsplit(".", 1) + IDOM_REGISTERED_COMPONENTS[dotted_path] = _import_dotted_path(dotted_path) + _logger.debug("IDOM has registered component %s", dotted_path) + + +def _import_dotted_path(dotted_path: str) -> Callable: + """Imports a dotted path and returns the callable.""" + module_name, component_name = dotted_path.rsplit(".", 1) try: module = import_module(module_name) @@ -41,15 +48,7 @@ def _register_component(full_component_name: str) -> None: f"Failed to import {module_name!r} while loading {component_name!r}" ) from error - try: - component = getattr(module, component_name) - except AttributeError as error: - raise RuntimeError( - f"Module {module_name!r} has no component named {component_name!r}" - ) from error - - IDOM_REGISTERED_COMPONENTS[full_component_name] = component - _logger.debug("IDOM has registered component %s", full_component_name) + return getattr(module, component_name) class ComponentPreloader: diff --git a/tests/test_app/components.py b/tests/test_app/components.py index f8069378..c103e01c 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -1,134 +1,144 @@ -import idom +import inspect + +from django.http import HttpRequest +from idom import component, hooks, html, web from test_app.models import TodoItem import django_idom +from django_idom.components import view_to_component from django_idom.hooks import use_mutation, use_query +from . import views + -@idom.component +@component def hello_world(): - return idom.html.h1({"id": "hello-world"}, "Hello World!") + return html._(html.h1({"id": "hello-world"}, "Hello World!"), html.hr()) -@idom.component +@component def button(): - count, set_count = idom.hooks.use_state(0) - return idom.html.div( - idom.html.button( - {"id": "counter-inc", "onClick": lambda event: set_count(count + 1)}, - "Click me!", - ), - idom.html.p( - {"id": "counter-num", "data-count": count}, - f"Current count is: {count}", + count, set_count = hooks.use_state(0) + return html._( + html.div( + html.button( + {"id": "counter-inc", "onClick": lambda event: set_count(count + 1)}, + "Click me!", + ), + html.p( + {"id": "counter-num", "data-count": count}, + f"Current count is: {count}", + ), ), + html.hr(), ) -@idom.component +@component def parameterized_component(x, y): total = x + y - return idom.html.h1({"id": "parametrized-component", "data-value": total}, total) + return html._( + html.h1({"id": "parametrized-component", "data-value": total}, total), + html.hr(), + ) -victory = idom.web.module_from_template("react", "victory-bar", fallback="...") -VictoryBar = idom.web.export(victory, "VictoryBar") +victory = web.module_from_template("react", "victory-bar", fallback="...") +VictoryBar = web.export(victory, "VictoryBar") -@idom.component +@component def simple_bar_chart(): - return VictoryBar() + return html._(VictoryBar(), html.hr()) -@idom.component +@component def use_websocket(): ws = django_idom.hooks.use_websocket() - ws.scope = "..." success = bool(ws.scope and ws.close and ws.disconnect and ws.view_id) - return idom.html.div( + return html.div( {"id": "use-websocket", "data-success": success}, - idom.html.hr(), f"use_websocket: {ws}", - idom.html.hr(), + html.hr(), ) -@idom.component +@component def use_scope(): scope = django_idom.hooks.use_scope() success = len(scope) >= 10 and scope["type"] == "websocket" - return idom.html.div( + return html.div( {"id": "use-scope", "data-success": success}, f"use_scope: {scope}", - idom.html.hr(), + html.hr(), ) -@idom.component +@component def use_location(): location = django_idom.hooks.use_location() success = bool(location) - return idom.html.div( + return html.div( {"id": "use-location", "data-success": success}, f"use_location: {location}", - idom.html.hr(), + html.hr(), ) -@idom.component +@component def django_css(): - return idom.html.div( + return html.div( {"id": "django-css"}, django_idom.components.django_css("django-css-test.css"), - idom.html.div({"style": {"display": "inline"}}, "django_css: "), - idom.html.button("This text should be blue."), - idom.html.hr(), + html.div({"style": {"display": "inline"}}, "django_css: "), + html.button("This text should be blue."), + html.hr(), ) -@idom.component +@component def django_js(): success = False - return idom.html._( - idom.html.div( + return html._( + html.div( {"id": "django-js", "data-success": success}, f"django_js: {success}", django_idom.components.django_js("django-js-test.js"), ), - idom.html.hr(), + html.hr(), ) -@idom.component +@component @django_idom.decorators.auth_required( - fallback=idom.html.div( + fallback=html.div( {"id": "unauthorized-user-fallback"}, "unauthorized_user: Success", - idom.html.hr(), + html.hr(), ) ) def unauthorized_user(): - return idom.html.div( + return html.div( {"id": "unauthorized-user"}, "unauthorized_user: Fail", - idom.html.hr(), + html.hr(), ) -@idom.component +@component @django_idom.decorators.auth_required( auth_attribute="is_anonymous", - fallback=idom.html.div( + fallback=html.div( {"id": "authorized-user-fallback"}, "authorized_user: Fail", - idom.html.hr(), + html.hr(), ), ) def authorized_user(): - return idom.html.div( + return html.div( {"id": "authorized-user"}, "authorized_user: Success", - idom.html.hr(), + html.hr(), ) @@ -153,30 +163,30 @@ def toggle_item_mutation(item: TodoItem): item.save() -@idom.component +@component def todo_list(): - input_value, set_input_value = idom.use_state("") + input_value, set_input_value = hooks.use_state("") items = use_query(get_items_query) toggle_item = use_mutation(toggle_item_mutation, refetch=get_items_query) if items.error: - rendered_items = idom.html.h2(f"Error when loading - {items.error}") + rendered_items = html.h2(f"Error when loading - {items.error}") elif items.data is None: - rendered_items = idom.html.h2("Loading...") + rendered_items = html.h2("Loading...") else: - rendered_items = idom.html._( - idom.html.h3("Not Done"), + rendered_items = html._( + html.h3("Not Done"), _render_items([i for i in items.data if not i.done], toggle_item), - idom.html.h3("Done"), + html.h3("Done"), _render_items([i for i in items.data if i.done], toggle_item), ) add_item = use_mutation(add_item_mutation, refetch=get_items_query) if add_item.loading: - mutation_status = idom.html.h2("Working...") + mutation_status = html.h2("Working...") elif add_item.error: - mutation_status = idom.html.h2(f"Error when adding - {add_item.error}") + mutation_status = html.h2(f"Error when adding - {add_item.error}") else: mutation_status = "" @@ -188,9 +198,9 @@ def on_submit(event): def on_change(event): set_input_value(event["target"]["value"]) - return idom.html.div( - idom.html.label("Add an item:"), - idom.html.input( + return html.div( + html.label("Add an item:"), + html.input( { "type": "text", "id": "todo-input", @@ -201,16 +211,17 @@ def on_change(event): ), mutation_status, rendered_items, + html.hr(), ) def _render_items(items, toggle_item): - return idom.html.ul( + return html.ul( [ - idom.html.li( + html.li( {"id": f"todo-item-{item.text}"}, item.text, - idom.html.input( + html.input( { "id": f"todo-item-{item.text}-checkbox", "type": "checkbox", @@ -223,3 +234,138 @@ def _render_items(items, toggle_item): for item in items ] ) + + +@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) + + +@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 + ), + html.hr(), + ) + + +@component +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 + ), + html.hr(), + ) + + +@component +def view_to_component_sync_class_compatibility(): + return html.div( + {"id": inspect.currentframe().f_code.co_name}, + view_to_component( + views.ViewToComponentSyncClassCompatibility, compatibility=True + ), + html.hr(), + ) + + +@component +def view_to_component_async_class_compatibility(): + return html.div( + {"id": inspect.currentframe().f_code.co_name}, + view_to_component( + views.ViewToComponentAsyncClassCompatibility, compatibility=True + ), + html.hr(), + ) + + +@component +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 + ), + 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) + + def on_click(_): + post_request = HttpRequest() + post_request.method = "POST" + set_request(post_request) + + 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_request, request=request), + ) + + +@component +def view_to_component_args(): + params, set_params = hooks.use_state("false") + + def on_click(_): + set_params("") + + 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]), + ) + + +@component +def view_to_component_kwargs(): + params, set_params = hooks.use_state("false") + + def on_click(_): + set_params("") + + 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}), + ) diff --git a/tests/test_app/static/django-css-test.css b/tests/test_app/static/django-css-test.css index 41f98461..40266ebb 100644 --- a/tests/test_app/static/django-css-test.css +++ b/tests/test_app/static/django-css-test.css @@ -1,3 +1,3 @@ #django-css button { - color: rgb(0, 0, 255); + color: rgb(0, 0, 255); } diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index b22f0bd1..bea9893a 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -12,6 +12,14 @@

IDOM Test Page

+ +
{% component "test_app.components.hello_world" class="hello-world" %}
{% component "test_app.components.button" class="button" %}
{% component "test_app.components.parameterized_component" class="parametarized-component" x=123 y=456 %}
@@ -24,6 +32,20 @@

IDOM Test Page

{% component "test_app.components.unauthorized_user" %}
{% component "test_app.components.authorized_user" %}
{% component "test_app.components.todo_list" %}
+
{% component "test_app.components.view_to_component_sync_func" %}
+
{% component "test_app.components.view_to_component_async_func" %}
+
{% component "test_app.components.view_to_component_sync_class" %}
+
{% component "test_app.components.view_to_component_async_class" %}
+
{% component "test_app.components.view_to_component_template_view_class" %}
+
{% component "test_app.components.view_to_component_script" %}
+
{% component "test_app.components.view_to_component_request" %}
+
{% component "test_app.components.view_to_component_args" %}
+
{% component "test_app.components.view_to_component_kwargs" %}
+
{% component "test_app.components.view_to_component_sync_func_compatibility" %}
+
{% component "test_app.components.view_to_component_async_func_compatibility" %}
+
{% 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" %}
diff --git a/tests/test_app/templates/view_to_component.html b/tests/test_app/templates/view_to_component.html new file mode 100644 index 00000000..7dbe51de --- /dev/null +++ b/tests/test_app/templates/view_to_component.html @@ -0,0 +1,4 @@ +{% block top %}{% endblock %} +
{{ test_name }}: {% firstof status "Success" %} +
+
{% block bottom %}{% endblock %} diff --git a/tests/test_app/templates/view_to_component_script.html b/tests/test_app/templates/view_to_component_script.html new file mode 100644 index 00000000..c9c5d263 --- /dev/null +++ b/tests/test_app/templates/view_to_component_script.html @@ -0,0 +1,16 @@ +{% extends "view_to_component.html" %} + +{% block top %} + +{% endblock %} + +{% block bottom %} + +{% endblock %} diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 95fe0963..cf6b153e 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -10,8 +10,18 @@ class TestIdomCapabilities(ChannelsLiveServerTestCase): @classmethod def setUpClass(cls): if sys.platform == "win32": - raise SkipTest("These tests are broken on Windows due to Selenium") + raise SkipTest("These tests are broken on Windows.") + + # FIXME: The following lines will be needed once Django channels fixes Windows tests + # See: https://github.com/django/channels/issues/1207 + + # import asyncio + # asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + # FIXME: This is required otherwise the tests will throw a `SynchronousOnlyOperation` + # error when deleting the test datatabase. Potentially a Django bug. os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" + super().setUpClass() cls.playwright = sync_playwright().start() headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", 0))) @@ -97,3 +107,72 @@ def test_use_query_and_mutation(self): f"#todo-item-sample-{i}", timeout=1, ) + + def test_view_to_component_sync_func(self): + self.page.locator("#view_to_component_sync_func[data-success=true]").wait_for() + + def test_view_to_component_async_func(self): + self.page.locator("#view_to_component_async_func[data-success=true]").wait_for() + + def test_view_to_component_sync_class(self): + self.page.locator("#ViewToComponentSyncClass[data-success=true]").wait_for() + + def test_view_to_component_async_class(self): + self.page.locator("#ViewToComponentAsyncClass[data-success=true]").wait_for() + + def test_view_to_component_template_view_class(self): + self.page.locator( + "#ViewToComponentTemplateViewClass[data-success=true]" + ).wait_for() + + def _click_btn_and_check_success(self, name): + self.page.locator(f"#{name}:not([data-success=true])").wait_for() + self.page.wait_for_selector(f"#{name}_btn").click() + self.page.locator(f"#{name}[data-success=true]").wait_for() + + def test_view_to_component_script(self): + self._click_btn_and_check_success("view_to_component_script") + + def test_view_to_component_request(self): + self._click_btn_and_check_success("view_to_component_request") + + def test_view_to_component_args(self): + self._click_btn_and_check_success("view_to_component_args") + + def test_view_to_component_kwargs(self): + self._click_btn_and_check_success("view_to_component_kwargs") + + def test_view_to_component_sync_func_compatibility(self): + self.page.frame_locator( + "#view_to_component_sync_func_compatibility > iframe" + ).locator( + "#view_to_component_sync_func_compatibility[data-success=true]" + ).wait_for() + + def test_view_to_component_async_func_compatibility(self): + self.page.frame_locator( + "#view_to_component_async_func_compatibility > iframe" + ).locator( + "#view_to_component_async_func_compatibility[data-success=true]" + ).wait_for() + + def test_view_to_component_sync_class_compatibility(self): + self.page.frame_locator( + "#view_to_component_sync_class_compatibility > iframe" + ).locator( + "#ViewToComponentSyncClassCompatibility[data-success=true]" + ).wait_for() + + def test_view_to_component_async_class_compatibility(self): + self.page.frame_locator( + "#view_to_component_async_class_compatibility > iframe" + ).locator( + "#ViewToComponentAsyncClassCompatibility[data-success=true]" + ).wait_for() + + def test_view_to_component_template_view_class_compatibility(self): + self.page.frame_locator( + "#view_to_component_template_view_class_compatibility > iframe" + ).locator( + "#ViewToComponentTemplateViewClassCompatibility[data-success=true]" + ).wait_for() diff --git a/tests/test_app/views.py b/tests/test_app/views.py index 11874908..cec726f5 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -1,6 +1,138 @@ +import inspect + +from channels.db import database_sync_to_async from django.shortcuts import render +from django.views.generic import TemplateView, View def base_template(request): context = {} return render(request, "base.html", context) + + +def view_to_component_sync_func(request): + return render( + request, + "view_to_component.html", + {"test_name": inspect.currentframe().f_code.co_name}, + ) + + +async def view_to_component_async_func(request): + return render( + request, + "view_to_component.html", + {"test_name": inspect.currentframe().f_code.co_name}, + ) + + +class ViewToComponentSyncClass(View): + def get(self, request, *args, **kwargs): + return render( + request, + "view_to_component.html", + {"test_name": self.__class__.__name__}, + ) + + +class ViewToComponentAsyncClass(View): + async def get(self, request, *args, **kwargs): + return await database_sync_to_async(render)( + request, + "view_to_component.html", + {"test_name": self.__class__.__name__}, + ) + + +class ViewToComponentTemplateViewClass(TemplateView): + template_name = "view_to_component.html" + + def get_context_data(self, **kwargs): + return {"test_name": self.__class__.__name__} + + +def view_to_component_sync_func_compatibility(request): + return render( + request, + "view_to_component.html", + {"test_name": inspect.currentframe().f_code.co_name}, + ) + + +async def view_to_component_async_func_compatibility(request): + return await database_sync_to_async(render)( + request, + "view_to_component.html", + {"test_name": inspect.currentframe().f_code.co_name}, + ) + + +class ViewToComponentSyncClassCompatibility(View): + def get(self, request, *args, **kwargs): + return render( + request, + "view_to_component.html", + {"test_name": self.__class__.__name__}, + ) + + +class ViewToComponentAsyncClassCompatibility(View): + async def get(self, request, *args, **kwargs): + return await database_sync_to_async(render)( + request, + "view_to_component.html", + {"test_name": self.__class__.__name__}, + ) + + +class ViewToComponentTemplateViewClassCompatibility(TemplateView): + template_name = "view_to_component.html" + + def get_context_data(self, **kwargs): + return {"test_name": self.__class__.__name__} + + +def view_to_component_script(request): + return render( + request, + "view_to_component_script.html", + { + "test_name": inspect.currentframe().f_code.co_name, + "status": "false", + }, + ) + + +def view_to_component_request(request): + if request.method == "POST": + return render( + request, + "view_to_component.html", + {"test_name": inspect.currentframe().f_code.co_name}, + ) + + return render( + request, + "view_to_component.html", + { + "test_name": inspect.currentframe().f_code.co_name, + "status": "false", + "success": "false", + }, + ) + + +def view_to_component_args(request, success): + return render( + request, + "view_to_component.html", + {"test_name": inspect.currentframe().f_code.co_name, "status": success}, + ) + + +def view_to_component_kwargs(request, success="false"): + return render( + request, + "view_to_component.html", + {"test_name": inspect.currentframe().f_code.co_name, "status": success}, + )