From 9f0fa65c184e53367dd56e212c9a213e4214858b Mon Sep 17 00:00:00 2001 From: Mark <16909269+Archmonger@users.noreply.github.com> Date: Sat, 2 Jul 2022 21:26:21 -0700 Subject: [PATCH 1/9] Docs revision (#85) * use snake_case component names * add settings.py to exclusive features * change light/dark mode icons * create docs tests * configure test workflows properly * rename workflow/jobs * remove alternative title on hooks page * format all docs --- .github/workflows/test-docs.yml | 22 ++++++++++++ .github/workflows/{test.yml => test-src.yml} | 2 +- README.md | 8 ++--- docs/features/components.md | 12 +++---- docs/features/hooks.md | 13 +++---- docs/features/settings.md | 27 ++++++++++++++ docs/features/templatetag.md | 16 +++++---- docs/installation/index.md | 14 +------- mkdocs.yml | 5 +-- src/django_idom/templatetags/idom.py | 2 +- tests/test_app/components.py | 36 +++++++++---------- ...tatic-css-test.css => django-css-test.css} | 2 +- tests/test_app/static/django-js-test.js | 3 ++ tests/test_app/static/static-js-test.js | 3 -- tests/test_app/templates/base.html | 18 +++++----- tests/test_app/tests/test_components.py | 4 +-- 16 files changed, 111 insertions(+), 76 deletions(-) create mode 100644 .github/workflows/test-docs.yml rename .github/workflows/{test.yml => test-src.yml} (97%) create mode 100644 docs/features/settings.md rename tests/test_app/static/{static-css-test.css => django-css-test.css} (61%) create mode 100644 tests/test_app/static/django-js-test.js delete mode 100644 tests/test_app/static/static-js-test.js diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml new file mode 100644 index 00000000..967f7c8c --- /dev/null +++ b/.github/workflows/test-docs.yml @@ -0,0 +1,22 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "0 0 * * *" + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: 3.x + - run: pip install -r requirements/build-docs.txt + - run: mkdocs build --verbose diff --git a/.github/workflows/test.yml b/.github/workflows/test-src.yml similarity index 97% rename from .github/workflows/test.yml rename to .github/workflows/test-src.yml index 2675c040..f5320f35 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test-src.yml @@ -11,7 +11,7 @@ on: - cron: "0 0 * * *" jobs: - test-python-versions: + source: runs-on: ubuntu-latest strategy: matrix: diff --git a/README.md b/README.md index 58f86611..dfee8134 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Any Python web framework with Websockets can support IDOM. See below for what fr -You'll need a file to define your [IDOM](https://github.com/idom-team/idom) components. We recommend creating a `components.py` file within your chosen **Django app** to start out. Within this file, we will create a simple `HelloWorld` component. +You'll need a file to define your [IDOM](https://github.com/idom-team/idom) components. We recommend creating a `components.py` file within your chosen **Django app** to start out. Within this file, we will create a simple `hello_world` component. @@ -34,7 +34,7 @@ You'll need a file to define your [IDOM](https://github.com/idom-team/idom) comp from idom import component, html @component -def HelloWorld(recipient: str): +def hello_world(recipient: str): return html.h1(f"Hello {recipient}!") ``` @@ -46,7 +46,7 @@ def HelloWorld(recipient: str): In your **Django app**'s HTML template, you can now embed your IDOM component using the `component` template tag. Within this tag, you will need to type in your dotted path to the component function as the first argument. -Additonally, you can pass in keyword arguments into your component function. For example, after reading the code below, pay attention to how the function definition for `HelloWorld` (_in the previous example_) accepts a `recipient` argument. +Additonally, you can pass in keyword arguments into your component function. For example, after reading the code below, pay attention to how the function definition for `hello_world` (_in the previous example_) accepts a `recipient` argument. @@ -56,7 +56,7 @@ Additonally, you can pass in keyword arguments into your component function. For - {% component "example_project.my_app.components.HelloWorld" recipient="World" %} + {% component "example_project.my_app.components.hello_world" recipient="World" %} ``` diff --git a/docs/features/components.md b/docs/features/components.md index 947a4431..dc697162 100644 --- a/docs/features/components.md +++ b/docs/features/components.md @@ -7,7 +7,7 @@ from idom import component, html from django_idom.components import django_css @component -def MyComponent(): +def my_component(): return html.div( django_css("css/buttons.css"), html.button("My Button!"), @@ -29,7 +29,7 @@ def MyComponent(): from django.templatetags.static import static @component - def MyComponent(): + def my_component(): return html.div( html.link({"rel": "stylesheet", "href": static("css/buttons.css")}), html.button("My Button!"), @@ -46,7 +46,7 @@ def MyComponent(): from idom import component, html @component - def MyComponent(): + def my_component(): return html.div( html.link({"rel": "stylesheet", "href": "https://example.com/external-styles.css"}), html.button("My Button!"), @@ -68,7 +68,7 @@ from idom import component, html from django_idom.components import django_js @component -def MyComponent(): +def my_component(): return html.div( html.button("My Button!"), django_js("js/scripts.js"), @@ -90,7 +90,7 @@ def MyComponent(): from django.templatetags.static import static @component - def MyComponent(): + def my_component(): return html.div( html.script({"src": static("js/scripts.js")}), html.button("My Button!"), @@ -107,7 +107,7 @@ def MyComponent(): from idom import component, html @component - def MyComponent(): + def my_component(): return html.div( html.script({"src": static("https://example.com/external-scripts.js")}), html.button("My Button!"), diff --git a/docs/features/hooks.md b/docs/features/hooks.md index e1cc4d04..7a0d8a99 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -1,5 +1,3 @@ -# Django Hooks - ???+ 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! @@ -13,13 +11,11 @@ from idom import component, html from django_idom.hooks import use_websocket @component -def MyComponent(): +def my_component(): my_websocket = use_websocket() return html.div(my_websocket) ``` - - ## Use Scope This is a shortcut that returns the Websocket's `scope`. @@ -29,17 +25,16 @@ from idom import component, html from django_idom.hooks import use_scope @component -def MyComponent(): +def my_component(): my_scope = use_scope() return html.div(my_scope) ``` - ## Use Location ??? info "This hook's behavior will be changed in a future update" - This hook will be updated to return the browser's current URL. This will come in alongside our built-in [Single Page Application (SPA) support](https://github.com/idom-team/idom/issues/569). + This hook will be updated to return the browser's current URL. This change will come in alongside [IDOM URL routing support](https://github.com/idom-team/idom/issues/569). This is a shortcut that returns the Websocket's `path`. @@ -48,7 +43,7 @@ from idom import component, html from django_idom.hooks import use_location @component -def MyComponent(): +def my_component(): my_location = use_location() return html.div(my_location) ``` diff --git a/docs/features/settings.md b/docs/features/settings.md new file mode 100644 index 00000000..b003ae3c --- /dev/null +++ b/docs/features/settings.md @@ -0,0 +1,27 @@ +Django IDOM uses your **Django project's** `settings.py` file to modify some behaviors of IDOM. + +Here are the configurable variables that are available. + + + +```python title="settings.py" +# If "idom" cache is not configured, then we'll use "default" instead +CACHES = { +"idom": {"BACKEND": ...}, +} + +# Maximum seconds between two reconnection attempts that would cause the client give up. +# 0 will disable reconnection. +IDOM_WS_MAX_RECONNECT_TIMEOUT = 604800 + +# The URL for IDOM to serve websockets +IDOM_WEBSOCKET_URL = "idom/" +``` + + + +??? question "Do I need to modify my settings?" + + The default configuration of IDOM is adequate for the majority of use cases. + + You should only consider changing settings when the necessity arises. diff --git a/docs/features/templatetag.md b/docs/features/templatetag.md index 26a2f4ad..5f917f92 100644 --- a/docs/features/templatetag.md +++ b/docs/features/templatetag.md @@ -14,16 +14,16 @@ Integrated within Django IDOM, we bundle a template tag. Within this tag, you ca ```python title="views.py" def example_view(): - context_vars = {"DontDoThis": "example_project.my_app.components.HelloWorld"} + context_vars = {"dont_do_this": "example_project.my_app.components.hello_world"} return render(request, "my-template.html", context_vars) ``` ```jinja title="my-template.html" - {% component DontDoThis recipient="World" %} + {% component dont_do_this recipient="World" %} - {% component "example_project.my_app.components.HelloWorld" recipient="World" %} + {% component "example_project.my_app.components.hello_world" recipient="World" %} ``` @@ -38,7 +38,7 @@ Integrated within Django IDOM, we bundle a template tag. Within this tag, you ca ```jinja title="my-template.html" ... - {% component "example.components.MyComponent" class="my-html-class" key=123 %} + {% component "example.components.my_component" class="my-html-class" key=123 %} ... ``` @@ -54,15 +54,17 @@ Integrated within Django IDOM, we bundle a template tag. Within this tag, you ca - {% component "example_project.my_app.components.HelloWorld" recipient="World" %} - {% component "example_project.my_app_2.components.ClassComponent" class="bold small-font" %} -
{% component "example_project.my_app_3.components.SimpleComponent" %}
+ {% component "example_project.my_app.components.hello_world" recipient="World" %} + {% component "example_project.my_app_2.components.class_component" class="bold small-font" %} +
{% component "example_project.my_app_3.components.simple_component" %}
``` But keep in mind, in scenarios where you are trying to create a Single Page Application (SPA) within Django, you will only have one central component within your `#!html ` tag. + Additionally, the components in the example above will not be able to interact with each other, except through database queries. + diff --git a/docs/installation/index.md b/docs/installation/index.md index a2a6e391..e0cdfc1e 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -43,19 +43,7 @@ INSTALLED_APPS = [ Below are a handful of values you can change within `settings.py` to modify the behavior of IDOM. - ```python title="settings.py" - # If "idom" cache is not configured, then we'll use "default" instead - CACHES = { - "idom": {"BACKEND": ...}, - } - - # Maximum seconds between two reconnection attempts that would cause the client give up. - # 0 will disable reconnection. - IDOM_WS_MAX_RECONNECT_TIMEOUT = 604800 - - # The URL for IDOM to serve websockets - IDOM_WEBSOCKET_URL = "idom/" - ``` + {% include-markdown "../features/settings.md" start="" end="" %} --- diff --git a/mkdocs.yml b/mkdocs.yml index 50cef329..9cf9d38b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,7 @@ nav: - Components: features/components.md - Hooks: features/hooks.md - Template Tag: features/templatetag.md + - Settings: features/settings.md - Contribute: - Code: contribute/django-idom.md - Docs: contribute/docs.md @@ -24,14 +25,14 @@ theme: - media: "(prefers-color-scheme: dark)" scheme: slate toggle: - icon: material/toggle-switch + icon: material/white-balance-sunny name: Switch to light mode primary: deep-orange accent: orange - media: "(prefers-color-scheme: light)" scheme: default toggle: - icon: material/toggle-switch-off-outline + icon: material/weather-night name: Switch to dark mode primary: black features: diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index 7b8f3610..5e6b7ced 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -26,7 +26,7 @@ def component(_component_id_, **kwargs): - {% component "example_project.my_app.components.HelloWorld" recipient="World" %} + {% component "example_project.my_app.components.hello_world" recipient="World" %} """ diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 687bfea2..a2491c17 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -4,12 +4,12 @@ @idom.component -def HelloWorld(): +def hello_world(): return idom.html.h1({"id": "hello-world"}, "Hello World!") @idom.component -def Button(): +def button(): count, set_count = idom.hooks.use_state(0) return idom.html.div( idom.html.button( @@ -24,7 +24,7 @@ def Button(): @idom.component -def ParametrizedComponent(x, y): +def parameterized_component(x, y): total = x + y return idom.html.h1({"id": "parametrized-component", "data-value": total}, total) @@ -34,64 +34,64 @@ def ParametrizedComponent(x, y): @idom.component -def SimpleBarChart(): +def simple_bar_chart(): return VictoryBar() @idom.component -def UseWebsocket(): +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( {"id": "use-websocket", "data-success": success}, idom.html.hr(), - f"UseWebsocket: {ws}", + f"use_websocket: {ws}", idom.html.hr(), ) @idom.component -def UseScope(): +def use_scope(): scope = django_idom.hooks.use_scope() success = len(scope) >= 10 and scope["type"] == "websocket" return idom.html.div( {"id": "use-scope", "data-success": success}, - f"UseScope: {scope}", + f"use_scope: {scope}", idom.html.hr(), ) @idom.component -def UseLocation(): +def use_location(): location = django_idom.hooks.use_location() success = bool(location) return idom.html.div( {"id": "use-location", "data-success": success}, - f"UseLocation: {location}", + f"use_location: {location}", idom.html.hr(), ) @idom.component -def StaticCSS(): +def django_css(): return idom.html.div( - {"id": "static-css"}, - django_idom.components.django_css("static-css-test.css"), - idom.html.div({"style": {"display": "inline"}}, "StaticCSS: "), + {"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(), ) @idom.component -def StaticJS(): +def django_js(): success = False return idom.html._( idom.html.div( - {"id": "static-js", "data-success": success}, - f"StaticJS: {success}", - django_idom.components.django_js("static-js-test.js"), + {"id": "django-js", "data-success": success}, + f"django_js: {success}", + django_idom.components.django_js("django-js-test.js"), ), idom.html.hr(), ) diff --git a/tests/test_app/static/static-css-test.css b/tests/test_app/static/django-css-test.css similarity index 61% rename from tests/test_app/static/static-css-test.css rename to tests/test_app/static/django-css-test.css index d5565d70..af68e6ed 100644 --- a/tests/test_app/static/static-css-test.css +++ b/tests/test_app/static/django-css-test.css @@ -1,3 +1,3 @@ -#static-css button { +#django-css button { color: rgba(0, 0, 255, 1); } diff --git a/tests/test_app/static/django-js-test.js b/tests/test_app/static/django-js-test.js new file mode 100644 index 00000000..1d59eb31 --- /dev/null +++ b/tests/test_app/static/django-js-test.js @@ -0,0 +1,3 @@ +let el = document.body.querySelector("#django-js"); +el.textContent = "django_js: True"; +el.dataset.success = "true"; diff --git a/tests/test_app/static/static-js-test.js b/tests/test_app/static/static-js-test.js deleted file mode 100644 index b4423acf..00000000 --- a/tests/test_app/static/static-js-test.js +++ /dev/null @@ -1,3 +0,0 @@ -let el = document.body.querySelector("#static-js"); -el.textContent = "StaticJS: True"; -el.dataset.success = "true"; diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index c597cdf9..e6960c14 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -12,15 +12,15 @@

IDOM Test Page

-
{% component "test_app.components.HelloWorld" class="hello-world" %}
-
{% component "test_app.components.Button" class="button" %}
-
{% component "test_app.components.ParametrizedComponent" class="parametarized-component" x=123 y=456 %}
-
{% component "test_app.components.SimpleBarChart" %}
-
{% component "test_app.components.UseWebsocket" %}
-
{% component "test_app.components.UseScope" %}
-
{% component "test_app.components.UseLocation" %}
-
{% component "test_app.components.StaticCSS" %}
-
{% component "test_app.components.StaticJS" %}
+
{% 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 %}
+
{% component "test_app.components.simple_bar_chart" %}
+
{% component "test_app.components.use_websocket" %}
+
{% component "test_app.components.use_scope" %}
+
{% component "test_app.components.use_location" %}
+
{% component "test_app.components.django_css" %}
+
{% component "test_app.components.django_js" %}
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 33a8c6e8..8ed89efa 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -60,13 +60,13 @@ def test_use_location(self): self.assertEqual(element.get_attribute("data-success"), "true") def test_static_css(self): - element = self.driver.find_element_by_css_selector("#static-css button") + element = self.driver.find_element_by_css_selector("#django-css button") self.assertEqual( element.value_of_css_property("color"), "rgba(0, 0, 255, 1)" ) def test_static_js(self): - element = self.driver.find_element_by_id("static-js") + element = self.driver.find_element_by_id("django-js") self.assertEqual(element.get_attribute("data-success"), "true") From afad233d68dc489fa450ee2f6ee46406f5f5e97b Mon Sep 17 00:00:00 2001 From: Mark <16909269+Archmonger@users.noreply.github.com> Date: Tue, 5 Jul 2022 21:35:24 -0700 Subject: [PATCH 2/9] ORM Usage Docs (#87) --- .github/pull_request_template.md | 4 +-- docs/features/hooks.md | 4 ++- docs/features/orm.md | 52 ++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 4 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 docs/features/orm.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 92ac3c3d..33212937 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,8 +1,8 @@ -# Description +## Description A summary of the changes. -# Checklist: +## Checklist: Please update this checklist as you complete each item: diff --git a/docs/features/hooks.md b/docs/features/hooks.md index 7a0d8a99..ad4afb34 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -34,7 +34,9 @@ def my_component(): ??? info "This hook's behavior will be changed in a future update" - This hook will be updated to return the browser's current URL. This change will come in alongside [IDOM URL routing support](https://github.com/idom-team/idom/issues/569). + This hook will be updated to return the browser's current URL. This change will come in alongside IDOM URL routing support. + + Check out [idom-team/idom#569](https://github.com/idom-team/idom/issues/569) for more information. This is a shortcut that returns the Websocket's `path`. diff --git a/docs/features/orm.md b/docs/features/orm.md new file mode 100644 index 00000000..056db7f1 --- /dev/null +++ b/docs/features/orm.md @@ -0,0 +1,52 @@ +??? info "Our suggested ORM access method will be changed in a future update" + + The Django IDOM team is currently assessing the optimal way to integrate the [Django ORM](https://docs.djangoproject.com/en/dev/topics/db/queries/) with our React-style framework. + + This docs page exists to demonstrate how the ORM should be used with the current version of Django IDOM. + + Check out [idom-team/django-idom#79](https://github.com/idom-team/django-idom/issues/79) for more information. + +This is the suggested method of using the Django ORM with your components. + +```python title="components.py" +from channels.db import database_sync_to_async +from example_project.my_app.models import Category +from idom import component, hooks, html + + +@component +def simple_list(): + categories, set_categories = hooks.use_state(None) + + @hooks.use_effect + @database_sync_to_async + def get_categories(): + if categories: + return + set_categories(list(Category.objects.all())) + + if not categories: + return html.h2("Loading...") + + return html.ul( + [html.li(category.name, key=category.name) for category in categories] + ) +``` + +??? question "Why does this example use `list()` within `set_categories`?" + + [Django's ORM is lazy](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy). Thus, `list()` is used to ensure that the database query is executed while within the hook. + + Failure to do this will result in `SynchronousOnlyOperation` when attempting to access your data. + +??? question "Why can't I make ORM calls without hooks?" + + Due to Django's ORM design, database queries must be deferred using hooks. Otherwise, you will see a `SynchronousOnlyOperation` exception. + + This may be resolved in a future version of Django with a natively asynchronous ORM. + +??? question "What is an ORM?" + + A Python **Object Relational Mapper** is an API for your code to access a database. + + See the [Django ORM documentation](https://docs.djangoproject.com/en/dev/topics/db/queries/) for more information. diff --git a/mkdocs.yml b/mkdocs.yml index 9cf9d38b..e4c6b059 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,6 +11,7 @@ nav: - Exclusive Features: - Components: features/components.md - Hooks: features/hooks.md + - ORM: features/orm.md - Template Tag: features/templatetag.md - Settings: features/settings.md - Contribute: From c42ed9d7648c3bffc56c4f69c9af5f4334b8c96b Mon Sep 17 00:00:00 2001 From: Mark <16909269+Archmonger@users.noreply.github.com> Date: Wed, 6 Jul 2022 19:42:18 -0700 Subject: [PATCH 3/9] Fix some example in docs (#89) * fix django js example * standardize quotes on "what is an... " * fix reference --- docs/features/components.md | 2 +- docs/features/orm.md | 2 +- docs/getting-started/render-view.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/features/components.md b/docs/features/components.md index dc697162..fa6b45f0 100644 --- a/docs/features/components.md +++ b/docs/features/components.md @@ -109,7 +109,7 @@ def my_component(): @component def my_component(): return html.div( - html.script({"src": static("https://example.com/external-scripts.js")}), + html.script({"src": "https://example.com/external-scripts.js"}), html.button("My Button!"), ) ``` diff --git a/docs/features/orm.md b/docs/features/orm.md index 056db7f1..38454ca2 100644 --- a/docs/features/orm.md +++ b/docs/features/orm.md @@ -45,7 +45,7 @@ def simple_list(): This may be resolved in a future version of Django with a natively asynchronous ORM. -??? question "What is an ORM?" +??? question "What is an "ORM"?" A Python **Object Relational Mapper** is an API for your code to access a database. diff --git a/docs/getting-started/render-view.md b/docs/getting-started/render-view.md index deb6a14e..d264517b 100644 --- a/docs/getting-started/render-view.md +++ b/docs/getting-started/render-view.md @@ -36,4 +36,4 @@ Now, navigate to `http://127.0.0.1:8000/example/`. If you copy-pasted the compon Once you reach that point, we recommend creating an individual `urls.py` within each of your **Django apps**. - Then, within your **Django project's** `urls.py` you will use Django's [`include` function](https://docs.djangoproject.com/en/dev/ref/urls/) to link it all together. + Then, within your **Django project's** `urls.py` you will use Django's [`include` function](https://docs.djangoproject.com/en/dev/ref/urls/#include) to link it all together. From 1e571ffd390bbc0f2cc14d451b135e8cab65a369 Mon Sep 17 00:00:00 2001 From: Mark <16909269+Archmonger@users.noreply.github.com> Date: Wed, 13 Jul 2022 00:22:13 -0700 Subject: [PATCH 4/9] Auth Required Decorator (#88) --- CHANGELOG.md | 6 +- docs/features/decorators.md | 115 ++++++++++++++++++++++++ docs/stylesheets/extra.css | 2 +- mkdocs.yml | 1 + src/django_idom/__init__.py | 15 +++- src/django_idom/decorators.py | 44 +++++++++ src/django_idom/hooks.py | 11 +-- src/django_idom/types.py | 10 +++ src/django_idom/websocket/consumer.py | 3 +- tests/test_app/components.py | 33 +++++++ tests/test_app/templates/base.html | 2 + tests/test_app/tests/test_components.py | 19 ++++ 12 files changed, 244 insertions(+), 17 deletions(-) create mode 100644 docs/features/decorators.md create mode 100644 src/django_idom/decorators.py create mode 100644 src/django_idom/types.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a283c7d8..e4f20e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), " 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}, + ) From 78823ee501b466b7bac35b3f067b3d9806e9bdbc Mon Sep 17 00:00:00 2001 From: Mark <16909269+Archmonger@users.noreply.github.com> Date: Mon, 19 Sep 2022 22:12:41 -0700 Subject: [PATCH 9/9] v1.2.0 (#97) --- CHANGELOG.md | 8 +++++++- docs/src/contribute/django-idom.md | 1 - docs/src/features/components.md | 6 +++--- src/django_idom/__init__.py | 2 +- src/django_idom/components.py | 4 ++-- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 471f872e..894a1390 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ Using the following categories, list your changes in this order: ## [Unreleased] +- Nothing (Yet) + +## [1.2.0] - 2022-09-19 + ### Added - `auth_required` decorator to prevent your components from rendering to unauthenticated users. @@ -32,6 +36,7 @@ Using the following categories, list your changes in this order: ### Changed - Bumped the minimum IDOM version to 0.40.2 +- Testing suite now uses `playwright` instead of `selenium` ### Fixed @@ -125,7 +130,8 @@ Using the following categories, list your changes in this order: - Support for IDOM within the Django -[unreleased]: https://github.com/idom-team/django-idom/compare/1.0.0...HEAD +[unreleased]: https://github.com/idom-team/django-idom/compare/1.2.0...HEAD +[1.2.0]: https://github.com/idom-team/django-idom/compare/1.1.0...1.2.0 [1.1.0]: https://github.com/idom-team/django-idom/compare/1.0.0...1.1.0 [1.0.0]: https://github.com/idom-team/django-idom/compare/0.0.5...1.0.0 [0.0.5]: https://github.com/idom-team/django-idom/compare/0.0.4...0.0.5 diff --git a/docs/src/contribute/django-idom.md b/docs/src/contribute/django-idom.md index 6199d4f8..3f3d7aee 100644 --- a/docs/src/contribute/django-idom.md +++ b/docs/src/contribute/django-idom.md @@ -7,7 +7,6 @@ If you plan to make code changes to this repository, you'll need to install the - [Python 3.8+](https://www.python.org/downloads/) - [Git](https://git-scm.com/downloads) - [NPM](https://docs.npmjs.com/try-the-latest-stable-version-of-npm) for installing and managing Javascript -- [ChromeDriver](https://chromedriver.chromium.org/downloads) for testing with [Selenium](https://www.seleniumhq.org/) Once done, you should clone this repository: diff --git a/docs/src/features/components.md b/docs/src/features/components.md index cc8d9bf4..8d95c062 100644 --- a/docs/src/features/components.md +++ b/docs/src/features/components.md @@ -27,10 +27,10 @@ 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. Strict parsing does not apply to compatibility mode. | `False` | + | 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` | | 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` | + | 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` | @@ -93,7 +93,7 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible 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. + Any view can be rendered within compatibility mode. However, the `transforms`, `strict_parsing`, and `request` 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. diff --git a/src/django_idom/__init__.py b/src/django_idom/__init__.py index c34467fc..998ab5a6 100644 --- a/src/django_idom/__init__.py +++ b/src/django_idom/__init__.py @@ -3,7 +3,7 @@ from django_idom.websocket.paths import IDOM_WEBSOCKET_PATH -__version__ = "1.1.0" +__version__ = "1.2.0" __all__ = [ "IDOM_WEBSOCKET_PATH", "IdomWebsocket", diff --git a/src/django_idom/components.py b/src/django_idom/components.py index 9c5aae6c..34ce359c 100644 --- a/src/django_idom/components.py +++ b/src/django_idom/components.py @@ -36,13 +36,13 @@ def view_to_component( Keyword Args: compatibility: If True, the component will be rendered in an iframe. - Strict parsing does not apply to compatibility mode. + 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. - 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. """