diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 0af1d0a3..d6339c6b 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -7,8 +7,10 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-python@v4 with: python-version: 3.x - run: pip install -r requirements/build-docs.txt diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml index 4b4de80e..4439cb5e 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-py.yml @@ -11,15 +11,15 @@ jobs: release-package: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2-beta + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: node-version: "14.x" - name: Set up Python 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 diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 967f7c8c..228e4fca 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -14,9 +14,12 @@ jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-python@v4 with: python-version: 3.x - run: pip install -r requirements/build-docs.txt + - run: linkcheckMarkdown docs/ -v -r - run: mkdocs build --verbose diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index f5320f35..16c6cd5d 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -17,13 +17,13 @@ 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 + - uses: actions/setup-node@v3 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 @@ -32,4 +32,4 @@ jobs: run: | npm install -g npm@latest npm --version - nox -s test -- --headless + nox -s test 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 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 diff --git a/docs/features/hooks.md b/docs/features/hooks.md index ad4afb34..1951f43d 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -2,6 +2,163 @@ Check out the [IDOM Core docs](https://idom-docs.herokuapp.com/docs/reference/hooks-api.html?highlight=hooks) on hooks! +## Use Query + +The `use_query` hook is used fetch Django ORM queries. + +=== "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() + + @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) + + 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 Django 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): + if event["key"] == "Enter": + item_mutation.execute(text=event["target"]["value"]) + + return html.div( + html.label("Add an item:"), + html.input({"type": "text", "onKeyDown": submit_event}), + mutation_status, + ) + ``` + +=== "models.py" + + ```python + from django.db import models + + class TodoItem(models.Model): + text = models.CharField(max_length=255) + ``` + +??? question "Can `use_mutation` trigger a refetch of `use_query`?" + + Yes, `use_mutation` can queue a refetch of a `use_query` via the `refetch=...` argument. + + The example below is a merge of the `use_query` and `use_mutation` examples above with the addition of a `refetch` argument on `use_mutation`. + + Please note that any `use_query` hooks that use `get_items` will be refetched upon a successful mutation. + + ```python title="components.py" + from example_project.my_app.models import TodoItem + from idom import component, html + from django_idom.hooks import use_mutation + + 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): + if event["key"] == "Enter": + item_mutation.execute(text=event["target"]["value"]) + + return html.div( + html.label("Add an item:"), + html.input({"type": "text", "onKeyDown": submit_event}), + mutation_status, + rendered_items, + ) + ``` + +??? 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. + + 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. + + See the [Django ORM documentation](https://docs.djangoproject.com/en/dev/topics/db/queries/) for more information. + ## Use Websocket You can fetch the Django Channels websocket at any time by using `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 diff --git a/noxfile.py b/noxfile.py index e6426d50..f382980d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -51,13 +51,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) @@ -65,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 @@ -73,18 +74,17 @@ 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", + "/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)) diff --git a/pyproject.toml b/pyproject.toml index 74157e0d..0103531f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ 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" +ignore_missing_imports = true +warn_unused_configs = true +warn_redundant_casts = true +warn_unused_ignores = true 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 diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 08e42fad..8fb71a85 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 +idom >=0.40.2, <0.41.0 aiofile >=3.0 +typing_extensions 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/setup.py b/setup.py index 9957e4ae..4d796537 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 logging import StreamHandler, getLogger from pathlib import Path 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 79ce1d16..49b2f606 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -1,14 +1,26 @@ -from typing import Dict, Type, Union +from __future__ import annotations +import asyncio +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 +from django.db.models.query import QuerySet +from idom import use_callback, use_ref 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_effect, use_state -from django_idom.types import IdomWebsocket +from django_idom.types import IdomWebsocket, Mutation, Query, _Params, _Result -WebsocketContext: Type[Context[Union[IdomWebsocket, None]]] = create_context( - None, "WebSocketContext" +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) def use_location() -> Location: @@ -19,7 +31,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 +42,108 @@ def use_websocket() -> IdomWebsocket: if websocket is None: raise RuntimeError("No websocket. Are you running with a Django server?") return websocket + + +def use_query( + query: Callable[_Params, _Result | None], + *args: _Params.args, + **kwargs: _Params.kwargs, +) -> 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}.") + + 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(cast(Union[Exception, None], None)) + + @use_callback + def refetch() -> None: + set_should_execute(True) + set_loading(True) + set_error(None) + + @use_effect(dependencies=[]) + 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) + return lambda: _REFETCH_CALLBACKS[query].remove(refetch) + + @use_effect(dependencies=None) + @database_sync_to_async + def execute_query() -> None: + if not should_execute: + return + + try: + new_data = query(*args, **kwargs) + _fetch_deferred(new_data) + except Exception as e: + set_data(None) + set_loading(False) + set_error(e) + return + finally: + set_should_execute(False) + + set_data(new_data) + set_loading(False) + set_error(None) + + return Query(data, loading, error, refetch) + + +def use_mutation( + 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)) + + @use_callback + def call(*args: _Params.args, **kwargs: _Params.kwargs) -> None: + set_loading(True) + + @database_sync_to_async + def execute_mutation() -> None: + try: + should_refetch = mutate(*args, **kwargs) + except Exception as e: + set_loading(False) + set_error(e) + else: + set_loading(False) + set_error(None) + 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()) + + @use_callback + def reset() -> None: + set_loading(False) + set_error(None) + + return Mutation(call, loading, error, reset) + + +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_model_fields(value) diff --git a/src/django_idom/types.py b/src/django_idom/types.py index 1b1fc7d4..88f9c32f 100644 --- a/src/django_idom/types.py +++ b/src/django_idom/types.py @@ -1,10 +1,45 @@ +from __future__ import annotations + 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] diff --git a/src/js/package-lock.json b/src/js/package-lock.json index 54aa3359..a11a055a 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.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.37.2", - "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.37.2.tgz", - "integrity": "sha512-9yUp39Ah57EXmdzfRF9yL9aXk3MnQgK9S+i01dTbZIfMaTdDtgfjj9sdQKmM+lEFKY6nSgkGenohuB4h1ZOy7Q==", + "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.37.2", - "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.37.2.tgz", - "integrity": "sha512-9yUp39Ah57EXmdzfRF9yL9aXk3MnQgK9S+i01dTbZIfMaTdDtgfjj9sdQKmM+lEFKY6nSgkGenohuB4h1ZOy7Q==", + "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 67bfb2a4..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.37.2", + "idom-client-react": "^0.40.2", "react": "^17.0.2", "react-dom": "^17.0.2" } diff --git a/tests/test_app/admin.py b/tests/test_app/admin.py new file mode 100644 index 00000000..aa897829 --- /dev/null +++ b/tests/test_app/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from .models import TodoItem + + +@admin.register(TodoItem) +class TodoItemAdmin(admin.ModelAdmin): + pass diff --git a/tests/test_app/components.py b/tests/test_app/components.py index f9c9cb90..f8069378 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -1,6 +1,8 @@ import idom +from test_app.models import TodoItem import django_idom +from django_idom.hooks import use_mutation, use_query @idom.component @@ -128,3 +130,96 @@ def authorized_user(): "authorized_user: Success", idom.html.hr(), ) + + +def get_items_query(): + return TodoItem.objects.all() + + +def add_item_mutation(text: str): + 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_mutation(item: TodoItem): + item.done = not item.done + item.save() + + +@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) + + if items.error: + rendered_items = idom.html.h2(f"Error when loading - {items.error}") + elif items.data is None: + rendered_items = idom.html.h2("Loading...") + else: + rendered_items = idom.html._( + idom.html.h3("Not Done"), + _render_items([i for i in items.data if not i.done], toggle_item), + 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 = idom.html.h2("Working...") + elif add_item.error: + mutation_status = idom.html.h2(f"Error when adding - {add_item.error}") + else: + mutation_status = "" + + 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 idom.html.div( + idom.html.label("Add an item:"), + idom.html.input( + { + "type": "text", + "id": "todo-input", + "value": input_value, + "onKeyPress": on_submit, + "onChange": on_change, + } + ), + mutation_status, + rendered_items, + ) + + +def _render_items(items, toggle_item): + return idom.html.ul( + [ + idom.html.li( + {"id": f"todo-item-{item.text}"}, + item.text, + idom.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/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/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/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}, }, } 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..95fe0963 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,98 +1,99 @@ import os import sys +from unittest import SkipTest 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 - - -# 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() - - 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 test_hello_world(self): - self.driver.find_element_by_id("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() - - def test_parametrized_component(self): - element = self.driver.find_element_by_id("parametrized-component") - self.assertEqual(element.get_attribute("data-value"), "579") - - def test_component_from_web_module(self): - self.wait(20).until( - expected_conditions.visibility_of_element_located( - (By.CLASS_NAME, "VictoryContainer") - ) - ) - - def test_use_websocket(self): - element = self.driver.find_element_by_id("use-websocket") - self.assertEqual(element.get_attribute("data-success"), "true") - - def test_use_scope(self): - element = self.driver.find_element_by_id("use-scope") - self.assertEqual(element.get_attribute("data-success"), "true") - - def test_use_location(self): - element = self.driver.find_element_by_id("use-location") - self.assertEqual(element.get_attribute("data-success"), "true") - - 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)" - ) - - def test_static_js(self): - element = self.driver.find_element_by_id("django-js") - self.assertEqual(element.get_attribute("data-success"), "true") - - def test_unauthorized_user(self): - self.assertRaises( - NoSuchElementException, - self.driver.find_element_by_id, - "unauthorized-user", - ) - element = self.driver.find_element_by_id("unauthorized-user-fallback") - self.assertIsNotNone(element) - - def test_authorized_user(self): +from playwright.sync_api import TimeoutError, sync_playwright + + +class TestIdomCapabilities(ChannelsLiveServerTestCase): + @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}", 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() self.assertRaises( - NoSuchElementException, - self.driver.find_element_by_id, - "authorized-user-fallback", + TimeoutError, + self.page.wait_for_selector, + f"#todo-item-sample-{i}", + 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 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")),