From c01152ab89ffa9c1264441e7eeb18b24f5b32d42 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 2 Jul 2022 12:18:59 -0700 Subject: [PATCH 01/57] wip use_query and use_mutation --- docs/features/hooks.md | 49 +++++++++++++ requirements/pkg-deps.txt | 1 + src/django_idom/hooks.py | 143 +++++++++++++++++++++++++++++++++++++- src/django_idom/utils.py | 9 +++ 4 files changed, 199 insertions(+), 3 deletions(-) diff --git a/docs/features/hooks.md b/docs/features/hooks.md index ad4afb34..9ca67f97 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -2,6 +2,55 @@ Check out the [IDOM Core docs](https://idom-docs.herokuapp.com/docs/reference/hooks-api.html?highlight=hooks) on hooks! +## Use Query and Use Mutation + + + +```python +from example_project.my_app.models import TodoItem +from idom import component, html +from django_idom.hooks import use_query, use_mutation + + +def get_items(): + return TodoItem.objects.all() + +def add_item(text: str): + TodoItem(text=text).save() + + +@component +def todo_list(): + items_query = use_query(get_items) + add_item_mutation = use_mutation(add_item, refetch=get_items) + item_draft, set_item_draft = use_state("") + + if items_query.loading: + items_view = html.h2("Loading...") + elif items_query.error: + items_view = html.h2(f"Error when loading: {items.error}") + else: + items_view = html.ul(html.li(item, key=item) for item in items_query.data) + + if add_item_mutation.loading: + add_item_status = html.h2("Adding...") + elif add_item_mutation.error: + add_item_status = html.h2(f"Error when adding: {add_item_mutation.error}") + else: + add_item_status = "" + + def handle_add_item(event): + set_item_draft("") + add_item_mutation.execute(text=item_draft) + + return html.div( + html.label("Add an item:") + html.input({"type": "text", "onClick": handle_add_item}) + add_item_status, + items_view, + ) +``` + ## Use Websocket You can fetch the Django Channels websocket at any time by using `use_websocket`. diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 08e42fad..0674e22b 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,3 +1,4 @@ channels >=3.0.0 idom >=0.39.0, <0.40.0 aiofile >=3.0 +typing_extensions diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 79ce1d16..79298517 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -1,7 +1,31 @@ -from typing import Dict, Type, Union +from __future__ import annotations + +from dataclasses import dataclass +from threading import Thread +from types import FunctionType +from typing import ( + Dict, + Type, + Union, + Any, + Awaitable, + Callable, + DefaultDict, + Optional, + Sequence, + Type, + Union, + TypeVar, + Generic, + NamedTuple, +) + +from typing_extensions import ParamSpec +from idom import use_callback from idom.backend.types import Location -from idom.core.hooks import Context, create_context, use_context +from idom.core.hooks import Context, create_context, use_context, use_state, use_effect +from django_idom.utils import UNDEFINED from django_idom.types import IdomWebsocket @@ -19,7 +43,7 @@ def use_location() -> Location: return Location(scope["path"], f"?{search}" if search else "") -def use_scope() -> Dict: +def use_scope() -> dict[str, Any]: """Get the current ASGI scope dictionary""" return use_websocket().scope @@ -30,3 +54,116 @@ def use_websocket() -> IdomWebsocket: if websocket is None: raise RuntimeError("No websocket. Are you running with a Django server?") return websocket + + +_REFETCH_CALLBACKS: DefaultDict[FunctionType, set[Callable[[], None]]] = DefaultDict( + set +) + + +_Data = TypeVar("_Data") +_Params = ParamSpec("_Params") + + +def use_query( + query: Callable[_Params, _Data], + *args: _Params.args, + **kwargs: _Params.kwargs, +) -> Query[_Data]: + given_query = query + query, _ = use_state(given_query) + if given_query is not query: + raise ValueError(f"Query function changed from {query} to {given_query}.") + + data, set_data = use_state(UNDEFINED) + loading, set_loading = use_state(True) + error, set_error = use_state(None) + + @use_callback + def refetch() -> None: + set_data(UNDEFINED) + set_loading(True) + set_error(None) + + @use_effect(dependencies=[]) + def add_refetch_callback(): + # By tracking callbacks globally, any usage of the query function will be re-run + # if the user has told a mutation to refetch it. + _REFETCH_CALLBACKS[query].add(refetch) + return lambda: _REFETCH_CALLBACKS[query].remove(refetch) + + @use_effect(dependencies=None) + def execute_query(): + if data is not UNDEFINED: + return + + def thread_target(): + try: + returned = query(*args, **kwargs) + except Exception as e: + set_data(UNDEFINED) + set_loading(False) + set_error(e) + else: + set_data(returned) + set_loading(False) + set_error(None) + + # We need to run this in a thread so we don't prevent rendering when loading. + # I'm also hoping that Django is ok with this since this thread won't have an + # active event loop. + Thread(target=thread_target, daemon=True).start() + + return Query(data, loading, error, refetch) + + +class Query(NamedTuple, Generic[_Data]): + data: _Data + loading: bool + error: Exception | None + refetch: Callable[[], None] + + +def use_mutation( + mutate: Callable[_Params, None], + refetch: Callable[..., Any] | Sequence[Callable[..., Any]], +) -> Mutation[_Params]: + loading, set_loading = use_state(True) + error, set_error = use_state(None) + + @use_callback + def call(*args: _Params.args, **kwargs: _Params.kwargs) -> None: + set_loading(True) + + def thread_target(): + try: + mutate(*args, **kwargs) + except Exception as e: + set_loading(False) + set_error(e) + else: + set_loading(False) + set_error(None) + for query in (refetch,) if isinstance(refetch, Query) else refetch: + refetch_callback = _REFETCH_CALLBACKS.get(query) + if refetch_callback is not None: + refetch_callback() + + # We need to run this in a thread so we don't prevent rendering when loading. + # I'm also hoping that Django is ok with this since this thread won't have an + # active event loop. + Thread(target=thread_target, daemon=True).start() + + @use_callback + def reset() -> None: + set_loading(False) + set_error(None) + + return Query(call, loading, error, reset) + + +class Mutation(NamedTuple, Generic[_Params]): + execute: Callable[_Params, None] + loading: bool + error: Exception | None + reset: Callable[[], None] diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py index 81783013..54ceba01 100644 --- a/src/django_idom/utils.py +++ b/src/django_idom/utils.py @@ -133,3 +133,12 @@ def _register_components(self, components: Set) -> None: "\033[0m", component, ) + + +class _Undefined: + def __repr__(self): + return "UNDEFINED" + + +UNDEFINED = _Undefined() +"""Sentinel for undefined values""" From 86b65751d3f9c5f1c3e5cc9af2036158441ae306 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 14 Jul 2022 18:58:28 -0700 Subject: [PATCH 02/57] fetch deferred attrs --- src/django_idom/hooks.py | 45 ++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 79298517..5c128dc0 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -1,17 +1,13 @@ from __future__ import annotations -from dataclasses import dataclass from threading import Thread from types import FunctionType from typing import ( - Dict, Type, Union, Any, - Awaitable, Callable, DefaultDict, - Optional, Sequence, Type, Union, @@ -20,6 +16,8 @@ NamedTuple, ) +from django.db.models.base import Model +from django.db.models.query import QuerySet from typing_extensions import ParamSpec from idom import use_callback @@ -68,6 +66,7 @@ def use_websocket() -> IdomWebsocket: def use_query( query: Callable[_Params, _Data], *args: _Params.args, + fetch_deferred_fields: bool = True, **kwargs: _Params.kwargs, ) -> Query[_Data]: given_query = query @@ -99,15 +98,33 @@ def execute_query(): def thread_target(): try: - returned = query(*args, **kwargs) + query_result = query(*args, **kwargs) except Exception as e: set_data(UNDEFINED) set_loading(False) set_error(e) - else: - set_data(returned) - set_loading(False) - set_error(None) + return + + if isinstance(query_result, QuerySet): + if fetch_deferred_fields: + for model in query_result: + _fetch_deferred_fields(model) + else: + # still force query set to execute + for _ in query_result: + pass + elif isinstance(query_result, Model): + if fetch_deferred_fields: + _fetch_deferred_fields(query_result) + elif fetch_deferred_fields: + raise ValueError( + f"Expected {query} to return Model or Query because " + f"{fetch_deferred_fields=}, got {query_result!r}" + ) + + set_data(query_result) + set_loading(False) + set_error(None) # We need to run this in a thread so we don't prevent rendering when loading. # I'm also hoping that Django is ok with this since this thread won't have an @@ -167,3 +184,13 @@ class Mutation(NamedTuple, Generic[_Params]): loading: bool error: Exception | None reset: Callable[[], None] + + +_Model = TypeVar("_Model", bound=Model) + + +def _fetch_deferred_fields(model: _Model) -> _Model: + for field in model.get_deferred_fields(): + value = getattr(model, field) + if isinstance(value, Model): + _fetch_deferred_fields(value) From 780251cfcb188a3c8ad2b9d628b3cf3bb31a5ee8 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 14 Jul 2022 19:43:02 -0700 Subject: [PATCH 03/57] update comment --- src/django_idom/hooks.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 5c128dc0..89c06b1d 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -127,8 +127,7 @@ def thread_target(): set_error(None) # We need to run this in a thread so we don't prevent rendering when loading. - # I'm also hoping that Django is ok with this since this thread won't have an - # active event loop. + # We also can't do this async since Django's ORM doesn't support this yet. Thread(target=thread_target, daemon=True).start() return Query(data, loading, error, refetch) @@ -167,8 +166,7 @@ def thread_target(): refetch_callback() # We need to run this in a thread so we don't prevent rendering when loading. - # I'm also hoping that Django is ok with this since this thread won't have an - # active event loop. + # We also can't do this async since Django's ORM doesn't support this yet. Thread(target=thread_target, daemon=True).start() @use_callback From e465055eb1950b2f1e18aa53a10207d4ddbd51c9 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 14 Jul 2022 19:49:47 -0700 Subject: [PATCH 04/57] use dataclass instead of namedtuple --- src/django_idom/hooks.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 89c06b1d..81532a6f 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -1,4 +1,5 @@ from __future__ import annotations +from dataclasses import dataclass from threading import Thread from types import FunctionType @@ -13,7 +14,6 @@ Union, TypeVar, Generic, - NamedTuple, ) from django.db.models.base import Model @@ -133,7 +133,8 @@ def thread_target(): return Query(data, loading, error, refetch) -class Query(NamedTuple, Generic[_Data]): +@dataclass +class Query(Generic[_Data]): data: _Data loading: bool error: Exception | None @@ -177,7 +178,8 @@ def reset() -> None: return Query(call, loading, error, reset) -class Mutation(NamedTuple, Generic[_Params]): +@dataclass +class Mutation(Generic[_Params]): execute: Callable[_Params, None] loading: bool error: Exception | None From adc69b62da516ceae9e259519faac99d4860c6a0 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 14 Jul 2022 19:52:06 -0700 Subject: [PATCH 05/57] sort imports --- src/django_idom/hooks.py | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 81532a6f..882e20de 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -1,31 +1,19 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass from threading import Thread from types import FunctionType -from typing import ( - Type, - Union, - Any, - Callable, - DefaultDict, - Sequence, - Type, - Union, - TypeVar, - Generic, -) +from typing import Any, Callable, DefaultDict, Generic, Sequence, Type, TypeVar, Union from django.db.models.base import Model from django.db.models.query import QuerySet -from typing_extensions import ParamSpec from idom import use_callback - from idom.backend.types import Location -from idom.core.hooks import Context, create_context, use_context, use_state, use_effect -from django_idom.utils import UNDEFINED +from idom.core.hooks import Context, create_context, use_context, use_effect, use_state +from typing_extensions import ParamSpec from django_idom.types import IdomWebsocket +from django_idom.utils import UNDEFINED WebsocketContext: Type[Context[Union[IdomWebsocket, None]]] = create_context( From 84f833273b274131274040bc0ca2d7a7b6fdb05b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 25 Jul 2022 17:42:22 -0700 Subject: [PATCH 06/57] move undefined to types module --- src/django_idom/hooks.py | 3 +-- src/django_idom/types.py | 8 ++++++++ src/django_idom/utils.py | 6 ------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 882e20de..0f3d6d4f 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -12,8 +12,7 @@ from idom.core.hooks import Context, create_context, use_context, use_effect, use_state from typing_extensions import ParamSpec -from django_idom.types import IdomWebsocket -from django_idom.utils import UNDEFINED +from django_idom.types import UNDEFINED, IdomWebsocket WebsocketContext: Type[Context[Union[IdomWebsocket, None]]] = create_context( diff --git a/src/django_idom/types.py b/src/django_idom/types.py index 1b1fc7d4..1597f859 100644 --- a/src/django_idom/types.py +++ b/src/django_idom/types.py @@ -8,3 +8,11 @@ class IdomWebsocket: close: Callable[[Optional[int]], Awaitable[None]] disconnect: Callable[[int], Awaitable[None]] view_id: str + +class _Undefined: + def __repr__(self): + return "UNDEFINED" + + +UNDEFINED = _Undefined() +"""Sentinel for undefined values""" \ No newline at end of file diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py index 54ceba01..e89b9883 100644 --- a/src/django_idom/utils.py +++ b/src/django_idom/utils.py @@ -135,10 +135,4 @@ def _register_components(self, components: Set) -> None: ) -class _Undefined: - def __repr__(self): - return "UNDEFINED" - -UNDEFINED = _Undefined() -"""Sentinel for undefined values""" From e618868db1e70c095389157377813cd1b2bfc79a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 25 Jul 2022 17:43:45 -0700 Subject: [PATCH 07/57] formatting --- src/django_idom/types.py | 3 ++- src/django_idom/utils.py | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/django_idom/types.py b/src/django_idom/types.py index 1597f859..403f262f 100644 --- a/src/django_idom/types.py +++ b/src/django_idom/types.py @@ -9,10 +9,11 @@ class IdomWebsocket: disconnect: Callable[[int], Awaitable[None]] view_id: str + class _Undefined: def __repr__(self): return "UNDEFINED" UNDEFINED = _Undefined() -"""Sentinel for undefined values""" \ No newline at end of file +"""Sentinel for undefined values""" diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py index e89b9883..81783013 100644 --- a/src/django_idom/utils.py +++ b/src/django_idom/utils.py @@ -133,6 +133,3 @@ def _register_components(self, components: Set) -> None: "\033[0m", component, ) - - - From 2d8d9e9fbaee3c2b532e2ec0b081479fe0a82758 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 25 Jul 2022 17:57:26 -0700 Subject: [PATCH 08/57] attempt fix for checkout warning --- .github/workflows/publish-docs.yml | 4 +++- .github/workflows/publish-py.yml | 2 +- .github/workflows/test-docs.yml | 4 +++- .github/workflows/test-src.yml | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 0af1d0a3..1a41a24c 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -7,7 +7,9 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 + with: + fetch-depth: 0 - uses: actions/setup-python@v2 with: python-version: 3.x diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml index 4b4de80e..64064b9a 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-py.yml @@ -11,7 +11,7 @@ jobs: release-package: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: actions/setup-node@v2-beta with: node-version: "14.x" diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 967f7c8c..8664e883 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -14,7 +14,9 @@ jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 + with: + fetch-depth: 0 - uses: actions/setup-python@v2 with: python-version: 3.x diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index f5320f35..a7efd0ab 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -17,7 +17,7 @@ jobs: matrix: python-version: ["3.8", "3.9", "3.10"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: nanasess/setup-chromedriver@master - uses: actions/setup-node@v2-beta with: From 70e3c91adf5488c0c9ad54a2abc94abf6a1762a9 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 25 Jul 2022 17:57:48 -0700 Subject: [PATCH 09/57] fix docs typo --- docs/features/decorators.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/features/decorators.md b/docs/features/decorators.md index 17707eff..29a32d96 100644 --- a/docs/features/decorators.md +++ b/docs/features/decorators.md @@ -42,7 +42,6 @@ def my_component(): ```python title="components.py" from django_idom.decorators import auth_required - from django_idom.hooks import use_websocket from idom import component, html @component From f3b1d9ee7562e3398e9e49a1b6aa6a6c8687c2f3 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 25 Jul 2022 17:59:24 -0700 Subject: [PATCH 10/57] add to changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4f20e0e..5a4ca3b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ Using the following categories, list your changes in this order: ### Added - `auth_required` decorator to prevent your components from rendered to unauthenticated users. +- `use_query` hook for fetching database values. +- `use_mutation` hook for modifying database values. ## [1.1.0] - 2022-07-01 From f920ae612a9d15546d280c80215ade6b37363bd9 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 25 Jul 2022 18:01:13 -0700 Subject: [PATCH 11/57] change event function name --- docs/features/hooks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features/hooks.md b/docs/features/hooks.md index 9ca67f97..97955da4 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -39,13 +39,13 @@ def todo_list(): else: add_item_status = "" - def handle_add_item(event): + def click_event(event): set_item_draft("") add_item_mutation.execute(text=item_draft) return html.div( html.label("Add an item:") - html.input({"type": "text", "onClick": handle_add_item}) + html.input({"type": "text", "onClick": click_event}) add_item_status, items_view, ) From 2c2e4e150e10503985311cf025b4c28e82056153 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 26 Jul 2022 02:36:57 -0700 Subject: [PATCH 12/57] separate query/mutation docs --- docs/features/hooks.md | 144 +++++++++++++++++++++++++++++------------ docs/features/orm.md | 52 --------------- mkdocs.yml | 3 +- 3 files changed, 105 insertions(+), 94 deletions(-) delete mode 100644 docs/features/orm.md diff --git a/docs/features/hooks.md b/docs/features/hooks.md index 97955da4..7c208c3e 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -2,54 +2,116 @@ Check out the [IDOM Core docs](https://idom-docs.herokuapp.com/docs/reference/hooks-api.html?highlight=hooks) on hooks! -## Use Query and Use Mutation +## Use Query - +The `use_query` hook is used make ORM queries. -```python -from example_project.my_app.models import TodoItem -from idom import component, html -from django_idom.hooks import use_query, use_mutation +=== "components.py" + ```python + from example_project.my_app.models import TodoItem + from idom import component, html + from django_idom.hooks import use_query -def get_items(): - return TodoItem.objects.all() + def get_items(): + return TodoItem.objects.all() -def add_item(text: str): - TodoItem(text=text).save() + @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) -@component -def todo_list(): - items_query = use_query(get_items) - add_item_mutation = use_mutation(add_item, refetch=get_items) - item_draft, set_item_draft = use_state("") - - if items_query.loading: - items_view = html.h2("Loading...") - elif items_query.error: - items_view = html.h2(f"Error when loading: {items.error}") - else: - items_view = html.ul(html.li(item, key=item) for item in items_query.data) - - if add_item_mutation.loading: - add_item_status = html.h2("Adding...") - elif add_item_mutation.error: - add_item_status = html.h2(f"Error when adding: {add_item_mutation.error}") - else: - add_item_status = "" - - def click_event(event): - set_item_draft("") - add_item_mutation.execute(text=item_draft) - - return html.div( - html.label("Add an item:") - html.input({"type": "text", "onClick": click_event}) - add_item_status, - items_view, - ) -``` + return rendered_items + ``` + +=== "models.py" + + ```python + from django.db import models + + class TodoItem(models.Model): + text = models.CharField(max_length=255) + ``` + +??? 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 "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. + +## Use Mutation + +The `use_mutation` hook is used to modify ORM objects. + +=== "components.py" + + ```python + 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(): + item_mutation = use_mutation(add_item) + + if item_mutation.loading: + mutation_status = html.h2("Adding...") + elif item_mutation.error: + mutation_status = html.h2("Error when adding!") + else: + mutation_status = "" + + def submit_event(event): + # TODO: How do I get the text from this input? + if event["key"] == "Enter": + item_mutation.execute(text="Testing") + + return html.div( + html.label("Add an item:"), + html.input({"type": "text", "onKeyDown": submit_event}), + mutation_status, + ) + ``` + +=== "models.py" + + ```python + from django.db import models + + class TodoItem(models.Model): + text = models.CharField(max_length=255) + ``` + +??? question "`refetch`?" + + Need to figure out what refetch does before writing this question + +??? 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 "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. ## Use Websocket diff --git a/docs/features/orm.md b/docs/features/orm.md deleted file mode 100644 index 38454ca2..00000000 --- a/docs/features/orm.md +++ /dev/null @@ -1,52 +0,0 @@ -??? 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 2fcf788d..60ece595 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,7 +12,6 @@ nav: - Components: features/components.md - Hooks: features/hooks.md - Decorators: features/decorators.md - - ORM: features/orm.md - Template Tag: features/templatetag.md - Settings: features/settings.md - Contribute: @@ -52,6 +51,8 @@ markdown_extensions: - pymdownx.emoji: emoji_index: !!python/name:materialx.emoji.twemoji emoji_generator: !!python/name:materialx.emoji.to_svg + - pymdownx.tabbed: + alternate_style: true - pymdownx.highlight - pymdownx.superfences - pymdownx.details From 725995b79475a729eb9b28762b37afc0f224b3db Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 26 Jul 2022 02:53:17 -0700 Subject: [PATCH 13/57] enable link checking --- .github/workflows/test-docs.yml | 1 + requirements/build-docs.txt | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 8664e883..34395038 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -21,4 +21,5 @@ jobs: with: python-version: 3.x - run: pip install -r requirements/build-docs.txt + - run: linkcheckMarkdown docs/ -r - run: mkdocs build --verbose diff --git a/requirements/build-docs.txt b/requirements/build-docs.txt index ce3fba84..87761d74 100644 --- a/requirements/build-docs.txt +++ b/requirements/build-docs.txt @@ -1,4 +1,5 @@ mkdocs mkdocs-git-revision-date-localized-plugin mkdocs-material -mkdocs-include-markdown-plugin \ No newline at end of file +mkdocs-include-markdown-plugin +linkcheckmd \ No newline at end of file From 196c7ba46d6d50c7ffcdb7d5f586a9db09674157 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 26 Jul 2022 02:55:25 -0700 Subject: [PATCH 14/57] verbose link checking --- .github/workflows/test-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 34395038..b323d786 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -21,5 +21,5 @@ jobs: with: python-version: 3.x - run: pip install -r requirements/build-docs.txt - - run: linkcheckMarkdown docs/ -r + - run: linkcheckMarkdown docs/ -v -r - run: mkdocs build --verbose From 618071926f4109574927455b7c5397a17cc0bb24 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 26 Jul 2022 02:59:26 -0700 Subject: [PATCH 15/57] bump setup python version --- .github/workflows/publish-docs.yml | 2 +- .github/workflows/test-docs.yml | 2 +- .github/workflows/test-src.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 1a41a24c..d6339c6b 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v4 with: python-version: 3.x - run: pip install -r requirements/build-docs.txt diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index b323d786..228e4fca 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v4 with: python-version: 3.x - run: pip install -r requirements/build-docs.txt diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index a7efd0ab..0fb02e82 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -23,7 +23,7 @@ jobs: with: node-version: "14" - name: Use Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies From ae7c75405e9a4a3163138950f11236b23a916e60 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 26 Jul 2022 03:00:33 -0700 Subject: [PATCH 16/57] bump setup node --- .github/workflows/publish-py.yml | 2 +- .github/workflows/test-src.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml index 64064b9a..36eb51a4 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-py.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: actions/setup-node@v2-beta + - uses: actions/setup-node@v3 with: node-version: "14.x" - name: Set up Python diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index 0fb02e82..577d3289 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v3 - uses: nanasess/setup-chromedriver@master - - uses: actions/setup-node@v2-beta + - uses: actions/setup-node@v3 with: node-version: "14" - name: Use Python ${{ matrix.python-version }} From b1b15522366530461db6b59f1a7374465f46fa00 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 26 Jul 2022 03:08:21 -0700 Subject: [PATCH 17/57] fix task name --- .github/workflows/publish-py.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml index 36eb51a4..4439cb5e 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-py.yml @@ -19,7 +19,7 @@ jobs: uses: actions/setup-python@v1 with: python-version: "3.x" - - name: Install latest NPM + - name: Install NPM run: | npm install -g npm@7.22.0 npm --version From 01c427f438467a7d6edd2448a1c5925a1d4e84ba Mon Sep 17 00:00:00 2001 From: Mark <16909269+Archmonger@users.noreply.github.com> Date: Tue, 26 Jul 2022 12:38:44 -0700 Subject: [PATCH 18/57] Update src/django_idom/hooks.py Co-authored-by: Ryan Morshead --- src/django_idom/hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 0f3d6d4f..5360d2a3 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -148,7 +148,7 @@ def thread_target(): else: set_loading(False) set_error(None) - for query in (refetch,) if isinstance(refetch, Query) else refetch: + for query in (refetch,) if callable(refetch) else refetch: refetch_callback = _REFETCH_CALLBACKS.get(query) if refetch_callback is not None: refetch_callback() From da9f4ce3d981a27fcc80c01e84e61c74984db37e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 26 Jul 2022 15:25:40 -0700 Subject: [PATCH 19/57] Can `use_mutation` trigger refetch of a `use_query` --- docs/features/hooks.md | 48 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/docs/features/hooks.md b/docs/features/hooks.md index 7c208c3e..c6194cd5 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -97,9 +97,53 @@ The `use_mutation` hook is used to modify ORM objects. text = models.CharField(max_length=255) ``` -??? question "`refetch`?" +??? question "Can `use_mutation` trigger refetch of a `use_query`?" - Need to figure out what refetch does before writing this question + Yes, `use_mutation` can queue a refetch of a `use_query` via the `refetch=...` argument. + + In the example below, please note that any `use_query` hooks that use the `get_items` hook 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 + + def get_items(): + return TodoItem.objects.all() + + def add_item(text: str): + TodoItem(text=text).save() + + @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) + + 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 = "" + + def submit_event(event): + # TODO: How do I get the text from this input? + if event["key"] == "Enter": + item_mutation.execute(text="Testing") + + return html.div( + html.label("Add an item:"), + html.input({"type": "text", "onKeyDown": submit_event}), + mutation_status, + rendered_items, + ) + ``` ??? question "Can I make ORM calls without hooks?" From 08950d949be41729c64d162bb2fb47cdf174ac7e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 26 Jul 2022 15:34:45 -0700 Subject: [PATCH 20/57] wordsmith --- docs/features/hooks.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/features/hooks.md b/docs/features/hooks.md index c6194cd5..ded24623 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -4,7 +4,7 @@ ## Use Query -The `use_query` hook is used make ORM queries. +The `use_query` hook is used fetch Django ORM queries. === "components.py" @@ -53,7 +53,7 @@ The `use_query` hook is used make ORM queries. ## Use Mutation -The `use_mutation` hook is used to modify ORM objects. +The `use_mutation` hook is used to modify Django ORM objects. === "components.py" @@ -101,7 +101,9 @@ The `use_mutation` hook is used to modify ORM objects. Yes, `use_mutation` can queue a refetch of a `use_query` via the `refetch=...` argument. - In the example below, please note that any `use_query` hooks that use the `get_items` hook will be refetched upon a successful mutation. + 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 d67f9b490570799b717a38a6a87b9a35c22eeab1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 29 Jul 2022 05:36:15 -0700 Subject: [PATCH 21/57] event["target"]["value"] --- docs/features/hooks.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/features/hooks.md b/docs/features/hooks.md index ded24623..1a0986d3 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -77,9 +77,8 @@ The `use_mutation` hook is used to modify Django ORM objects. mutation_status = "" def submit_event(event): - # TODO: How do I get the text from this input? if event["key"] == "Enter": - item_mutation.execute(text="Testing") + item_mutation.execute(text=event["target"]["value"]) return html.div( html.label("Add an item:"), @@ -97,7 +96,7 @@ The `use_mutation` hook is used to modify Django ORM objects. text = models.CharField(max_length=255) ``` -??? question "Can `use_mutation` trigger refetch of a `use_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. @@ -135,9 +134,8 @@ The `use_mutation` hook is used to modify Django ORM objects. mutation_status = "" def submit_event(event): - # TODO: How do I get the text from this input? if event["key"] == "Enter": - item_mutation.execute(text="Testing") + item_mutation.execute(text=event["target"]["value"]) return html.div( html.label("Add an item:"), From 6cb01fa1a39ca0d5b36a6a15149e08ae71f13e41 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 29 Jul 2022 19:16:07 -0700 Subject: [PATCH 22/57] Ignore some type hints --- src/django_idom/hooks.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 5360d2a3..107010ee 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -57,7 +57,7 @@ def use_query( **kwargs: _Params.kwargs, ) -> Query[_Data]: given_query = query - query, _ = use_state(given_query) + query, _ = use_state(given_query) # type: ignore if given_query is not query: raise ValueError(f"Query function changed from {query} to {given_query}.") @@ -75,8 +75,8 @@ def refetch() -> None: def add_refetch_callback(): # By tracking callbacks globally, any usage of the query function will be re-run # if the user has told a mutation to refetch it. - _REFETCH_CALLBACKS[query].add(refetch) - return lambda: _REFETCH_CALLBACKS[query].remove(refetch) + _REFETCH_CALLBACKS[query].add(refetch) # type: ignore + return lambda: _REFETCH_CALLBACKS[query].remove(refetch) # type: ignore @use_effect(dependencies=None) def execute_query(): @@ -84,15 +84,16 @@ def execute_query(): return def thread_target(): + # sourcery skip: remove-empty-nested-block, remove-redundant-pass try: query_result = query(*args, **kwargs) except Exception as e: set_data(UNDEFINED) set_loading(False) - set_error(e) + set_error(e) # type: ignore return - if isinstance(query_result, QuerySet): + if isinstance(query_result, QuerySet): # type: ignore if fetch_deferred_fields: for model in query_result: _fetch_deferred_fields(model) @@ -109,7 +110,7 @@ def thread_target(): f"{fetch_deferred_fields=}, got {query_result!r}" ) - set_data(query_result) + set_data(query_result) # type: ignore set_loading(False) set_error(None) @@ -117,7 +118,7 @@ def thread_target(): # We also can't do this async since Django's ORM doesn't support this yet. Thread(target=thread_target, daemon=True).start() - return Query(data, loading, error, refetch) + return Query(data, loading, error, refetch) # type: ignore @dataclass @@ -144,14 +145,14 @@ def thread_target(): mutate(*args, **kwargs) except Exception as e: set_loading(False) - set_error(e) + set_error(e) # type: ignore else: set_loading(False) set_error(None) for query in (refetch,) if callable(refetch) else refetch: - refetch_callback = _REFETCH_CALLBACKS.get(query) + refetch_callback = _REFETCH_CALLBACKS.get(query) # type: ignore if refetch_callback is not None: - refetch_callback() + refetch_callback() # type: ignore # We need to run this in a thread so we don't prevent rendering when loading. # We also can't do this async since Django's ORM doesn't support this yet. @@ -162,7 +163,7 @@ def reset() -> None: set_loading(False) set_error(None) - return Query(call, loading, error, reset) + return Query(call, loading, error, reset) # type: ignore @dataclass @@ -173,10 +174,7 @@ class Mutation(Generic[_Params]): reset: Callable[[], None] -_Model = TypeVar("_Model", bound=Model) - - -def _fetch_deferred_fields(model: _Model) -> _Model: +def _fetch_deferred_fields(model): for field in model.get_deferred_fields(): value = getattr(model, field) if isinstance(value, Model): From 13177012453b11dd5116c5ca8b1d5821e5a7663e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 29 Jul 2022 19:16:20 -0700 Subject: [PATCH 23/57] add ORM clarification --- docs/features/hooks.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/features/hooks.md b/docs/features/hooks.md index 1a0986d3..1951f43d 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -151,6 +151,8 @@ The `use_mutation` hook is used to modify Django ORM objects. This may be resolved in a future version of Django with a natively asynchronous ORM. + However, even when resolved it is best practice to perform ORM queries within the `use_query` in order to handle `loading` and `error` states. + ??? question "What is an "ORM"?" A Python **Object Relational Mapper** is an API for your code to access a database. From 308b5d1f57b8181320ec83551f22a8b3eb8189d7 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 29 Jul 2022 23:39:01 -0700 Subject: [PATCH 24/57] misc fixes + remove fetch_deferred_fields --- src/django_idom/hooks.py | 96 +++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 50 deletions(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 107010ee..83493ed4 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -2,17 +2,26 @@ from dataclasses import dataclass from threading import Thread -from types import FunctionType -from typing import Any, Callable, DefaultDict, Generic, Sequence, Type, TypeVar, Union +from typing import ( + Any, + Callable, + DefaultDict, + Generic, + Sequence, + Type, + TypeVar, + Union, + cast, +) from django.db.models.base import Model from django.db.models.query import QuerySet -from idom import use_callback +from idom import use_callback, use_ref from idom.backend.types import Location from idom.core.hooks import Context, create_context, use_context, use_effect, use_state from typing_extensions import ParamSpec -from django_idom.types import UNDEFINED, IdomWebsocket +from django_idom.types import IdomWebsocket WebsocketContext: Type[Context[Union[IdomWebsocket, None]]] = create_context( @@ -41,76 +50,61 @@ def use_websocket() -> IdomWebsocket: return websocket -_REFETCH_CALLBACKS: DefaultDict[FunctionType, set[Callable[[], None]]] = DefaultDict( - set -) +_REFETCH_CALLBACKS: DefaultDict[ + Callable[..., Any], set[Callable[[], None]] +] = DefaultDict(set) -_Data = TypeVar("_Data") +_Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]]) _Params = ParamSpec("_Params") def use_query( - query: Callable[_Params, _Data], + query: Callable[_Params, _Result | None], *args: _Params.args, - fetch_deferred_fields: bool = True, **kwargs: _Params.kwargs, -) -> Query[_Data]: - given_query = query - query, _ = use_state(given_query) # type: ignore - if given_query is not query: - raise ValueError(f"Query function changed from {query} to {given_query}.") +) -> Query[_Result | None]: + query_ref = use_ref(query) + if query_ref.current is not query: + raise ValueError(f"Query function changed from {query_ref.current} to {query}.") - data, set_data = use_state(UNDEFINED) + should_execute, set_should_execute = use_state(True) + data, set_data = use_state(cast(Union[_Result, None], None)) loading, set_loading = use_state(True) - error, set_error = use_state(None) + error, set_error = use_state(cast(Union[Exception, None], None)) @use_callback def refetch() -> None: - set_data(UNDEFINED) + set_should_execute(True) + set_data(None) set_loading(True) set_error(None) @use_effect(dependencies=[]) - def add_refetch_callback(): + def add_refetch_callback() -> Callable[[], None]: # By tracking callbacks globally, any usage of the query function will be re-run # if the user has told a mutation to refetch it. - _REFETCH_CALLBACKS[query].add(refetch) # type: ignore - return lambda: _REFETCH_CALLBACKS[query].remove(refetch) # type: ignore + _REFETCH_CALLBACKS[query].add(refetch) + return lambda: _REFETCH_CALLBACKS[query].remove(refetch) @use_effect(dependencies=None) - def execute_query(): - if data is not UNDEFINED: + def execute_query() -> None: + if not should_execute: return - def thread_target(): + def thread_target() -> None: # sourcery skip: remove-empty-nested-block, remove-redundant-pass try: query_result = query(*args, **kwargs) except Exception as e: - set_data(UNDEFINED) + set_data(None) set_loading(False) - set_error(e) # type: ignore + set_error(e) return + finally: + set_should_execute(False) - if isinstance(query_result, QuerySet): # type: ignore - if fetch_deferred_fields: - for model in query_result: - _fetch_deferred_fields(model) - else: - # still force query set to execute - for _ in query_result: - pass - elif isinstance(query_result, Model): - if fetch_deferred_fields: - _fetch_deferred_fields(query_result) - elif fetch_deferred_fields: - raise ValueError( - f"Expected {query} to return Model or Query because " - f"{fetch_deferred_fields=}, got {query_result!r}" - ) - - set_data(query_result) # type: ignore + set_data(query_result) set_loading(False) set_error(None) @@ -118,7 +112,10 @@ def thread_target(): # We also can't do this async since Django's ORM doesn't support this yet. Thread(target=thread_target, daemon=True).start() - return Query(data, loading, error, refetch) # type: ignore + return Query(data, loading, error, refetch) + + +_Data = TypeVar("_Data") @dataclass @@ -140,7 +137,7 @@ def use_mutation( def call(*args: _Params.args, **kwargs: _Params.kwargs) -> None: set_loading(True) - def thread_target(): + def thread_target() -> None: try: mutate(*args, **kwargs) except Exception as e: @@ -150,9 +147,8 @@ def thread_target(): set_loading(False) set_error(None) for query in (refetch,) if callable(refetch) else refetch: - refetch_callback = _REFETCH_CALLBACKS.get(query) # type: ignore - if refetch_callback is not None: - refetch_callback() # type: ignore + for callback in _REFETCH_CALLBACKS.get(query) or (): + callback() # We need to run this in a thread so we don't prevent rendering when loading. # We also can't do this async since Django's ORM doesn't support this yet. @@ -174,7 +170,7 @@ class Mutation(Generic[_Params]): reset: Callable[[], None] -def _fetch_deferred_fields(model): +def _fetch_deferred_fields(model: Any) -> None: for field in model.get_deferred_fields(): value = getattr(model, field) if isinstance(value, Model): From 7327106c0562b92b0acc1d9fd660e46ada0705e9 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 30 Jul 2022 01:15:10 -0700 Subject: [PATCH 25/57] remove unused code --- src/django_idom/hooks.py | 1 - src/django_idom/types.py | 9 --------- 2 files changed, 10 deletions(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 83493ed4..6700cc4e 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -93,7 +93,6 @@ def execute_query() -> None: return def thread_target() -> None: - # sourcery skip: remove-empty-nested-block, remove-redundant-pass try: query_result = query(*args, **kwargs) except Exception as e: diff --git a/src/django_idom/types.py b/src/django_idom/types.py index 403f262f..1b1fc7d4 100644 --- a/src/django_idom/types.py +++ b/src/django_idom/types.py @@ -8,12 +8,3 @@ class IdomWebsocket: close: Callable[[Optional[int]], Awaitable[None]] disconnect: Callable[[int], Awaitable[None]] view_id: str - - -class _Undefined: - def __repr__(self): - return "UNDEFINED" - - -UNDEFINED = _Undefined() -"""Sentinel for undefined values""" From 668ffc342bf933c4988ef171bd3e25e71ecb2605 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 30 Jul 2022 02:34:50 -0700 Subject: [PATCH 26/57] More typehint cleanup --- src/django_idom/hooks.py | 40 +++++++++++++++++----------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 6700cc4e..81666e19 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -24,9 +24,15 @@ from django_idom.types import IdomWebsocket +_REFETCH_CALLBACKS: DefaultDict[ + Callable[..., Any], set[Callable[[], None]] +] = DefaultDict(set) WebsocketContext: Type[Context[Union[IdomWebsocket, None]]] = create_context( None, "WebSocketContext" ) +_Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]]) +_Params = ParamSpec("_Params") +_Data = TypeVar("_Data") def use_location() -> Location: @@ -50,15 +56,6 @@ def use_websocket() -> IdomWebsocket: return websocket -_REFETCH_CALLBACKS: DefaultDict[ - Callable[..., Any], set[Callable[[], None]] -] = DefaultDict(set) - - -_Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]]) -_Params = ParamSpec("_Params") - - def use_query( query: Callable[_Params, _Result | None], *args: _Params.args, @@ -114,23 +111,12 @@ def thread_target() -> None: return Query(data, loading, error, refetch) -_Data = TypeVar("_Data") - - -@dataclass -class Query(Generic[_Data]): - data: _Data - loading: bool - error: Exception | None - refetch: Callable[[], None] - - def use_mutation( mutate: Callable[_Params, None], refetch: Callable[..., Any] | Sequence[Callable[..., Any]], ) -> Mutation[_Params]: loading, set_loading = use_state(True) - error, set_error = use_state(None) + error, set_error = use_state(cast(Union[Exception, None], None)) @use_callback def call(*args: _Params.args, **kwargs: _Params.kwargs) -> None: @@ -141,7 +127,7 @@ def thread_target() -> None: mutate(*args, **kwargs) except Exception as e: set_loading(False) - set_error(e) # type: ignore + set_error(e) else: set_loading(False) set_error(None) @@ -158,7 +144,15 @@ def reset() -> None: set_loading(False) set_error(None) - return Query(call, loading, error, reset) # type: ignore + return Mutation(call, loading, error, reset) + + +@dataclass +class Query(Generic[_Data]): + data: _Data + loading: bool + error: Exception | None + refetch: Callable[[], None] @dataclass From 4c3de354fe6d196a59fbeb7952ef21c91d367cd2 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Mon, 1 Aug 2022 20:40:45 -0700 Subject: [PATCH 27/57] put deferred fetch back --- pyproject.toml | 6 ++++ src/django_idom/hooks.py | 60 +++++++++++++++++++++++++--------------- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 18a77f5a..0103531f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,3 +10,9 @@ ensure_newline_before_comments = "True" include_trailing_comma = "True" line_length = 88 lines_after_imports = 2 + +[tool.mypy] +ignore_missing_imports = true +warn_unused_configs = true +warn_redundant_casts = true +warn_unused_ignores = true diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 81666e19..76255f91 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -1,9 +1,10 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass -from threading import Thread from typing import ( Any, + Awaitable, Callable, DefaultDict, Generic, @@ -14,6 +15,7 @@ cast, ) +from channels.db import database_sync_to_async as _database_sync_to_async from django.db.models.base import Model from django.db.models.query import QuerySet from idom import use_callback, use_ref @@ -24,6 +26,11 @@ from django_idom.types import IdomWebsocket +database_sync_to_async = cast( + Callable[..., Callable[..., Awaitable[Any]]], + _database_sync_to_async, +) + _REFETCH_CALLBACKS: DefaultDict[ Callable[..., Any], set[Callable[[], None]] ] = DefaultDict(set) @@ -85,28 +92,25 @@ def add_refetch_callback() -> Callable[[], None]: return lambda: _REFETCH_CALLBACKS[query].remove(refetch) @use_effect(dependencies=None) + @database_sync_to_async def execute_query() -> None: if not should_execute: return - def thread_target() -> None: - try: - query_result = query(*args, **kwargs) - except Exception as e: - set_data(None) - set_loading(False) - set_error(e) - return - finally: - set_should_execute(False) - - set_data(query_result) + try: + new_data = query(*args, **kwargs) + _fetch_deferred(new_data) + except Exception as e: + set_data(None) set_loading(False) - set_error(None) + set_error(e) + return + finally: + set_should_execute(False) - # We need to run this in a thread so we don't prevent rendering when loading. - # We also can't do this async since Django's ORM doesn't support this yet. - Thread(target=thread_target, daemon=True).start() + set_data(new_data) + set_loading(False) + set_error(None) return Query(data, loading, error, refetch) @@ -122,7 +126,8 @@ def use_mutation( def call(*args: _Params.args, **kwargs: _Params.kwargs) -> None: set_loading(True) - def thread_target() -> None: + @database_sync_to_async + def execute_mutation() -> None: try: mutate(*args, **kwargs) except Exception as e: @@ -135,9 +140,7 @@ def thread_target() -> None: for callback in _REFETCH_CALLBACKS.get(query) or (): callback() - # We need to run this in a thread so we don't prevent rendering when loading. - # We also can't do this async since Django's ORM doesn't support this yet. - Thread(target=thread_target, daemon=True).start() + asyncio.ensure_future(execute_mutation()) @use_callback def reset() -> None: @@ -163,8 +166,19 @@ class Mutation(Generic[_Params]): reset: Callable[[], None] -def _fetch_deferred_fields(model: Any) -> None: +def _fetch_deferred(data: Any) -> None: + # https://github.com/typeddjango/django-stubs/issues/704 + if isinstance(data, QuerySet): # type: ignore[misc] + for model in data: + _fetch_deferred_model_fields(model) + elif isinstance(data, Model): + _fetch_deferred_model_fields(data) + 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_fields(value) + _fetch_deferred_model_fields(value) From 8f730203f8c5fde5498fa4a0a0f10527986357d7 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Mon, 1 Aug 2022 22:55:11 -0700 Subject: [PATCH 28/57] switch from selenium to playwright --- noxfile.py | 7 +- requirements/test-env.txt | 2 +- src/django_idom/hooks.py | 2 +- tests/test_app/components.py | 114 +++++++++++++++++----- tests/test_app/models.py | 6 ++ tests/test_app/static/django-css-test.css | 2 +- tests/test_app/templates/base.html | 1 + tests/test_app/tests/test_components.py | 106 +++++++++----------- 8 files changed, 147 insertions(+), 93 deletions(-) create mode 100644 tests/test_app/models.py diff --git a/noxfile.py b/noxfile.py index b28665df..fff6fc23 100644 --- a/noxfile.py +++ b/noxfile.py @@ -50,13 +50,14 @@ def test_suite(session: Session) -> None: session.env["IDOM_DEBUG_MODE"] = "1" posargs = session.posargs[:] - if "--headless" in posargs: - posargs.remove("--headless") - session.env["SELENIUM_HEADLESS"] = "1" + if "--headed" in posargs: + posargs.remove("--headed") + session.env["PLAYWRIGHT_HEADED"] = "1" if "--no-debug-mode" not in posargs: posargs.append("--debug-mode") + session.run("playwright", "install", "chromium") session.run("python", "manage.py", "test", *posargs) diff --git a/requirements/test-env.txt b/requirements/test-env.txt index 61ee65ee..32187f96 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -1,3 +1,3 @@ django -selenium <= 4.2.0 +playwright twisted diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 76255f91..66444624 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -119,7 +119,7 @@ def use_mutation( mutate: Callable[_Params, None], refetch: Callable[..., Any] | Sequence[Callable[..., Any]], ) -> Mutation[_Params]: - loading, set_loading = use_state(True) + loading, set_loading = use_state(False) error, set_error = use_state(cast(Union[Exception, None], None)) @use_callback diff --git a/tests/test_app/components.py b/tests/test_app/components.py index f9c9cb90..7a8a9334 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -1,22 +1,25 @@ import idom +from idom import html import django_idom +from django_idom.hooks import use_mutation, use_query +from test_app.models import TodoItem @idom.component def hello_world(): - return idom.html.h1({"id": "hello-world"}, "Hello World!") + return html.h1({"id": "hello-world"}, "Hello World!") @idom.component def button(): count, set_count = idom.hooks.use_state(0) - return idom.html.div( - idom.html.button( + return html.div( + html.button( {"id": "counter-inc", "onClick": lambda event: set_count(count + 1)}, "Click me!", ), - idom.html.p( + html.p( {"id": "counter-num", "data-count": count}, f"Current count is: {count}", ), @@ -26,7 +29,7 @@ def button(): @idom.component def parameterized_component(x, y): total = x + y - return idom.html.h1({"id": "parametrized-component", "data-value": total}, total) + return html.h1({"id": "parametrized-component", "data-value": total}, total) victory = idom.web.module_from_template("react", "victory-bar", fallback="...") @@ -43,11 +46,11 @@ 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(), + html.hr(), f"use_websocket: {ws}", - idom.html.hr(), + html.hr(), ) @@ -55,10 +58,10 @@ def use_websocket(): 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(), ) @@ -66,65 +69,122 @@ def use_scope(): 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 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 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 @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 @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(), + ) + + +def get_items(): + return TodoItem.objects.all().order_by("done") + + +def add_item(text: str): + TodoItem(text=text, done=False).save() + + +def toggle_item(item: TodoItem): + item.done = not item.done + item.save() + + +@idom.component +def todo_list(): + get_item_query = use_query(get_items) + add_item_mutation = use_mutation(add_item, refetch=get_items) + toggle_item_mutation = use_mutation(toggle_item, refetch=get_items) + + if get_item_query.loading: + rendered_items = html.h2("Loading...") + elif get_item_query.error: + rendered_items = html.h2(f"Error when loading - {get_item_query.error}") + else: + rendered_items = html.ul( + html.li( + item, + html.button( + { + "type": "checkbox", + "onClick": lambda event: toggle_item_mutation.execute(item), + } + ), + key=item, + ) + for item in reversed(get_item_query.data) + ) + + if add_item_mutation.loading: + mutation_status = html.h2("Adding...") + elif add_item_mutation.error: + mutation_status = html.h2(f"Error when adding - {add_item_mutation.error}") + else: + mutation_status = "" + + def submit_event(event): + if event["key"] == "Enter": + add_item_mutation.execute(text=event["target"]["value"]) + + return html.div( + html.label("Add an item:"), + html.input({"type": "text", "onKeyDown": submit_event}), + mutation_status, + rendered_items, ) diff --git a/tests/test_app/models.py b/tests/test_app/models.py new file mode 100644 index 00000000..93d3efd2 --- /dev/null +++ b/tests/test_app/models.py @@ -0,0 +1,6 @@ +from django.db import models + + +class TodoItem(models.Model): + done = models.BooleanField() + text = models.CharField(max_length=1000) diff --git a/tests/test_app/static/django-css-test.css b/tests/test_app/static/django-css-test.css index af68e6ed..41f98461 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: rgba(0, 0, 255, 1); + color: rgb(0, 0, 255); } diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index 28c8a815..b22f0bd1 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -23,6 +23,7 @@

IDOM Test Page

{% component "test_app.components.django_js" %}
{% component "test_app.components.unauthorized_user" %}
{% component "test_app.components.authorized_user" %}
+
{% component "test_app.components.todo_list" %}
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 7b1f0518..fe4569c2 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,98 +1,84 @@ +from multiprocessing.sharedctypes import Value import os import sys +from django.test import TestCase from channels.testing import ChannelsLiveServerTestCase -from selenium import webdriver -from selenium.common.exceptions import NoSuchElementException -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions -from selenium.webdriver.support.wait import WebDriverWait - +from playwright.sync_api import TimeoutError, sync_playwright # These tests are broken on Windows due to Selenium if sys.platform != "win32": - class TestIdomCapabilities(ChannelsLiveServerTestCase): - def setUp(self): - self.driver = make_driver(5, 5) - self.driver.get(self.live_server_url) - - def tearDown(self) -> None: - self.driver.quit() + class TestIdomCapabilities(ChannelsLiveServerTestCase, TestCase): + @classmethod + def setUpClass(cls): + os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" + super().setUpClass() + cls.playwright = sync_playwright().start() + headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", 1))) + cls.browser = cls.playwright.chromium.launch(headless=not headed) + cls.page = cls.browser.new_page() + cls.page.set_default_timeout(10000) + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + cls.page.close() + cls.browser.close() + cls.playwright.stop() - def wait(self, timeout=10): - return WebDriverWait(self.driver, timeout) - - def wait_until(self, condition, timeout=10): - return self.wait(timeout).until(lambda driver: condition()) + def setUp(self): + super().setUp() + self.page.goto(self.live_server_url) def test_hello_world(self): - self.driver.find_element_by_id("hello-world") + self.page.wait_for_selector("#hello-world") def test_counter(self): - button = self.driver.find_element_by_id("counter-inc") - count = self.driver.find_element_by_id("counter-num") - for i in range(5): - self.wait_until(lambda: count.get_attribute("data-count") == str(i)) - button.click() + self.page.locator(f"#counter-num[data-count={i}]") + self.page.locator("#counter-inc").click() def test_parametrized_component(self): - element = self.driver.find_element_by_id("parametrized-component") - self.assertEqual(element.get_attribute("data-value"), "579") + self.page.locator("#parametrized-component[data-value='579']").wait_for() def test_component_from_web_module(self): - self.wait(20).until( - expected_conditions.visibility_of_element_located( - (By.CLASS_NAME, "VictoryContainer") - ) - ) + self.page.wait_for_selector(".VictoryContainer") def test_use_websocket(self): - element = self.driver.find_element_by_id("use-websocket") - self.assertEqual(element.get_attribute("data-success"), "true") + self.page.locator("#use-websocket[data-success=true]").wait_for() def test_use_scope(self): - element = self.driver.find_element_by_id("use-scope") - self.assertEqual(element.get_attribute("data-success"), "true") + self.page.locator("#use-scope[data-success=true]").wait_for() def test_use_location(self): - element = self.driver.find_element_by_id("use-location") - self.assertEqual(element.get_attribute("data-success"), "true") + self.page.locator("#use-location[data-success=true]").wait_for() def test_static_css(self): - element = self.driver.find_element_by_css_selector("#django-css button") self.assertEqual( - element.value_of_css_property("color"), "rgba(0, 0, 255, 1)" + self.page.wait_for_selector("#django-css button").evaluate( + "e => window.getComputedStyle(e).getPropertyValue('color')" + ), + "rgb(0, 0, 255)", ) def test_static_js(self): - element = self.driver.find_element_by_id("django-js") - self.assertEqual(element.get_attribute("data-success"), "true") + self.page.locator("#django-js[data-success=true]").wait_for() def test_unauthorized_user(self): self.assertRaises( - NoSuchElementException, - self.driver.find_element_by_id, - "unauthorized-user", + TimeoutError, + self.page.wait_for_selector, + "#unauthorized-user", + timeout=1, ) - element = self.driver.find_element_by_id("unauthorized-user-fallback") - self.assertIsNotNone(element) + self.page.wait_for_selector("#unauthorized-user-fallback") def test_authorized_user(self): self.assertRaises( - NoSuchElementException, - self.driver.find_element_by_id, - "authorized-user-fallback", + TimeoutError, + self.page.wait_for_selector, + "#authorized-user-fallback", + timeout=1, ) - element = self.driver.find_element_by_id("authorized-user") - self.assertIsNotNone(element) - - -def make_driver(page_load_timeout, implicit_wait_timeout): - options = webdriver.ChromeOptions() - options.headless = bool(int(os.environ.get("SELENIUM_HEADLESS", 0))) - driver = webdriver.Chrome(options=options) - driver.set_page_load_timeout(page_load_timeout) - driver.implicitly_wait(implicit_wait_timeout) - return driver + self.page.wait_for_selector("#authorized-user") From 7b6ced358d0082def217163637689e76a5bd1041 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 2 Aug 2022 00:39:01 -0700 Subject: [PATCH 29/57] add basic test --- .github/workflows/test-src.yml | 2 +- src/django_idom/hooks.py | 12 +++---- src/django_idom/websocket/consumer.py | 4 ++- tests/test_app/components.py | 43 +++++++++++++++-------- tests/test_app/migrations/0001_initial.py | 22 ++++++++++++ tests/test_app/tests/test_components.py | 17 +++++++-- 6 files changed, 74 insertions(+), 26 deletions(-) create mode 100644 tests/test_app/migrations/0001_initial.py diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index 577d3289..16c6cd5d 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -32,4 +32,4 @@ jobs: run: | npm install -g npm@latest npm --version - nox -s test -- --headless + nox -s test diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 66444624..2f9d4fec 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -80,7 +80,6 @@ def use_query( @use_callback def refetch() -> None: set_should_execute(True) - set_data(None) set_loading(True) set_error(None) @@ -116,7 +115,7 @@ def execute_query() -> None: def use_mutation( - mutate: Callable[_Params, None], + mutate: Callable[_Params, bool | None], refetch: Callable[..., Any] | Sequence[Callable[..., Any]], ) -> Mutation[_Params]: loading, set_loading = use_state(False) @@ -129,16 +128,17 @@ def call(*args: _Params.args, **kwargs: _Params.kwargs) -> None: @database_sync_to_async def execute_mutation() -> None: try: - mutate(*args, **kwargs) + should_refetch = mutate(*args, **kwargs) except Exception as e: set_loading(False) set_error(e) else: set_loading(False) set_error(None) - for query in (refetch,) if callable(refetch) else refetch: - for callback in _REFETCH_CALLBACKS.get(query) or (): - callback() + if should_refetch is not False: + for query in (refetch,) if callable(refetch) else refetch: + for callback in _REFETCH_CALLBACKS.get(query) or (): + callback() asyncio.ensure_future(execute_mutation()) diff --git a/src/django_idom/websocket/consumer.py b/src/django_idom/websocket/consumer.py index cdc3eb9f..dfb3af34 100644 --- a/src/django_idom/websocket/consumer.py +++ b/src/django_idom/websocket/consumer.py @@ -5,7 +5,6 @@ from typing import Any from urllib.parse import parse_qsl -from channels.auth import login from channels.db import database_sync_to_async as convert_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from idom.core.layout import Layout, LayoutEvent @@ -23,6 +22,9 @@ class IdomAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer): """Communicates with the browser to perform actions on-demand.""" async def connect(self) -> None: + # this triggers AppRegistryNotReady exception in manage.py if at root level + from channels.auth import login + await super().connect() user = self.scope.get("user") diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 7a8a9334..9eb15921 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -134,11 +134,19 @@ def authorized_user(): def get_items(): - return TodoItem.objects.all().order_by("done") + return TodoItem.objects.filter(done=False).order_by("done") def add_item(text: str): - TodoItem(text=text, done=False).save() + existing = TodoItem.objects.filter(text=text).first() + if existing: + if existing.done: + existing.done = False + existing.save() + else: + return False + else: + TodoItem(text=text, done=False).save() def toggle_item(item: TodoItem): @@ -152,23 +160,28 @@ def todo_list(): add_item_mutation = use_mutation(add_item, refetch=get_items) toggle_item_mutation = use_mutation(toggle_item, refetch=get_items) - if get_item_query.loading: + if get_item_query.data is None: rendered_items = html.h2("Loading...") elif get_item_query.error: rendered_items = html.h2(f"Error when loading - {get_item_query.error}") else: rendered_items = html.ul( - html.li( - item, - html.button( - { - "type": "checkbox", - "onClick": lambda event: toggle_item_mutation.execute(item), - } - ), - key=item, - ) - for item in reversed(get_item_query.data) + [ + html.li( + {"id": f"todo-item-{item.text}"}, + item.text, + html.input( + { + "id": f"todo-item-{item.text}-checkbox", + "type": "checkbox", + "checked": item.done, + "onClick": lambda event: toggle_item_mutation.execute(item), + } + ), + key=item.text, + ) + for item in reversed(get_item_query.data) + ] ) if add_item_mutation.loading: @@ -184,7 +197,7 @@ def submit_event(event): return html.div( html.label("Add an item:"), - html.input({"type": "text", "onKeyDown": submit_event}), + html.input({"type": "text", "id": "todo-input", "onKeyDown": submit_event}), mutation_status, rendered_items, ) diff --git a/tests/test_app/migrations/0001_initial.py b/tests/test_app/migrations/0001_initial.py new file mode 100644 index 00000000..e05fedd6 --- /dev/null +++ b/tests/test_app/migrations/0001_initial.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.5 on 2022-08-02 06:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='TodoItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('done', models.BooleanField()), + ('text', models.CharField(max_length=1000)), + ], + ), + ] diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index fe4569c2..6ab61c6b 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,4 +1,3 @@ -from multiprocessing.sharedctypes import Value import os import sys @@ -9,7 +8,7 @@ # These tests are broken on Windows due to Selenium if sys.platform != "win32": - class TestIdomCapabilities(ChannelsLiveServerTestCase, TestCase): + class TestIdomCapabilities(TestCase, ChannelsLiveServerTestCase): @classmethod def setUpClass(cls): os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" @@ -18,7 +17,6 @@ def setUpClass(cls): headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", 1))) cls.browser = cls.playwright.chromium.launch(headless=not headed) cls.page = cls.browser.new_page() - cls.page.set_default_timeout(10000) @classmethod def tearDownClass(cls): @@ -82,3 +80,16 @@ def test_authorized_user(self): timeout=1, ) self.page.wait_for_selector("#authorized-user") + + def test_use_query_and_mutation(self): + todo_input = self.page.wait_for_selector("#todo-input") + todo_input.type("sample-1") + todo_input.press("Enter") + self.page.wait_for_selector("#todo-item-sample-1") + self.page.wait_for_selector("#todo-item-sample-1-checkbox").click() + self.assertRaises( + TimeoutError, + self.page.wait_for_selector, + "#todo-item-sample-1", + timeout=1, + ) From 0b5c1fd0a21687088e6d744a5fa098e619c8d660 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 2 Aug 2022 00:58:06 -0700 Subject: [PATCH 30/57] fix style --- noxfile.py | 5 ++--- tests/test_app/components.py | 2 +- tests/test_app/tests/test_components.py | 3 ++- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/noxfile.py b/noxfile.py index fff6fc23..4c4effa2 100644 --- a/noxfile.py +++ b/noxfile.py @@ -66,13 +66,12 @@ def test_style(session: Session) -> None: """Check that style guidelines are being followed""" install_requirements_file(session, "check-style") session.run("flake8", "src/django_idom", "tests") - black_default_exclude = r"\.eggs|\.git|\.hg|\.mypy_cache|\.nox|\.tox|\.venv|\.svn|_build|buck-out|build|dist" session.run( "black", ".", "--check", - "--exclude", - rf"/({black_default_exclude}|venv|node_modules)/", + "--extend-exclude", + rf"/migrations/", ) session.run("isort", ".", "--check-only") diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 9eb15921..788742e7 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -1,9 +1,9 @@ import idom from idom import html +from test_app.models import TodoItem import django_idom from django_idom.hooks import use_mutation, use_query -from test_app.models import TodoItem @idom.component diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 6ab61c6b..3d2de95d 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,10 +1,11 @@ import os import sys -from django.test import TestCase from channels.testing import ChannelsLiveServerTestCase +from django.test import TestCase from playwright.sync_api import TimeoutError, sync_playwright + # These tests are broken on Windows due to Selenium if sys.platform != "win32": From 02936e3bf64a168963bd05449d2a72f2d3eef397 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 2 Aug 2022 01:00:11 -0700 Subject: [PATCH 31/57] headless by default --- tests/test_app/tests/test_components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 3d2de95d..e20d0839 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -15,7 +15,7 @@ def setUpClass(cls): os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" super().setUpClass() cls.playwright = sync_playwright().start() - headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", 1))) + headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", 0))) cls.browser = cls.playwright.chromium.launch(headless=not headed) cls.page = cls.browser.new_page() From f437106ba57543c4a7f0ffd496168c9118b2803b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 2 Aug 2022 01:11:38 -0700 Subject: [PATCH 32/57] increase DB timeout --- tests/test_app/settings.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index fbacac81..8f5ff109 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -74,9 +74,8 @@ "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, "db.sqlite3"), - "TEST": { - "NAME": os.path.join(BASE_DIR, "db_test.sqlite3"), - }, + "TEST": {"NAME": os.path.join(BASE_DIR, "db_test.sqlite3")}, + "OPTIONS": {"timeout": 5}, }, } From acfe4e56a0a9f7ee12eeac7a3babccc03c5e4f7e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 2 Aug 2022 01:23:47 -0700 Subject: [PATCH 33/57] add todo item to admin site --- tests/test_app/admin.py | 7 +++++++ tests/test_app/urls.py | 6 ++++++ 2 files changed, 13 insertions(+) create mode 100644 tests/test_app/admin.py diff --git a/tests/test_app/admin.py b/tests/test_app/admin.py new file mode 100644 index 00000000..273abb1c --- /dev/null +++ b/tests/test_app/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin + +from .models import TodoItem + +@admin.register(TodoItem) +class TodoItemAdmin(admin.ModelAdmin): + pass \ No newline at end of file diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index 525659ea..3038eb46 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -23,6 +23,12 @@ from .views import base_template +class AccessUser: + has_module_perms = has_perm = __getattr__ = lambda s, *a, **kw: True + + +admin.site.has_permission = lambda r: setattr(r, "user", AccessUser()) or True + urlpatterns = [ path("", base_template), path("idom/", include("django_idom.http.urls")), From aa6139aefc51e80d9c72cc702cbc348240c6813d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 2 Aug 2022 01:42:37 -0700 Subject: [PATCH 34/57] fix item done toggle --- tests/test_app/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 788742e7..708200d8 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -134,7 +134,7 @@ def authorized_user(): def get_items(): - return TodoItem.objects.filter(done=False).order_by("done") + return TodoItem.objects.all().order_by("done") def add_item(text: str): @@ -174,7 +174,7 @@ def todo_list(): { "id": f"todo-item-{item.text}-checkbox", "type": "checkbox", - "checked": item.done, + "defaultChecked": item.done, "onClick": lambda event: toggle_item_mutation.execute(item), } ), From e56255e5fb9479598e8201bc380b6dc9608b8398 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 2 Aug 2022 01:48:52 -0700 Subject: [PATCH 35/57] format --- tests/test_app/admin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_app/admin.py b/tests/test_app/admin.py index 273abb1c..aa897829 100644 --- a/tests/test_app/admin.py +++ b/tests/test_app/admin.py @@ -2,6 +2,7 @@ from .models import TodoItem + @admin.register(TodoItem) class TodoItemAdmin(admin.ModelAdmin): - pass \ No newline at end of file + pass From 8595c4e0fa60191b990c3750eec3fc78e73c86f3 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 2 Aug 2022 02:04:15 -0700 Subject: [PATCH 36/57] attempt using onChange as devtools suggests --- tests/test_app/components.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 708200d8..9db21ec3 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -140,6 +140,9 @@ def get_items(): def add_item(text: str): existing = TodoItem.objects.filter(text=text).first() if existing: + print("existing") + from time import sleep + sleep(1) if existing.done: existing.done = False existing.save() @@ -175,7 +178,7 @@ def todo_list(): "id": f"todo-item-{item.text}-checkbox", "type": "checkbox", "defaultChecked": item.done, - "onClick": lambda event: toggle_item_mutation.execute(item), + "onChange": lambda event: toggle_item_mutation.execute(item), } ), key=item.text, From a0075663668e063398dc4bbd8b79f650b28f9739 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 2 Aug 2022 02:33:42 -0700 Subject: [PATCH 37/57] remove accidental sleep --- tests/test_app/components.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 9db21ec3..cc4dd242 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -140,9 +140,6 @@ def get_items(): def add_item(text: str): existing = TodoItem.objects.filter(text=text).first() if existing: - print("existing") - from time import sleep - sleep(1) if existing.done: existing.done = False existing.save() From 093fc368fe974d0ee17b9c8a55169c2b6042b20e Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 9 Aug 2022 11:09:05 -0700 Subject: [PATCH 38/57] try to fix tests --- tests/test_app/components.py | 74 ++++++++++++++----------- tests/test_app/tests/test_components.py | 8 ++- 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index cc4dd242..4c0367da 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -133,11 +133,11 @@ def authorized_user(): ) -def get_items(): - return TodoItem.objects.all().order_by("done") +def get_items_query(): + return TodoItem.objects.all() -def add_item(text: str): +def add_item_mutation(text: str): existing = TodoItem.objects.filter(text=text).first() if existing: if existing.done: @@ -149,51 +149,40 @@ def add_item(text: str): TodoItem(text=text, done=False).save() -def toggle_item(item: TodoItem): +def toggle_item_mutation(item: TodoItem): item.done = not item.done item.save() @idom.component def todo_list(): - get_item_query = use_query(get_items) - add_item_mutation = use_mutation(add_item, refetch=get_items) - toggle_item_mutation = use_mutation(toggle_item, refetch=get_items) + items = use_query(get_items_query) + toggle_item = use_mutation(toggle_item_mutation, refetch=get_items_query) - if get_item_query.data is None: + if items.error: + rendered_items = html.h2(f"Error when loading - {items.error}") + elif items.data is None: rendered_items = html.h2("Loading...") - elif get_item_query.error: - rendered_items = html.h2(f"Error when loading - {get_item_query.error}") else: - rendered_items = html.ul( - [ - html.li( - {"id": f"todo-item-{item.text}"}, - item.text, - html.input( - { - "id": f"todo-item-{item.text}-checkbox", - "type": "checkbox", - "defaultChecked": item.done, - "onChange": lambda event: toggle_item_mutation.execute(item), - } - ), - key=item.text, - ) - for item in reversed(get_item_query.data) - ] + rendered_items = html._( + html.h3("Not Done"), + _render_items([i for i in items.data if not i.done], toggle_item), + html.h3("Done"), + _render_items([i for i in items.data if i.done], toggle_item), ) - if add_item_mutation.loading: - mutation_status = html.h2("Adding...") - elif add_item_mutation.error: - mutation_status = html.h2(f"Error when adding - {add_item_mutation.error}") + add_item = use_mutation(add_item_mutation, refetch=get_items_query) + + if add_item.loading: + mutation_status = html.h2("Working...") + elif add_item.error: + mutation_status = html.h2(f"Error when adding - {add_item.error}") else: mutation_status = "" def submit_event(event): if event["key"] == "Enter": - add_item_mutation.execute(text=event["target"]["value"]) + add_item.execute(text=event["target"]["value"]) return html.div( html.label("Add an item:"), @@ -201,3 +190,24 @@ def submit_event(event): mutation_status, rendered_items, ) + + +def _render_items(items, toggle_item): + return html.ul( + [ + html.li( + {"id": f"todo-item-{item.text}"}, + item.text, + html.input( + { + "id": f"todo-item-{item.text}-checkbox", + "type": "checkbox", + "checked": item.done, + "onChange": lambda event, i=item: toggle_item.execute(i), + } + ), + key=item.text, + ) + for item in items + ] + ) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index e20d0839..d0460fe0 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -2,14 +2,18 @@ import sys from channels.testing import ChannelsLiveServerTestCase -from django.test import TestCase +from django.test import TransactionTestCase from playwright.sync_api import TimeoutError, sync_playwright # These tests are broken on Windows due to Selenium if sys.platform != "win32": - class TestIdomCapabilities(TestCase, ChannelsLiveServerTestCase): + class TestIdomCapabilities( + ChannelsLiveServerTestCase, + # using the normal TestCase caused the database to lock up after tests + TransactionTestCase, + ): @classmethod def setUpClass(cls): os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" From 83b2d01afb28a26ff6aadfb0341d36fcd7222fa2 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 13 Aug 2022 21:12:47 -0700 Subject: [PATCH 39/57] fix setup.py for deprecated distutils --- setup.py | 23 ++++++++++++++++++++--- src/django_idom/hooks.py | 4 +--- src/js/package-lock.json | 14 +++++++------- src/js/package.json | 2 +- tests/test_app/components.py | 17 +++++++++++++++-- tests/test_app/tests/test_components.py | 24 ++++++++++++++---------- 6 files changed, 58 insertions(+), 26 deletions(-) diff --git a/setup.py b/setup.py index 9957e4ae..55a3ca2b 100644 --- a/setup.py +++ b/setup.py @@ -6,12 +6,12 @@ import sys import traceback from distutils import log -from distutils.command.build import build # type: ignore -from distutils.command.sdist import sdist # type: ignore from pathlib import Path +from logging import getLogger, StreamHandler from setuptools import find_packages, setup from setuptools.command.develop import develop +from setuptools.command.sdist import sdist if sys.platform == "win32": @@ -22,6 +22,15 @@ def list2cmdline(cmd_list): return " ".join(map(pipes.quote, cmd_list)) +log = getLogger() +log.addHandler(StreamHandler(sys.stdout)) + + +# ----------------------------------------------------------------------------- +# Basic Constants +# ----------------------------------------------------------------------------- + + # the name of the project name = "django_idom" @@ -135,10 +144,18 @@ def run(self): package["cmdclass"] = { "sdist": build_javascript_first(sdist), - "build": build_javascript_first(build), "develop": build_javascript_first(develop), } +if sys.version_info < (3, 10, 6): + from distutils.command.build import build + + package["cmdclass"]["build"] = build_javascript_first(build) +else: + from setuptools.command.build_py import build_py + + package["cmdclass"]["build_py"] = build_javascript_first(build_py) + # ----------------------------------------------------------------------------- # Install It diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 2f9d4fec..f35e80e6 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -34,9 +34,7 @@ _REFETCH_CALLBACKS: DefaultDict[ Callable[..., Any], set[Callable[[], None]] ] = DefaultDict(set) -WebsocketContext: Type[Context[Union[IdomWebsocket, None]]] = create_context( - None, "WebSocketContext" -) +WebsocketContext: Type[Context[Union[IdomWebsocket, None]]] = create_context(None) _Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]]) _Params = ParamSpec("_Params") _Data = TypeVar("_Data") diff --git a/src/js/package-lock.json b/src/js/package-lock.json index 54aa3359..61945e8b 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "idom-client-react": "^0.37.2", + "idom-client-react": "^0.39.0", "react": "^17.0.2", "react-dom": "^17.0.2" }, @@ -99,9 +99,9 @@ "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" }, "node_modules/idom-client-react": { - "version": "0.37.2", - "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.37.2.tgz", - "integrity": "sha512-9yUp39Ah57EXmdzfRF9yL9aXk3MnQgK9S+i01dTbZIfMaTdDtgfjj9sdQKmM+lEFKY6nSgkGenohuB4h1ZOy7Q==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.39.0.tgz", + "integrity": "sha512-tZzJYd80kAAeSiSgg7Ij8DH1bueTNAgvU+SI2KLGRnY1wfKVU9PcKq8ENzGBalKExOccmfxzA6gTqi4vvvNiDw==", "dependencies": { "fast-json-patch": "^3.0.0-1", "htm": "^3.0.3" @@ -379,9 +379,9 @@ "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" }, "idom-client-react": { - "version": "0.37.2", - "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.37.2.tgz", - "integrity": "sha512-9yUp39Ah57EXmdzfRF9yL9aXk3MnQgK9S+i01dTbZIfMaTdDtgfjj9sdQKmM+lEFKY6nSgkGenohuB4h1ZOy7Q==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.39.0.tgz", + "integrity": "sha512-tZzJYd80kAAeSiSgg7Ij8DH1bueTNAgvU+SI2KLGRnY1wfKVU9PcKq8ENzGBalKExOccmfxzA6gTqi4vvvNiDw==", "requires": { "fast-json-patch": "^3.0.0-1", "htm": "^3.0.3" diff --git a/src/js/package.json b/src/js/package.json index 67bfb2a4..2d53bd6f 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -16,7 +16,7 @@ "rollup-plugin-replace": "^2.2.0" }, "dependencies": { - "idom-client-react": "^0.37.2", + "idom-client-react": "^0.39.0", "react": "^17.0.2", "react-dom": "^17.0.2" } diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 4c0367da..356128bc 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -156,6 +156,7 @@ def toggle_item_mutation(item: TodoItem): @idom.component def todo_list(): + input_value, set_input_value = idom.use_state("") items = use_query(get_items_query) toggle_item = use_mutation(toggle_item_mutation, refetch=get_items_query) @@ -180,13 +181,25 @@ def todo_list(): else: mutation_status = "" - def submit_event(event): + def on_submit(event): if event["key"] == "Enter": add_item.execute(text=event["target"]["value"]) + set_input_value("") + + def on_change(event): + set_input_value(event["target"]["value"]) return html.div( html.label("Add an item:"), - html.input({"type": "text", "id": "todo-input", "onKeyDown": submit_event}), + html.input( + { + "type": "text", + "id": "todo-input", + "value": input_value, + "onKeyPress": on_submit, + "onChange": on_change, + } + ), mutation_status, rendered_items, ) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index d0460fe0..abdd6ad5 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -88,13 +88,17 @@ def test_authorized_user(self): def test_use_query_and_mutation(self): todo_input = self.page.wait_for_selector("#todo-input") - todo_input.type("sample-1") - todo_input.press("Enter") - self.page.wait_for_selector("#todo-item-sample-1") - self.page.wait_for_selector("#todo-item-sample-1-checkbox").click() - self.assertRaises( - TimeoutError, - self.page.wait_for_selector, - "#todo-item-sample-1", - timeout=1, - ) + + item_ids = list(range(5)) + + for i in item_ids: + todo_input.type(f"sample-{i}") + todo_input.press("Enter") + self.page.wait_for_selector(f"#todo-item-sample-{i}") + self.page.wait_for_selector(f"#todo-item-sample-{i}-checkbox").click() + self.assertRaises( + TimeoutError, + self.page.wait_for_selector, + f"#todo-item-sample-{i}", + timeout=1, + ) From 6a754cff4341d0e832fcb04c32f6fecf8c85cc6c Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 13 Aug 2022 22:16:17 -0700 Subject: [PATCH 40/57] use propper skip error --- setup.py | 2 +- tests/test_app/tests/test_components.py | 182 ++++++++++++------------ 2 files changed, 92 insertions(+), 92 deletions(-) diff --git a/setup.py b/setup.py index 55a3ca2b..4d796537 100644 --- a/setup.py +++ b/setup.py @@ -6,8 +6,8 @@ import sys import traceback from distutils import log +from logging import StreamHandler, getLogger from pathlib import Path -from logging import getLogger, StreamHandler from setuptools import find_packages, setup from setuptools.command.develop import develop diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index abdd6ad5..83f7610e 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,104 +1,104 @@ import os import sys +from unittest import SkipTest from channels.testing import ChannelsLiveServerTestCase from django.test import TransactionTestCase from playwright.sync_api import TimeoutError, sync_playwright -# These tests are broken on Windows due to Selenium -if sys.platform != "win32": - - class TestIdomCapabilities( - ChannelsLiveServerTestCase, - # using the normal TestCase caused the database to lock up after tests - TransactionTestCase, - ): - @classmethod - def setUpClass(cls): - os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" - super().setUpClass() - cls.playwright = sync_playwright().start() - headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", 0))) - cls.browser = cls.playwright.chromium.launch(headless=not headed) - cls.page = cls.browser.new_page() - - @classmethod - def tearDownClass(cls): - super().tearDownClass() - cls.page.close() - cls.browser.close() - cls.playwright.stop() - - def setUp(self): - super().setUp() - self.page.goto(self.live_server_url) - - def test_hello_world(self): - self.page.wait_for_selector("#hello-world") - - def test_counter(self): - for i in range(5): - self.page.locator(f"#counter-num[data-count={i}]") - self.page.locator("#counter-inc").click() - - def test_parametrized_component(self): - self.page.locator("#parametrized-component[data-value='579']").wait_for() - - def test_component_from_web_module(self): - self.page.wait_for_selector(".VictoryContainer") - - def test_use_websocket(self): - self.page.locator("#use-websocket[data-success=true]").wait_for() - - def test_use_scope(self): - self.page.locator("#use-scope[data-success=true]").wait_for() - - def test_use_location(self): - self.page.locator("#use-location[data-success=true]").wait_for() - - def test_static_css(self): - self.assertEqual( - self.page.wait_for_selector("#django-css button").evaluate( - "e => window.getComputedStyle(e).getPropertyValue('color')" - ), - "rgb(0, 0, 255)", - ) - - def test_static_js(self): - self.page.locator("#django-js[data-success=true]").wait_for() - - def test_unauthorized_user(self): - self.assertRaises( - TimeoutError, - self.page.wait_for_selector, - "#unauthorized-user", - timeout=1, - ) - self.page.wait_for_selector("#unauthorized-user-fallback") - - def test_authorized_user(self): +class TestIdomCapabilities( + ChannelsLiveServerTestCase, + # using the normal TestCase caused the database to lock up after tests + TransactionTestCase, +): + @classmethod + def setUpClass(cls): + if sys.platform == "win32": + raise SkipTest("These tests are broken on Windows due to Selenium") + os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" + super().setUpClass() + cls.playwright = sync_playwright().start() + headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", 0))) + cls.browser = cls.playwright.chromium.launch(headless=not headed) + cls.page = cls.browser.new_page() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + cls.page.close() + cls.browser.close() + cls.playwright.stop() + + def setUp(self): + super().setUp() + self.page.goto(self.live_server_url) + + def test_hello_world(self): + self.page.wait_for_selector("#hello-world") + + def test_counter(self): + for i in range(5): + self.page.locator(f"#counter-num[data-count={i}]") + self.page.locator("#counter-inc").click() + + def test_parametrized_component(self): + self.page.locator("#parametrized-component[data-value='579']").wait_for() + + def test_component_from_web_module(self): + self.page.wait_for_selector(".VictoryContainer") + + def test_use_websocket(self): + self.page.locator("#use-websocket[data-success=true]").wait_for() + + def test_use_scope(self): + self.page.locator("#use-scope[data-success=true]").wait_for() + + def test_use_location(self): + self.page.locator("#use-location[data-success=true]").wait_for() + + def test_static_css(self): + self.assertEqual( + self.page.wait_for_selector("#django-css button").evaluate( + "e => window.getComputedStyle(e).getPropertyValue('color')" + ), + "rgb(0, 0, 255)", + ) + + def test_static_js(self): + self.page.locator("#django-js[data-success=true]").wait_for() + + def test_unauthorized_user(self): + self.assertRaises( + TimeoutError, + self.page.wait_for_selector, + "#unauthorized-user", + timeout=1, + ) + self.page.wait_for_selector("#unauthorized-user-fallback") + + def test_authorized_user(self): + self.assertRaises( + TimeoutError, + self.page.wait_for_selector, + "#authorized-user-fallback", + timeout=1, + ) + self.page.wait_for_selector("#authorized-user") + + def test_use_query_and_mutation(self): + todo_input = self.page.wait_for_selector("#todo-input") + + item_ids = list(range(5)) + + for i in item_ids: + todo_input.type(f"sample-{i}") + todo_input.press("Enter") + self.page.wait_for_selector(f"#todo-item-sample-{i}") + self.page.wait_for_selector(f"#todo-item-sample-{i}-checkbox").click() self.assertRaises( TimeoutError, self.page.wait_for_selector, - "#authorized-user-fallback", + f"#todo-item-sample-{i}", timeout=1, ) - self.page.wait_for_selector("#authorized-user") - - def test_use_query_and_mutation(self): - todo_input = self.page.wait_for_selector("#todo-input") - - item_ids = list(range(5)) - - for i in item_ids: - todo_input.type(f"sample-{i}") - todo_input.press("Enter") - self.page.wait_for_selector(f"#todo-item-sample-{i}") - self.page.wait_for_selector(f"#todo-item-sample-{i}-checkbox").click() - self.assertRaises( - TimeoutError, - self.page.wait_for_selector, - f"#todo-item-sample-{i}", - timeout=1, - ) From 3d336b3806ce85162bd56b91a949c7dfd7426417 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 13 Aug 2022 22:25:50 -0700 Subject: [PATCH 41/57] bump idom dep --- requirements/pkg-deps.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 0674e22b..61de2966 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,4 +1,4 @@ channels >=3.0.0 -idom >=0.39.0, <0.40.0 +idom >=0.40.0, <0.41.0 aiofile >=3.0 typing_extensions From 38de5547dc569f831d069c84118061e8f81522ed Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 14 Aug 2022 01:46:40 -0700 Subject: [PATCH 42/57] Fix context type hint --- src/django_idom/hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index f35e80e6..daa3663e 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -34,7 +34,7 @@ _REFETCH_CALLBACKS: DefaultDict[ Callable[..., Any], set[Callable[[], None]] ] = DefaultDict(set) -WebsocketContext: Type[Context[Union[IdomWebsocket, None]]] = create_context(None) +WebsocketContext: Context[IdomWebsocket | None] = create_context(None) _Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]]) _Params = ParamSpec("_Params") _Data = TypeVar("_Data") From 5203f0df8d3dea9c9ee97783fab2ba581e0d8373 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 12 Sep 2022 16:21:18 -0700 Subject: [PATCH 43/57] bump idom --- requirements/pkg-deps.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 61de2966..1d73bdfc 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,4 +1,4 @@ channels >=3.0.0 -idom >=0.40.0, <0.41.0 +idom >=0.40.1, <0.41.0 aiofile >=3.0 typing_extensions From 1f2ed011cc2d0318f61ef2206ec01128366d3788 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 13 Sep 2022 00:23:39 -0700 Subject: [PATCH 44/57] remove unused import --- src/django_idom/hooks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index daa3663e..3e597883 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -9,7 +9,6 @@ DefaultDict, Generic, Sequence, - Type, TypeVar, Union, cast, From 0b8013c4ddf29003590c726249da66421d08b337 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 13 Sep 2022 00:40:45 -0700 Subject: [PATCH 45/57] no mypy on tests --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 1c43bd4a..e44d9a0e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -66,7 +66,7 @@ def test_suite(session: Session) -> None: def test_types(session: Session) -> None: install_requirements_file(session, "check-types") install_requirements_file(session, "pkg-deps") - session.run("mypy", "--show-error-codes", "src/django_idom", "tests/test_app") + session.run("mypy", "--show-error-codes", "src/django_idom") @nox.session From fd09362ce79cc287dbabd3d2bc977379f3bcafae Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 13 Sep 2022 01:35:01 -0700 Subject: [PATCH 46/57] bump idom-client-react --- src/js/package-lock.json | 14 +++++++------- src/js/package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/js/package-lock.json b/src/js/package-lock.json index 61945e8b..989959a7 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "idom-client-react": "^0.39.0", + "idom-client-react": "^0.40.1", "react": "^17.0.2", "react-dom": "^17.0.2" }, @@ -99,9 +99,9 @@ "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" }, "node_modules/idom-client-react": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.39.0.tgz", - "integrity": "sha512-tZzJYd80kAAeSiSgg7Ij8DH1bueTNAgvU+SI2KLGRnY1wfKVU9PcKq8ENzGBalKExOccmfxzA6gTqi4vvvNiDw==", + "version": "0.40.1", + "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.40.1.tgz", + "integrity": "sha512-vqeVIWSwLRoPUet88Kek664Q2W/+9JJy6f6oxjN1tW+j5cq6eVMrgbsmvPsvhbkLKynXwaHaaaTRiZ+Eprktzg==", "dependencies": { "fast-json-patch": "^3.0.0-1", "htm": "^3.0.3" @@ -379,9 +379,9 @@ "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" }, "idom-client-react": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.39.0.tgz", - "integrity": "sha512-tZzJYd80kAAeSiSgg7Ij8DH1bueTNAgvU+SI2KLGRnY1wfKVU9PcKq8ENzGBalKExOccmfxzA6gTqi4vvvNiDw==", + "version": "0.40.1", + "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.40.1.tgz", + "integrity": "sha512-vqeVIWSwLRoPUet88Kek664Q2W/+9JJy6f6oxjN1tW+j5cq6eVMrgbsmvPsvhbkLKynXwaHaaaTRiZ+Eprktzg==", "requires": { "fast-json-patch": "^3.0.0-1", "htm": "^3.0.3" diff --git a/src/js/package.json b/src/js/package.json index 2d53bd6f..1f17dfd6 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -16,7 +16,7 @@ "rollup-plugin-replace": "^2.2.0" }, "dependencies": { - "idom-client-react": "^0.39.0", + "idom-client-react": "^0.40.1", "react": "^17.0.2", "react-dom": "^17.0.2" } From 1e1d979e838a93ac88efa38059180e51a2bfd662 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 13 Sep 2022 15:53:24 -0700 Subject: [PATCH 47/57] bump idom version --- requirements/pkg-deps.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 1d73bdfc..8fb71a85 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,4 +1,4 @@ channels >=3.0.0 -idom >=0.40.1, <0.41.0 +idom >=0.40.2, <0.41.0 aiofile >=3.0 typing_extensions From a53ece458767f8c3fbe769dacd31e7810b4d490d Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 13 Sep 2022 16:09:22 -0700 Subject: [PATCH 48/57] add delay to typing --- tests/test_app/tests/test_components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 83f7610e..d59e7495 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -92,7 +92,7 @@ def test_use_query_and_mutation(self): item_ids = list(range(5)) for i in item_ids: - todo_input.type(f"sample-{i}") + todo_input.type(f"sample-{i}", delay=10) todo_input.press("Enter") self.page.wait_for_selector(f"#todo-item-sample-{i}") self.page.wait_for_selector(f"#todo-item-sample-{i}-checkbox").click() From ca289d03ae940540fe6596b5c24c877500b83305 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 13 Sep 2022 17:45:08 -0700 Subject: [PATCH 49/57] revert idom.html changes in tests --- tests/test_app/components.py | 81 ++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 41 deletions(-) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 356128bc..f8069378 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -1,5 +1,4 @@ import idom -from idom import html from test_app.models import TodoItem import django_idom @@ -8,18 +7,18 @@ @idom.component def hello_world(): - return html.h1({"id": "hello-world"}, "Hello World!") + return idom.html.h1({"id": "hello-world"}, "Hello World!") @idom.component def button(): count, set_count = idom.hooks.use_state(0) - return html.div( - html.button( + return idom.html.div( + idom.html.button( {"id": "counter-inc", "onClick": lambda event: set_count(count + 1)}, "Click me!", ), - html.p( + idom.html.p( {"id": "counter-num", "data-count": count}, f"Current count is: {count}", ), @@ -29,7 +28,7 @@ def button(): @idom.component def parameterized_component(x, y): total = x + y - return html.h1({"id": "parametrized-component", "data-value": total}, total) + return idom.html.h1({"id": "parametrized-component", "data-value": total}, total) victory = idom.web.module_from_template("react", "victory-bar", fallback="...") @@ -46,11 +45,11 @@ 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 html.div( + return idom.html.div( {"id": "use-websocket", "data-success": success}, - html.hr(), + idom.html.hr(), f"use_websocket: {ws}", - html.hr(), + idom.html.hr(), ) @@ -58,10 +57,10 @@ def use_websocket(): def use_scope(): scope = django_idom.hooks.use_scope() success = len(scope) >= 10 and scope["type"] == "websocket" - return html.div( + return idom.html.div( {"id": "use-scope", "data-success": success}, f"use_scope: {scope}", - html.hr(), + idom.html.hr(), ) @@ -69,67 +68,67 @@ def use_scope(): def use_location(): location = django_idom.hooks.use_location() success = bool(location) - return html.div( + return idom.html.div( {"id": "use-location", "data-success": success}, f"use_location: {location}", - html.hr(), + idom.html.hr(), ) @idom.component def django_css(): - return html.div( + return idom.html.div( {"id": "django-css"}, django_idom.components.django_css("django-css-test.css"), - html.div({"style": {"display": "inline"}}, "django_css: "), - html.button("This text should be blue."), - html.hr(), + idom.html.div({"style": {"display": "inline"}}, "django_css: "), + idom.html.button("This text should be blue."), + idom.html.hr(), ) @idom.component def django_js(): success = False - return html._( - html.div( + return idom.html._( + idom.html.div( {"id": "django-js", "data-success": success}, f"django_js: {success}", django_idom.components.django_js("django-js-test.js"), ), - html.hr(), + idom.html.hr(), ) @idom.component @django_idom.decorators.auth_required( - fallback=html.div( + fallback=idom.html.div( {"id": "unauthorized-user-fallback"}, "unauthorized_user: Success", - html.hr(), + idom.html.hr(), ) ) def unauthorized_user(): - return html.div( + return idom.html.div( {"id": "unauthorized-user"}, "unauthorized_user: Fail", - html.hr(), + idom.html.hr(), ) @idom.component @django_idom.decorators.auth_required( auth_attribute="is_anonymous", - fallback=html.div( + fallback=idom.html.div( {"id": "authorized-user-fallback"}, "authorized_user: Fail", - html.hr(), + idom.html.hr(), ), ) def authorized_user(): - return html.div( + return idom.html.div( {"id": "authorized-user"}, "authorized_user: Success", - html.hr(), + idom.html.hr(), ) @@ -161,23 +160,23 @@ def todo_list(): toggle_item = use_mutation(toggle_item_mutation, refetch=get_items_query) if items.error: - rendered_items = html.h2(f"Error when loading - {items.error}") + rendered_items = idom.html.h2(f"Error when loading - {items.error}") elif items.data is None: - rendered_items = html.h2("Loading...") + rendered_items = idom.html.h2("Loading...") else: - rendered_items = html._( - html.h3("Not Done"), + rendered_items = idom.html._( + idom.html.h3("Not Done"), _render_items([i for i in items.data if not i.done], toggle_item), - html.h3("Done"), + idom.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 = html.h2("Working...") + mutation_status = idom.html.h2("Working...") elif add_item.error: - mutation_status = html.h2(f"Error when adding - {add_item.error}") + mutation_status = idom.html.h2(f"Error when adding - {add_item.error}") else: mutation_status = "" @@ -189,9 +188,9 @@ def on_submit(event): def on_change(event): set_input_value(event["target"]["value"]) - return html.div( - html.label("Add an item:"), - html.input( + return idom.html.div( + idom.html.label("Add an item:"), + idom.html.input( { "type": "text", "id": "todo-input", @@ -206,12 +205,12 @@ def on_change(event): def _render_items(items, toggle_item): - return html.ul( + return idom.html.ul( [ - html.li( + idom.html.li( {"id": f"todo-item-{item.text}"}, item.text, - html.input( + idom.html.input( { "id": f"todo-item-{item.text}-checkbox", "type": "checkbox", From 281c5afb9c2aa57c350d591b8b2fb605cdc29fbb Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 13 Sep 2022 17:45:17 -0700 Subject: [PATCH 50/57] clean up noxfile --- noxfile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/noxfile.py b/noxfile.py index e44d9a0e..f382980d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -79,12 +79,12 @@ def test_style(session: Session) -> None: ".", "--check", "--extend-exclude", - rf"/migrations/", + "/migrations/", ) session.run("isort", ".", "--check-only") def install_requirements_file(session: Session, name: str) -> None: - file_path = HERE / "requirements" / (name + ".txt") + file_path = HERE / "requirements" / f"{name}.txt" assert file_path.exists(), f"requirements file {file_path} does not exist" session.install("-r", str(file_path)) From 949e8159a68f32929797398482cea390e6b7ad70 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 13 Sep 2022 17:46:21 -0700 Subject: [PATCH 51/57] bump idom client --- src/js/package-lock.json | 14 +++++++------- src/js/package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/js/package-lock.json b/src/js/package-lock.json index 989959a7..a11a055a 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "idom-client-react": "^0.40.1", + "idom-client-react": "^0.40.2", "react": "^17.0.2", "react-dom": "^17.0.2" }, @@ -99,9 +99,9 @@ "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" }, "node_modules/idom-client-react": { - "version": "0.40.1", - "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.40.1.tgz", - "integrity": "sha512-vqeVIWSwLRoPUet88Kek664Q2W/+9JJy6f6oxjN1tW+j5cq6eVMrgbsmvPsvhbkLKynXwaHaaaTRiZ+Eprktzg==", + "version": "0.40.2", + "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.40.2.tgz", + "integrity": "sha512-7oTdN23DU5oeBfCjGjVovMF8vQMQiD1+89EkNgYmxJL/zQtz7HpY11fxARTIZXnB8XPvICuGEZwcPYsXkZGBFQ==", "dependencies": { "fast-json-patch": "^3.0.0-1", "htm": "^3.0.3" @@ -379,9 +379,9 @@ "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" }, "idom-client-react": { - "version": "0.40.1", - "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.40.1.tgz", - "integrity": "sha512-vqeVIWSwLRoPUet88Kek664Q2W/+9JJy6f6oxjN1tW+j5cq6eVMrgbsmvPsvhbkLKynXwaHaaaTRiZ+Eprktzg==", + "version": "0.40.2", + "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.40.2.tgz", + "integrity": "sha512-7oTdN23DU5oeBfCjGjVovMF8vQMQiD1+89EkNgYmxJL/zQtz7HpY11fxARTIZXnB8XPvICuGEZwcPYsXkZGBFQ==", "requires": { "fast-json-patch": "^3.0.0-1", "htm": "^3.0.3" diff --git a/src/js/package.json b/src/js/package.json index 1f17dfd6..4d4112a2 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -16,7 +16,7 @@ "rollup-plugin-replace": "^2.2.0" }, "dependencies": { - "idom-client-react": "^0.40.1", + "idom-client-react": "^0.40.2", "react": "^17.0.2", "react-dom": "^17.0.2" } From ba42a8b0bbfa950a37bc7cf7cf730808597a72f4 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 13 Sep 2022 18:15:02 -0700 Subject: [PATCH 52/57] Move mutation and query to types.py --- src/django_idom/hooks.py | 38 +++----------------------------------- src/django_idom/types.py | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 3e597883..49b2f606 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -1,18 +1,7 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass -from typing import ( - Any, - Awaitable, - Callable, - DefaultDict, - Generic, - Sequence, - TypeVar, - Union, - cast, -) +from typing import Any, Awaitable, Callable, DefaultDict, Sequence, Union, cast from channels.db import database_sync_to_async as _database_sync_to_async from django.db.models.base import Model @@ -20,23 +9,18 @@ from idom import use_callback, use_ref from idom.backend.types import Location from idom.core.hooks import Context, create_context, use_context, use_effect, use_state -from typing_extensions import ParamSpec -from django_idom.types import IdomWebsocket +from django_idom.types import IdomWebsocket, Mutation, Query, _Params, _Result database_sync_to_async = cast( Callable[..., Callable[..., Awaitable[Any]]], _database_sync_to_async, ) - +WebsocketContext: Context[IdomWebsocket | None] = create_context(None) _REFETCH_CALLBACKS: DefaultDict[ Callable[..., Any], set[Callable[[], None]] ] = DefaultDict(set) -WebsocketContext: Context[IdomWebsocket | None] = create_context(None) -_Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]]) -_Params = ParamSpec("_Params") -_Data = TypeVar("_Data") def use_location() -> Location: @@ -147,22 +131,6 @@ def reset() -> None: return Mutation(call, loading, error, reset) -@dataclass -class Query(Generic[_Data]): - data: _Data - loading: bool - error: Exception | None - refetch: Callable[[], None] - - -@dataclass -class Mutation(Generic[_Params]): - execute: Callable[_Params, None] - loading: bool - error: Exception | None - reset: Callable[[], None] - - def _fetch_deferred(data: Any) -> None: # https://github.com/typeddjango/django-stubs/issues/704 if isinstance(data, QuerySet): # type: ignore[misc] diff --git a/src/django_idom/types.py b/src/django_idom/types.py index 1b1fc7d4..b0bb9e06 100644 --- a/src/django_idom/types.py +++ b/src/django_idom/types.py @@ -1,10 +1,43 @@ from dataclasses import dataclass -from typing import Awaitable, Callable, Optional +from typing import Any, Awaitable, Callable, Generic, Optional, TypeVar, Union + +from django.db.models.base import Model +from django.db.models.query import QuerySet +from typing_extensions import ParamSpec + + +__all__ = ["_Result", "_Params", "_Data", "IdomWebsocket", "Query", "Mutation"] + +_Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]]) +_Params = ParamSpec("_Params") +_Data = TypeVar("_Data") @dataclass class IdomWebsocket: + """Websocket returned by the `use_websocket` hook.""" + scope: dict close: Callable[[Optional[int]], Awaitable[None]] disconnect: Callable[[int], Awaitable[None]] view_id: str + + +@dataclass +class Query(Generic[_Data]): + """Queries generated by the `use_query` hook.""" + + data: _Data + loading: bool + error: Exception | None + refetch: Callable[[], None] + + +@dataclass +class Mutation(Generic[_Params]): + """Mutations generated by the `use_mutation` hook.""" + + execute: Callable[_Params, None] + loading: bool + error: Exception | None + reset: Callable[[], None] From b174a80abc42b89509a362f53f3bbacbcee03568 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 13 Sep 2022 18:20:53 -0700 Subject: [PATCH 53/57] Python < 3.10 compatibility --- src/django_idom/hooks.py | 10 +++++----- src/django_idom/types.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 49b2f606..f79e7d44 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -17,7 +17,7 @@ Callable[..., Callable[..., Awaitable[Any]]], _database_sync_to_async, ) -WebsocketContext: Context[IdomWebsocket | None] = create_context(None) +WebsocketContext: Context[Union[IdomWebsocket, None]] = create_context(None) _REFETCH_CALLBACKS: DefaultDict[ Callable[..., Any], set[Callable[[], None]] ] = DefaultDict(set) @@ -45,10 +45,10 @@ def use_websocket() -> IdomWebsocket: def use_query( - query: Callable[_Params, _Result | None], + query: Callable[_Params, Union[_Result, None]], *args: _Params.args, **kwargs: _Params.kwargs, -) -> Query[_Result | None]: +) -> Query[Union[_Result, None]]: query_ref = use_ref(query) if query_ref.current is not query: raise ValueError(f"Query function changed from {query_ref.current} to {query}.") @@ -96,8 +96,8 @@ def execute_query() -> None: def use_mutation( - mutate: Callable[_Params, bool | None], - refetch: Callable[..., Any] | Sequence[Callable[..., Any]], + mutate: Callable[_Params, Union[bool, None]], + refetch: Union[Callable[..., Any], Sequence[Callable[..., Any]]], ) -> Mutation[_Params]: loading, set_loading = use_state(False) error, set_error = use_state(cast(Union[Exception, None], None)) diff --git a/src/django_idom/types.py b/src/django_idom/types.py index b0bb9e06..ae20b9e8 100644 --- a/src/django_idom/types.py +++ b/src/django_idom/types.py @@ -29,7 +29,7 @@ class Query(Generic[_Data]): data: _Data loading: bool - error: Exception | None + error: Union[Exception, None] refetch: Callable[[], None] @@ -39,5 +39,5 @@ class Mutation(Generic[_Params]): execute: Callable[_Params, None] loading: bool - error: Exception | None + error: Union[Exception, None] reset: Callable[[], None] From 0324b06807e3be2bbf9f58df2673c99e03dc5f37 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Sep 2022 14:08:05 -0700 Subject: [PATCH 54/57] Revert "Python < 3.10 compatibility" This reverts commit b174a80abc42b89509a362f53f3bbacbcee03568. --- src/django_idom/hooks.py | 10 +++++----- src/django_idom/types.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index f79e7d44..49b2f606 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -17,7 +17,7 @@ Callable[..., Callable[..., Awaitable[Any]]], _database_sync_to_async, ) -WebsocketContext: Context[Union[IdomWebsocket, None]] = create_context(None) +WebsocketContext: Context[IdomWebsocket | None] = create_context(None) _REFETCH_CALLBACKS: DefaultDict[ Callable[..., Any], set[Callable[[], None]] ] = DefaultDict(set) @@ -45,10 +45,10 @@ def use_websocket() -> IdomWebsocket: def use_query( - query: Callable[_Params, Union[_Result, None]], + query: Callable[_Params, _Result | None], *args: _Params.args, **kwargs: _Params.kwargs, -) -> Query[Union[_Result, None]]: +) -> Query[_Result | None]: query_ref = use_ref(query) if query_ref.current is not query: raise ValueError(f"Query function changed from {query_ref.current} to {query}.") @@ -96,8 +96,8 @@ def execute_query() -> None: def use_mutation( - mutate: Callable[_Params, Union[bool, None]], - refetch: Union[Callable[..., Any], Sequence[Callable[..., Any]]], + mutate: Callable[_Params, bool | None], + refetch: Callable[..., Any] | Sequence[Callable[..., Any]], ) -> Mutation[_Params]: loading, set_loading = use_state(False) error, set_error = use_state(cast(Union[Exception, None], None)) diff --git a/src/django_idom/types.py b/src/django_idom/types.py index ae20b9e8..b0bb9e06 100644 --- a/src/django_idom/types.py +++ b/src/django_idom/types.py @@ -29,7 +29,7 @@ class Query(Generic[_Data]): data: _Data loading: bool - error: Union[Exception, None] + error: Exception | None refetch: Callable[[], None] @@ -39,5 +39,5 @@ class Mutation(Generic[_Params]): execute: Callable[_Params, None] loading: bool - error: Union[Exception, None] + error: Exception | None reset: Callable[[], None] From 19806cb4fa1fbc037e1dc13bdfc76e48de7467b8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Sep 2022 14:09:01 -0700 Subject: [PATCH 55/57] from __future__ import annotations --- src/django_idom/types.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/django_idom/types.py b/src/django_idom/types.py index b0bb9e06..88f9c32f 100644 --- a/src/django_idom/types.py +++ b/src/django_idom/types.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass from typing import Any, Awaitable, Callable, Generic, Optional, TypeVar, Union From f672798f83a6544369edb02ab7fc1d9d144a4646 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Sep 2022 17:14:07 -0700 Subject: [PATCH 56/57] remove TransactionTestCase --- tests/test_app/tests/test_components.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index d59e7495..95fe0963 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -3,15 +3,10 @@ from unittest import SkipTest from channels.testing import ChannelsLiveServerTestCase -from django.test import TransactionTestCase from playwright.sync_api import TimeoutError, sync_playwright -class TestIdomCapabilities( - ChannelsLiveServerTestCase, - # using the normal TestCase caused the database to lock up after tests - TransactionTestCase, -): +class TestIdomCapabilities(ChannelsLiveServerTestCase): @classmethod def setUpClass(cls): if sys.platform == "win32": From 81928fb132fc28821fa31ddcc9a253eb5ff72ec0 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Sep 2022 17:14:30 -0700 Subject: [PATCH 57/57] revert channels login changes --- src/django_idom/websocket/consumer.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/django_idom/websocket/consumer.py b/src/django_idom/websocket/consumer.py index dfb3af34..cdc3eb9f 100644 --- a/src/django_idom/websocket/consumer.py +++ b/src/django_idom/websocket/consumer.py @@ -5,6 +5,7 @@ from typing import Any from urllib.parse import parse_qsl +from channels.auth import login from channels.db import database_sync_to_async as convert_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from idom.core.layout import Layout, LayoutEvent @@ -22,9 +23,6 @@ class IdomAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer): """Communicates with the browser to perform actions on-demand.""" async def connect(self) -> None: - # this triggers AppRegistryNotReady exception in manage.py if at root level - from channels.auth import login - await super().connect() user = self.scope.get("user")