diff --git a/CHANGELOG.md b/CHANGELOG.md index 894a1390..20a85b93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,18 @@ Using the following categories, list your changes in this order: ## [Unreleased] -- Nothing (Yet) +### Added + +- `use_origin` hook to return the browser's `location.origin`. + +### Changed + +- `use_mutation` and `use_query` will now log any query failures. + +### Fixed + +- Allow `use_mutation` to have `refetch=None`, as the docs suggest is possible. +- `use_query` will now prefetch all fields to prevent `SynchronousOnlyOperation` exceptions. ## [1.2.0] - 2022-09-19 diff --git a/README.md b/README.md index dfee8134..1c7e4fbc 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ You'll need a file to define your [IDOM](https://github.com/idom-team/idom) comp -```python title="components.py" +```python linenums="1" from idom import component, html @component @@ -51,7 +51,7 @@ Additonally, you can pass in keyword arguments into your component function. For -```jinja title="my-template.html" +```jinja linenums="1" {% load idom %} diff --git a/docs/includes/examples.md b/docs/includes/examples.md index 623e60c3..3ed273fd 100644 --- a/docs/includes/examples.md +++ b/docs/includes/examples.md @@ -1,6 +1,6 @@ -```python +```python linenums="1" from django.http import HttpResponse def hello_world_view(request, *args, **kwargs): @@ -11,7 +11,7 @@ def hello_world_view(request, *args, **kwargs): -```python +```python linenums="1" from django.http import HttpResponse from django.views import View @@ -20,4 +20,15 @@ class HelloWorldView(View): return HttpResponse("Hello World!") ``` - \ No newline at end of file + + + + +```python linenums="1" +from django.db import models + +class TodoItem(models.Model): + text = models.CharField(max_length=255) +``` + + diff --git a/docs/src/contribute/django-idom.md b/docs/src/contribute/code.md similarity index 100% rename from docs/src/contribute/django-idom.md rename to docs/src/contribute/code.md diff --git a/docs/src/features/components.md b/docs/src/features/components.md index 8d95c062..5886fc23 100644 --- a/docs/src/features/components.md +++ b/docs/src/features/components.md @@ -1,10 +1,14 @@ +???+ summary + + Prefabricated components can be used within your `components.py` to help simplify development. + ## 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 + ```python linenums="1" from idom import component, html from django_idom.components import view_to_component from .views import hello_world_view @@ -47,7 +51,7 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible === "components.py" - ```python + ```python linenums="1" from idom import component, html from django_idom.components import view_to_component from .views import HelloWorldView @@ -69,7 +73,7 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible === "components.py" - ```python + ```python linenums="1" from idom import component, html from django_idom.components import view_to_component from .views import hello_world_view @@ -99,7 +103,7 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible === "components.py" - ```python + ```python linenums="1" from idom import component, html from django_idom.components import view_to_component from .views import hello_world_view @@ -125,7 +129,7 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible === "components.py" - ```python + ```python linenums="1" from idom import component, html from django_idom.components import view_to_component from .views import hello_world_view @@ -153,7 +157,7 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible === "components.py" - ```python + ```python linenums="1" from idom import component, html from django_idom.components import view_to_component from .views import hello_world_view @@ -174,7 +178,7 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible === "views.py" - ```python + ```python linenums="1" from django.http import HttpResponse def hello_world_view(request, *args, **kwargs): @@ -185,17 +189,33 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible 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 +=== "components.py" + + ```python linenums="1" + 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!"), + ) + ``` -@component -def my_component(): - return html.div( - django_css("css/buttons.css"), - html.button("My Button!"), - ) -``` +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | static_path | `str` | The path to the static file. This path is identical to what you would use on a `static` template tag. | N/A | + + **Returns** + + | Type | Description | + | --- | --- | + | `Component` | An IDOM component. | ??? question "Should I put `django_css` at the top of my component?" @@ -207,7 +227,7 @@ def my_component(): Here's an example on what you should avoid doing for Django static files: - ```python + ```python linenums="1" from idom import component, html from django.templatetags.static import static @@ -225,7 +245,7 @@ def my_component(): For external CSS, substitute `django_css` with `html.link`. - ```python + ```python linenums="1" from idom import component, html @component @@ -246,17 +266,33 @@ def my_component(): 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 +=== "components.py" + + ```python linenums="1" + 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"), + ) + ``` -@component -def my_component(): - return html.div( - html.button("My Button!"), - django_js("js/scripts.js"), - ) -``` +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | static_path | `str` | The path to the static file. This path is identical to what you would use on a `static` template tag. | N/A | + + **Returns** + + | Type | Description | + | --- | --- | + | `Component` | An IDOM component. | ??? question "Should I put `django_js` at the bottom of my component?" @@ -268,7 +304,7 @@ def my_component(): Here's an example on what you should avoid doing for Django static files: - ```python + ```python linenums="1" from idom import component, html from django.templatetags.static import static @@ -286,7 +322,7 @@ def my_component(): For external JavaScript, substitute `django_js` with `html.script`. - ```python + ```python linenums="1" from idom import component, html @component diff --git a/docs/src/features/decorators.md b/docs/src/features/decorators.md index 29a32d96..8b28c08a 100644 --- a/docs/src/features/decorators.md +++ b/docs/src/features/decorators.md @@ -1,23 +1,29 @@ +???+ summary + + Decorator utilities can be used within your `components.py` to help simplify development. + ## Auth Required You can limit access to a component to users with a specific `auth_attribute` by using this decorator. By default, this decorator checks if the user is logged in, and his/her account has not been deactivated. -Common uses of this decorator are to hide components from [`AnonymousUser`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.AnonymousUser), or render a component only if the user [`is_staff`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.User.is_staff) or [`is_superuser`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.User.is_superuser). +This decorator is commonly used to selectively render a component only if a user [`is_staff`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.User.is_staff) or [`is_superuser`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.User.is_superuser). This decorator can be used with or without parentheses. -```python title="components.py" -from django_idom.decorators import auth_required -from django_idom.hooks import use_websocket -from idom import component, html +=== "components.py" -@component -@auth_required -def my_component(): - return html.div("I am logged in!") -``` + ```python linenums="1" + from django_idom.decorators import auth_required + from django_idom.hooks import use_websocket + from idom import component, html + + @component + @auth_required + def my_component(): + return html.div("I am logged in!") + ``` ??? example "See Interface" @@ -40,50 +46,56 @@ def my_component(): You can use a component with the `fallback` argument, as seen below. - ```python title="components.py" - from django_idom.decorators import auth_required - from idom import component, html + === "components.py" - @component - def my_component_fallback(): - return html.div("I am NOT logged in!") + ```python linenums="1" + from django_idom.decorators import auth_required + from idom import component, html - @component - @auth_required(fallback=my_component_fallback) - def my_component(): - return html.div("I am logged in!") - ``` + @component + def my_component_fallback(): + return html.div("I am NOT logged in!") + + @component + @auth_required(fallback=my_component_fallback) + def my_component(): + return html.div("I am logged in!") + ``` ??? question "How do I render a simple `idom.html` snippet if authentication fails?" You can use a `idom.html` snippet with the `fallback` argument, as seen below. - ```python title="components.py" - from django_idom.decorators import auth_required - from django_idom.hooks import use_websocket - from idom import component, html + === "components.py" - @component - @auth_required(fallback=html.div("I am NOT logged in!")) - def my_component(): - return html.div("I am logged in!") - ``` + ```python linenums="1" + from django_idom.decorators import auth_required + from django_idom.hooks import use_websocket + from idom import component, html + + @component + @auth_required(fallback=html.div("I am NOT logged in!")) + def my_component(): + return html.div("I am logged in!") + ``` ??? question "How can I check if a user `is_staff`?" You can set the `auth_attribute` to `is_staff`, as seen blow. - ```python title="components.py" - from django_idom.decorators import auth_required - from django_idom.hooks import use_websocket - from idom import component, html + === "components.py" + ```python linenums="1" + from django_idom.decorators import auth_required + from django_idom.hooks import use_websocket + from idom import component, html - @component - @auth_required(auth_attribute="is_staff") - def my_component(): - return html.div("I am logged in!") - ``` + + @component + @auth_required(auth_attribute="is_staff") + def my_component(): + return html.div("I am logged in!") + ``` ??? question "How can I check for a custom attribute?" @@ -91,24 +103,28 @@ def my_component(): For example, if your user model has the field `is_really_cool` ... - ```python - from django.contrib.auth.models import AbstractBaseUser + === "models.py" - class CustomUserModel(AbstractBaseUser): - @property - def is_really_cool(self): - return True - ``` + ```python linenums="1" + from django.contrib.auth.models import AbstractBaseUser + + class CustomUserModel(AbstractBaseUser): + @property + def is_really_cool(self): + return True + ``` ... then you would do the following within your decorator: - ```python title="components.py" - from django_idom.decorators import auth_required - from django_idom.hooks import use_websocket - from idom import component, html + === "components.py" - @component - @auth_required(auth_attribute="is_really_cool") - def my_component(): - return html.div("I am logged in!") - ``` + ```python linenums="1" + from django_idom.decorators import auth_required + from django_idom.hooks import use_websocket + from idom import component, html + + @component + @auth_required(auth_attribute="is_really_cool") + def my_component(): + return html.div("I am logged in!") + ``` diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md index 09c8daa9..e9e67e39 100644 --- a/docs/src/features/hooks.md +++ b/docs/src/features/hooks.md @@ -1,14 +1,22 @@ -???+ tip "Looking for more hooks?" +???+ summary - Check out the [IDOM Core docs](https://idom-docs.herokuapp.com/docs/reference/hooks-api.html#basic-hooks) on hooks! + Prefabricated hooks can be used within your `components.py` to help simplify development. + +??? tip "Looking for standard ReactJS hooks?" + + Standard ReactJS hooks are contained within [`idom-team/idom`](https://github.com/idom-team/idom). Since `idom` is installed by default alongside `django-idom`, you can import them at any time. + + Check out the [IDOM Core docs](https://idom-docs.herokuapp.com/docs/reference/hooks-api.html#basic-hooks) to see what hooks are available! ## Use Query The `use_query` hook is used fetch Django ORM queries. +The function you provide into this hook must return either a `Model` or `QuerySet`. + === "components.py" - ```python + ```python linenums="1" from example_project.my_app.models import TodoItem from idom import component, html from django_idom.hooks import use_query @@ -32,19 +40,41 @@ The `use_query` hook is used fetch Django ORM queries. === "models.py" - ```python + ```python linenums="1" from django.db import models class TodoItem(models.Model): text = models.CharField(max_length=255) ``` +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | query | `Callable[_Params, _Result | None]` | A callable that returns a Django `Model` or `QuerySet`. | N/A | + | *args | `_Params.args` | Positional arguments to pass into `query`. | N/A | + | **kwargs | `_Params.kwargs` | Keyword arguments to pass into `query`. | N/A | + + **Returns** + + | Type | Description | + | --- | --- | + | `Query[_Result | None]` | A dataclass containing `loading`/`error` states, your `data` (if the query has successfully executed), and a `refetch` callable that can be used to re-run the query. | + ??? question "Can 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 "Why does the example `get_items` function return a `Model` or `QuerySet`?" + + This was a technical design decision to [based on Apollo](https://www.apollographql.com/docs/react/data/mutations/#usemutation-api), but ultimately helps avoid Django's `SynchronousOnlyOperation` exceptions. + + The `use_query` hook ensures the provided `Model` or `QuerySet` executes all [deferred](https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.get_deferred_fields)/[lazy queries](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy) safely prior to reaching your components. + ??? question "What is an "ORM"?" A Python **Object Relational Mapper** is an API for your code to access a database. @@ -53,11 +83,13 @@ The `use_query` hook is used fetch Django ORM queries. ## Use Mutation -The `use_mutation` hook is used to modify Django ORM objects. +The `use_mutation` hook is used to create, update, or delete Django ORM objects. + +The function you provide into this hook will have no return value. === "components.py" - ```python + ```python linenums="1" from example_project.my_app.models import TodoItem from idom import component, html from django_idom.hooks import use_mutation @@ -67,8 +99,11 @@ The `use_mutation` hook is used to modify Django ORM objects. @component def todo_list(): - item_mutation = use_mutation(add_item) + def submit_event(event): + if event["key"] == "Enter": + item_mutation.execute(text=event["target"]["value"]) + item_mutation = use_mutation(add_item) if item_mutation.loading: mutation_status = html.h2("Adding...") elif item_mutation.error: @@ -76,10 +111,6 @@ The `use_mutation` hook is used to modify Django ORM objects. else: mutation_status = "" - def submit_event(event): - if event["key"] == "Enter": - item_mutation.execute(text=event["target"]["value"]) - return html.div( html.label("Add an item:"), html.input({"type": "text", "onKeyDown": submit_event}), @@ -89,61 +120,121 @@ The `use_mutation` hook is used to modify Django ORM objects. === "models.py" - ```python - from django.db import models + {% include-markdown "../../includes/examples.md" start="" end="" %} - class TodoItem(models.Model): - text = models.CharField(max_length=255) - ``` +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | mutate | `Callable[_Params, bool | None]` | A callable that performs Django ORM create, update, or delete functionality. If this function returns `False`, then your `refetch` function will not be used. | N/A | + | refetch | `Callable[..., Any] | Sequence[Callable[..., Any]] | None` | A `query` function (used by the `use_query` hook) or a sequence of `query` functions that will be called if the mutation succeeds. This is useful for refetching data after a mutation has been performed. | `None` | + + **Returns** + + | Type | Description | + | --- | --- | + | `Mutation[_Params]` | A dataclass containing `loading`/`error` states, a `reset` callable that will set `loading`/`error` states to defaults, and a `execute` callable that will run the query. | ??? question "Can `use_mutation` trigger a refetch of `use_query`?" Yes, `use_mutation` can queue a refetch of a `use_query` via the `refetch=...` argument. The example below is a merge of the `use_query` and `use_mutation` examples above with the addition of a `refetch` argument on `use_mutation`. - + Please note that any `use_query` hooks that use `get_items` will be refetched upon a successful mutation. - ```python title="components.py" - from example_project.my_app.models import TodoItem - from idom import component, html - from django_idom.hooks import use_mutation + === "components.py" - def get_items(): - return TodoItem.objects.all() + ```python linenums="1" + from example_project.my_app.models import TodoItem + from idom import component, html + from django_idom.hooks import use_mutation - def add_item(text: str): - TodoItem(text=text).save() + def get_items(): + return TodoItem.objects.all() - @component - def todo_list(): - item_query = use_query(get_items) - if item_query.loading: - rendered_items = html.h2("Loading...") - elif item_query.error: - rendered_items = html.h2("Error when loading!") - else: - rendered_items = html.ul(html.li(item, key=item) for item in item_query.data) + def add_item(text: str): + TodoItem(text=text).save() - item_mutation = use_mutation(add_item, refetch=get_items) - if item_mutation.loading: - mutation_status = html.h2("Adding...") - elif item_mutation.error: - mutation_status = html.h2("Error when adding!") - else: - mutation_status = "" + @component + def todo_list(): + def submit_event(event): + if event["key"] == "Enter": + item_mutation.execute(text=event["target"]["value"]) - def submit_event(event): - if event["key"] == "Enter": - item_mutation.execute(text=event["target"]["value"]) + item_query = use_query(get_items) + if item_query.loading: + rendered_items = html.h2("Loading...") + elif item_query.error: + rendered_items = html.h2("Error when loading!") + else: + rendered_items = html.ul(html.li(item, key=item) for item in item_query.data) - return html.div( - html.label("Add an item:"), - html.input({"type": "text", "onKeyDown": submit_event}), - mutation_status, - rendered_items, - ) - ``` + item_mutation = use_mutation(add_item, refetch=get_items) + if item_mutation.loading: + mutation_status = html.h2("Adding...") + elif item_mutation.error: + mutation_status = html.h2("Error when adding!") + else: + mutation_status = "" + + return html.div( + html.label("Add an item:"), + html.input({"type": "text", "onKeyDown": submit_event}), + mutation_status, + rendered_items, + ) + ``` + + === "models.py" + + {% include-markdown "../../includes/examples.md" start="" end="" %} + +??? question "Can I make a failed `use_mutation` try again?" + + Yes, a `use_mutation` can be re-performed by calling `reset()` on your `use_mutation` instance. + + For example, take a look at `reset_event` below. + + === "components.py" + + ```python linenums="1" + from example_project.my_app.models import TodoItem + from idom import component, html + from django_idom.hooks import use_mutation + + def add_item(text: str): + TodoItem(text=text).save() + + @component + def todo_list(): + def reset_event(event): + item_mutation.reset() + + def submit_event(event): + if event["key"] == "Enter": + item_mutation.execute(text=event["target"]["value"]) + + item_mutation = use_mutation(add_item) + if item_mutation.loading: + mutation_status = html.h2("Adding...") + elif item_mutation.error: + mutation_status = html.button({"onClick": reset_event}, "Error: Try again!") + else: + mutation_status = "" + + return html.div( + html.label("Add an item:"), + html.input({"type": "text", "onKeyDown": submit_event}), + mutation_status, + ) + ``` + + === "models.py" + + {% include-markdown "../../includes/examples.md" start="" end="" %} ??? question "Can I make ORM calls without hooks?" @@ -161,48 +252,122 @@ The `use_mutation` hook is used to modify Django ORM objects. ## Use Websocket -You can fetch the Django Channels websocket at any time by using `use_websocket`. +You can fetch the Django Channels [websocket](https://channels.readthedocs.io/en/stable/topics/consumers.html#asyncjsonwebsocketconsumer) at any time by using `use_websocket`. -```python title="components.py" -from idom import component, html -from django_idom.hooks import use_websocket +=== "components.py" -@component -def my_component(): - my_websocket = use_websocket() - return html.div(my_websocket) -``` + ```python linenums="1" + from idom import component, html + from django_idom.hooks import use_websocket + + @component + def my_component(): + my_websocket = use_websocket() + return html.div(my_websocket) + ``` + +??? example "See Interface" + + **Parameters** + + `None` + + **Returns** + + | Type | Description | + | --- | --- | + | `IdomWebsocket` | The component's websocket. | ## Use Scope -This is a shortcut that returns the Websocket's `scope`. +This is a shortcut that returns the Websocket's [`scope`](https://channels.readthedocs.io/en/stable/topics/consumers.html#scope). + +=== "components.py" + + ```python linenums="1" + from idom import component, html + from django_idom.hooks import use_scope + + @component + def my_component(): + my_scope = use_scope() + return html.div(my_scope) + ``` + +??? example "See Interface" + + **Parameters** -```python title="components.py" -from idom import component, html -from django_idom.hooks import use_scope + `None` -@component -def my_component(): - my_scope = use_scope() - return html.div(my_scope) -``` + **Returns** + + | Type | Description | + | --- | --- | + | `dict[str, Any]` | The websocket's `scope`. | ## Use Location +This is a shortcut that returns the Websocket's `path`. + +You can expect this hook to provide strings such as `/idom/my_path`. + +=== "components.py" + + ```python linenums="1" + from idom import component, html + from django_idom.hooks import use_location + + @component + def my_component(): + my_location = use_location() + return html.div(my_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 change will come in alongside IDOM URL routing support. + This hook will be updated to return the browser's currently active path. 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. + Check out [idom-team/idom-router#2](https://github.com/idom-team/idom-router/issues/2) for more information. -This is a shortcut that returns the Websocket's `path`. +??? example "See Interface" + + **Parameters** + + `None` + + **Returns** + + | Type | Description | + | --- | --- | + | `Location` | A object containing the current URL's `pathname` and `search` query. | + +## Use Origin + +This is a shortcut that returns the Websocket's `origin`. + +You can expect this hook to provide strings such as `http://example.com`. + +=== "components.py" + + ```python linenums="1" + from idom import component, html + from django_idom.hooks import use_origin + + @component + def my_component(): + my_origin = use_origin() + return html.div(my_origin) + ``` + +??? example "See Interface" + + **Parameters** + + `None` -```python title="components.py" -from idom import component, html -from django_idom.hooks import use_location + **Returns** -@component -def my_component(): - my_location = use_location() - return html.div(my_location) -``` + | Type | Description | + | --- | --- | + | `str | None` | A string containing the browser's current origin, obtained from websocket headers (if available). | diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md index b003ae3c..c6a7c020 100644 --- a/docs/src/features/settings.md +++ b/docs/src/features/settings.md @@ -1,24 +1,28 @@ -Django IDOM uses your **Django project's** `settings.py` file to modify some behaviors of IDOM. +???+ summary -Here are the configurable variables that are available. + Django IDOM uses your **Django project's** `settings.py` file to modify the behavior of IDOM. - +## Primary Configuration -```python title="settings.py" -# If "idom" cache is not configured, then we'll use "default" instead -CACHES = { -"idom": {"BACKEND": ...}, -} +=== "settings.py" -# 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/" -``` + ```python linenums="1" + # 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?" diff --git a/docs/src/features/templatetag.md b/docs/src/features/templatetag.md index 43910f4c..3e7731d9 100644 --- a/docs/src/features/templatetag.md +++ b/docs/src/features/templatetag.md @@ -1,6 +1,12 @@ -Integrated within Django IDOM, we bundle a template tag. Within this tag, you can pass in keyword arguments directly into your component. +???+ summary -{% include-markdown "../../../README.md" start="" end="" %} + Template tags can be used within your Django templates such as `my-template.html` to import IDOM features. + +## Component + +=== "my-template.html" + + {% include-markdown "../../../README.md" start="" end="" %} @@ -8,23 +14,27 @@ Integrated within Django IDOM, we bundle a template tag. Within this tag, you ca Our pre-processor relies on the template tag containing a string. - **Do not** use a Django context variable for the path string. Failure to follow this warning will result in a performance penalty and also jankiness when using the Django autoreloader. + **Do not** use Django template/context variables for the component path. Failure to follow this warning will result in unexpected behavior. For example, **do not** do the following: - ```python title="views.py" - def example_view(): - context_vars = {"dont_do_this": "example_project.my_app.components.hello_world"} - return render(request, "my-template.html", context_vars) - ``` + === "my-template.html" - ```jinja title="my-template.html" - - {% component dont_do_this recipient="World" %} + ```jinja linenums="1" + + {% component dont_do_this recipient="World" %} - - {% component "example_project.my_app.components.hello_world" recipient="World" %} - ``` + + {% component "example_project.my_app.components.hello_world" recipient="World" %} + ``` + + === "views.py" + + ```python linenums="1" + def example_view(): + context_vars = {"dont_do_this": "example_project.my_app.components.hello_world"} + return render(request, "my-template.html", context_vars) + ``` @@ -36,30 +46,50 @@ Integrated within Django IDOM, we bundle a template tag. Within this tag, you ca - `class` allows you to apply a HTML class to the top-level component div. This is useful for styling purposes. - `key` allows you to force the component to use a [specific key value](https://idom-docs.herokuapp.com/docs/guides/understanding-idom/why-idom-needs-keys.html?highlight=key). You typically won't need to set this. - ```jinja title="my-template.html" - ... - {% component "example.components.my_component" class="my-html-class" key=123 %} - ... - ``` + === "my-template.html" + + ```jinja linenums="1" + ... + {% component "example.components.my_component" class="my-html-class" key=123 %} + ... + ``` + +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | dotted_path | `str` | The dotted path to the component to render. | N/A | + | **kwargs | `Any` | The keyword arguments to pass to the component. | N/A | + + **Returns** + + | Type | Description | + | --- | --- | + | `Component` | An IDOM component. | + ??? question "Can I use multiple components on one page?" You can add as many components to a webpage as needed by using the template tag multiple times. Retrofitting legacy sites to use IDOM will typically involve many components on one page. - ```jinja - {% load idom %} - - - - {% 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" %}
- - - ``` + === "my-template.html" + + ```jinja linenums="1" + {% load idom %} + + + + {% 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. @@ -81,6 +111,8 @@ 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="" %} + === "my-template.html" + + {% include-markdown "../../../README.md" start="" end="" %} diff --git a/docs/src/getting-started/create-component.md b/docs/src/getting-started/create-component.md index 29281051..8481cf87 100644 --- a/docs/src/getting-started/create-component.md +++ b/docs/src/getting-started/create-component.md @@ -6,7 +6,9 @@ {% include-markdown "../../../README.md" start="" end="" %} -{% include-markdown "../../../README.md" start="" end="" %} +=== "components.py" + + {% include-markdown "../../../README.md" start="" end="" %} ??? question "What should I name my IDOM files and functions?" diff --git a/docs/src/getting-started/learn-more.md b/docs/src/getting-started/learn-more.md index 31449ea9..e9aa7419 100644 --- a/docs/src/getting-started/learn-more.md +++ b/docs/src/getting-started/learn-more.md @@ -8,4 +8,4 @@ Additionally, the vast majority of tutorials/guides you find for React can be ap | 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 } | +| [Django-IDOM Advanced Usage](../features/components.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/src/getting-started/reference-component.md b/docs/src/getting-started/reference-component.md index db5c4a5e..f7c03ecb 100644 --- a/docs/src/getting-started/reference-component.md +++ b/docs/src/getting-started/reference-component.md @@ -6,7 +6,9 @@ {% include-markdown "../../../README.md" start="" end="" %} -{% include-markdown "../../../README.md" start="" end="" %} +=== "my-template.html" + + {% include-markdown "../../../README.md" start="" end="" %} {% include-markdown "../features/templatetag.md" start="" end="" %} diff --git a/docs/src/getting-started/render-view.md b/docs/src/getting-started/render-view.md index d264517b..b1055d9b 100644 --- a/docs/src/getting-started/render-view.md +++ b/docs/src/getting-started/render-view.md @@ -10,23 +10,27 @@ Within your **Django app**'s `views.py` file, you'll need to create a function t In this example, we will create a view that renders `my-template.html` (_from the previous step_). -```python title="views.py" -from django.shortcuts import render +=== "views.py" -def index(request): - return render(request, "my-template.html") -``` + ```python linenums="1" + from django.shortcuts import render + + def index(request): + return render(request, "my-template.html") + ``` We will add this new view into your [`urls.py`](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view). -```python title="urls.py" -from django.urls import path -from example_project.my_app import views +=== "urls.py" + + ```python linenums="1" + from django.urls import path + from example_project.my_app import views -urlpatterns = [ - path("example/", views.index), -] -``` + urlpatterns = [ + path("example/", views.index), + ] + ``` Now, navigate to `http://127.0.0.1:8000/example/`. If you copy-pasted the component from the previous example, you will now see your component display "Hello World". diff --git a/docs/src/installation/index.md b/docs/src/installation/index.md index e0cdfc1e..61782278 100644 --- a/docs/src/installation/index.md +++ b/docs/src/installation/index.md @@ -16,12 +16,14 @@ You'll also need to modify a few files in your **Django project**... In your settings you'll need to add `django_idom` to [`INSTALLED_APPS`](https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-INSTALLED_APPS). -```python title="settings.py" -INSTALLED_APPS = [ - "django_idom", - ... -] -``` +=== "settings.py" + + ```python linenums="1" + INSTALLED_APPS = [ + "django_idom", + ... + ] + ``` ??? warning "Enable Django ASGI (Required)" @@ -31,13 +33,15 @@ INSTALLED_APPS = [ Read the [Django Channels Docs](https://channels.readthedocs.io/en/stable/installation.html) for more info. - ```python title="settings.py" - INSTALLED_APPS = [ - "channels", - ... - ] - ASGI_APPLICATION = "example_project.asgi.application" - ``` + === "settings.py" + + ```python linenums="1" + INSTALLED_APPS = [ + "channels", + ... + ] + ASGI_APPLICATION = "example_project.asgi.application" + ``` ??? note "Configure IDOM settings (Optional)" @@ -51,14 +55,16 @@ INSTALLED_APPS = [ Add IDOM HTTP paths to your `urlpatterns`. -```python title="urls.py" -from django.urls import include, path +=== "urls.py" -urlpatterns = [ - path("idom/", include("django_idom.http.urls")), - ... -] -``` + ```python linenums="1" + from django.urls import include, path + + urlpatterns = [ + path("idom/", include("django_idom.http.urls")), + ... + ] + ``` --- @@ -66,28 +72,30 @@ urlpatterns = [ Register IDOM's Websocket using `IDOM_WEBSOCKET_PATH`. -```python title="asgi.py" -import os -from django.core.asgi import get_asgi_application - -# Ensure DJANGO_SETTINGS_MODULE is set properly based on your project name! -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") -django_asgi_app = get_asgi_application() - -from channels.auth import AuthMiddlewareStack -from channels.routing import ProtocolTypeRouter, URLRouter -from channels.sessions import SessionMiddlewareStack -from django_idom import IDOM_WEBSOCKET_PATH - -application = ProtocolTypeRouter( - { - "http": django_asgi_app, - "websocket": SessionMiddlewareStack( - AuthMiddlewareStack(URLRouter([IDOM_WEBSOCKET_PATH])) - ), - } -) -``` +=== "asgi.py" + + ```python linenums="1" + import os + from django.core.asgi import get_asgi_application + + # Ensure DJANGO_SETTINGS_MODULE is set properly based on your project name! + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") + django_asgi_app = get_asgi_application() + + from channels.auth import AuthMiddlewareStack + from channels.routing import ProtocolTypeRouter, URLRouter + from channels.sessions import SessionMiddlewareStack + from django_idom import IDOM_WEBSOCKET_PATH + + application = ProtocolTypeRouter( + { + "http": django_asgi_app, + "websocket": SessionMiddlewareStack( + AuthMiddlewareStack(URLRouter([IDOM_WEBSOCKET_PATH])) + ), + } + ) + ``` ??? question "Where is my asgi.py?" diff --git a/docs/src/stylesheets/extra.css b/docs/src/stylesheets/extra.css index 6b30153f..79881677 100644 --- a/docs/src/stylesheets/extra.css +++ b/docs/src/stylesheets/extra.css @@ -10,3 +10,8 @@ .md-typeset :is(.admonition, details) { margin: 0.55em 0; } + +.md-typeset .tabbed-labels > label { + padding-top: 0; + padding-bottom: 0.35em; +} diff --git a/mkdocs.yml b/mkdocs.yml index 3d7287b9..c3c339ec 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,7 +15,7 @@ nav: - Template Tag: features/templatetag.md - Settings: features/settings.md - Contribute: - - Code: contribute/django-idom.md + - Code: contribute/code.md - Docs: contribute/docs.md - Running Tests: contribute/running-tests.md - Changelog: changelog/index.md diff --git a/src/django_idom/components.py b/src/django_idom/components.py index 34ce359c..0a91c5c0 100644 --- a/src/django_idom/components.py +++ b/src/django_idom/components.py @@ -15,6 +15,7 @@ from django_idom.config import IDOM_CACHE, IDOM_VIEW_COMPONENT_IFRAMES from django_idom.types import ViewComponentIframe +from django_idom.utils import _generate_obj_name # TODO: Might want to intercept href clicks and form submit events. @@ -163,14 +164,3 @@ def _cached_static_contents(static_path: str): ) 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/hooks.py b/src/django_idom/hooks.py index 49b2f606..7e73df23 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import logging from typing import Any, Awaitable, Callable, DefaultDict, Sequence, Union, cast from channels.db import database_sync_to_async as _database_sync_to_async @@ -11,8 +12,10 @@ from idom.core.hooks import Context, create_context, use_context, use_effect, use_state from django_idom.types import IdomWebsocket, Mutation, Query, _Params, _Result +from django_idom.utils import _generate_obj_name +_logger = logging.getLogger(__name__) database_sync_to_async = cast( Callable[..., Callable[..., Awaitable[Any]]], _database_sync_to_async, @@ -24,13 +27,30 @@ def use_location() -> Location: - """Get the current route as a string""" + """Get the current route as a `Location` object""" # TODO: Use the browser's current page, rather than the WS route scope = use_scope() search = scope["query_string"].decode() return Location(scope["path"], f"?{search}" if search else "") +def use_origin() -> str | None: + """Get the current origin as a string. If the browser did not send an origin header, + this will be None.""" + scope = use_scope() + try: + return next( + ( + header[1].decode("utf-8") + for header in scope["headers"] + if header[0] == b"origin" + ), + None, + ) + except Exception: + return None + + def use_scope() -> dict[str, Any]: """Get the current ASGI scope dictionary""" return use_websocket().scope @@ -49,6 +69,14 @@ def use_query( *args: _Params.args, **kwargs: _Params.kwargs, ) -> Query[_Result | None]: + """Hook to fetch a Django ORM query. + + Args: + query: A callable that returns a Django `Model` or `QuerySet`. + *args: Positional arguments to pass into `query`. + + Keyword Args: + **kwargs: Keyword arguments to pass into `query`.""" query_ref = use_ref(query) if query_ref.current is not query: raise ValueError(f"Query function changed from {query_ref.current} to {query}.") @@ -79,11 +107,14 @@ def execute_query() -> None: try: new_data = query(*args, **kwargs) - _fetch_deferred(new_data) + _fetch_lazy_fields(new_data) except Exception as e: set_data(None) set_loading(False) set_error(e) + _logger.exception( + f"Failed to execute query: {_generate_obj_name(query) or query}" + ) return finally: set_should_execute(False) @@ -97,8 +128,19 @@ def execute_query() -> None: def use_mutation( mutate: Callable[_Params, bool | None], - refetch: Callable[..., Any] | Sequence[Callable[..., Any]], + refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None, ) -> Mutation[_Params]: + """Hook to create, update, or delete Django ORM objects. + + Args: + mutate: A callable that performs Django ORM create, update, or delete + functionality. If this function returns `False`, then your `refetch` + function will not be used. + refetch: A `query` function (used by the `use_query` hook) or a sequence of `query` + functions that will be called if the mutation succeeds. This is useful for + refetching data after a mutation has been performed. + """ + loading, set_loading = use_state(False) error, set_error = use_state(cast(Union[Exception, None], None)) @@ -113,10 +155,16 @@ def execute_mutation() -> None: except Exception as e: set_loading(False) set_error(e) + _logger.exception( + f"Failed to execute mutation: {_generate_obj_name(mutate) or mutate}" + ) else: set_loading(False) set_error(None) - if should_refetch is not False: + + # `refetch` will execute unless explicitly told not to + # or if `refetch` was not defined. + if should_refetch is not False and refetch: for query in (refetch,) if callable(refetch) else refetch: for callback in _REFETCH_CALLBACKS.get(query) or (): callback() @@ -131,19 +179,20 @@ def reset() -> None: return Mutation(call, loading, error, reset) -def _fetch_deferred(data: Any) -> None: +def _fetch_lazy_fields(data: Any) -> None: + """Fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily.""" + + # `QuerySet`, which is effectively a list of `Model` instances # https://github.com/typeddjango/django-stubs/issues/704 if isinstance(data, QuerySet): # type: ignore[misc] for model in data: - _fetch_deferred_model_fields(model) + _fetch_lazy_fields(model) + + # `Model` instances elif isinstance(data, Model): - _fetch_deferred_model_fields(data) + for field in data._meta.fields: + getattr(data, field.name) + + # Unrecognized type else: raise ValueError(f"Expected a Model or QuerySet, got {data!r}") - - -def _fetch_deferred_model_fields(model: Any) -> None: - for field in model.get_deferred_fields(): - value = getattr(model, field) - if isinstance(value, Model): - _fetch_deferred_model_fields(value) diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index 5e6b7ced..b6bb6868 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -1,4 +1,5 @@ import json +from typing import Any from urllib.parse import urlencode from uuid import uuid4 @@ -14,13 +15,16 @@ @register.inclusion_tag("idom/component.html") -def component(_component_id_, **kwargs): - """ - This tag is used to embed an existing IDOM component into your HTML template. +def component(dotted_path: str, **kwargs: Any): + """This tag is used to embed an existing IDOM component into your HTML template. + + Args: + dotted_path: The dotted path to the component to render. - The first argument within this tag is your dotted path to the component function. + Keyword Args: + **kwargs: The keyword arguments to pass to the component. - Subsequent values are keyworded arguments are passed into your component:: + Example :: {% load idom %} @@ -30,7 +34,7 @@ def component(_component_id_, **kwargs): """ - _register_component(_component_id_) + _register_component(dotted_path) class_ = kwargs.pop("class", "") json_kwargs = json.dumps(kwargs, separators=(",", ":")) @@ -41,6 +45,6 @@ def component(_component_id_, **kwargs): "idom_web_modules_url": IDOM_WEB_MODULES_URL, "idom_ws_max_reconnect_timeout": IDOM_WS_MAX_RECONNECT_TIMEOUT, "idom_mount_uuid": uuid4().hex, - "idom_component_id": _component_id_, + "idom_component_id": dotted_path, "idom_component_params": urlencode({"kwargs": json_kwargs}), } diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py index 2f6782ab..c6b308bb 100644 --- a/src/django_idom/utils.py +++ b/src/django_idom/utils.py @@ -6,7 +6,7 @@ import re from fnmatch import fnmatch from importlib import import_module -from typing import Callable +from typing import Any, Callable from django.template import engines from django.utils.encoding import smart_str @@ -144,3 +144,14 @@ def _register_components(self, components: set[str]) -> None: "\033[0m", component, ) + + +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/tests/test_app/components.py b/tests/test_app/components.py index c103e01c..40fbd7d6 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -85,6 +85,17 @@ def use_location(): ) +@component +def use_origin(): + origin = django_idom.hooks.use_origin() + success = bool(origin) + return html.div( + {"id": "use-origin", "data-success": success}, + f"use_origin: {origin}", + html.hr(), + ) + + @component def django_css(): return html.div( @@ -167,7 +178,7 @@ def toggle_item_mutation(item: TodoItem): def todo_list(): 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) + toggle_item = use_mutation(toggle_item_mutation) if items.error: rendered_items = html.h2(f"Error when loading - {items.error}") diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index bea9893a..8f610e96 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -27,6 +27,7 @@

IDOM Test Page

{% component "test_app.components.use_websocket" %}
{% component "test_app.components.use_scope" %}
{% component "test_app.components.use_location" %}
+
{% component "test_app.components.use_origin" %}
{% component "test_app.components.django_css" %}
{% component "test_app.components.django_js" %}
{% component "test_app.components.unauthorized_user" %}
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index cf6b153e..923e3e93 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -6,6 +6,9 @@ from playwright.sync_api import TimeoutError, sync_playwright +CLICK_DELAY = 250 # Delay in miliseconds. Needed for GitHub Actions. + + class TestIdomCapabilities(ChannelsLiveServerTestCase): @classmethod def setUpClass(cls): @@ -62,6 +65,9 @@ def test_use_scope(self): def test_use_location(self): self.page.locator("#use-location[data-success=true]").wait_for() + def test_use_origin(self): + self.page.locator("#use-origin[data-success=true]").wait_for() + def test_static_css(self): self.assertEqual( self.page.wait_for_selector("#django-css button").evaluate( @@ -97,8 +103,8 @@ def test_use_query_and_mutation(self): item_ids = list(range(5)) for i in item_ids: - todo_input.type(f"sample-{i}", delay=10) - todo_input.press("Enter") + todo_input.type(f"sample-{i}", delay=CLICK_DELAY) + todo_input.press("Enter", delay=CLICK_DELAY) self.page.wait_for_selector(f"#todo-item-sample-{i}") self.page.wait_for_selector(f"#todo-item-sample-{i}-checkbox").click() self.assertRaises(