diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a202fc..9ac34c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,35 @@ Using the following categories, list your changes in this order: ## [Unreleased] +### Added + +- The `idom` client will automatically configure itself to debug mode depending on `settings.py:DEBUG`. +- `use_connection` hook for returning the browser's active `Connection` + +### Changed + +- The `component` template tag now supports both positional and keyword arguments. +- The `component` template tag now supports non-serializable arguments. +- `IDOM_WS_MAX_RECONNECT_TIMEOUT` setting has been renamed to `IDOM_RECONNECT_MAX`. +- It is now mandatory to run `manage.py migrate` after installing IDOM. +- Bumped the minimum IDOM version to 0.43.0 + +### Removed + +- `django_idom.hooks.use_websocket` has been removed. The similar replacement is `django_idom.hooks.use_connection`. +- `django_idom.types.IdomWebsocket` has been removed. The similar replacement is `django_idom.types.Connection` + ### Fixed +- `view_to_component` will now retain any HTML that was defined in a `` tag. +- React client is now set to `production` rather than `development`. - `use_query` will now utilize `field.related_name` when postprocessing many-to-one relationships +### Security + +- Fixed a potential method of component template tag argument spoofing. +- Exception information will no longer be displayed on the page, based on the value of `settings.py:DEBUG`. + ## [2.2.1] - 2022-01-09 ### Fixed diff --git a/README.md b/README.md index f45f1242..11e19477 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ def hello_world(recipient: str): In your **Django app**'s HTML template, you can now embed your IDOM component using the `component` template tag. Within this tag, you will need to type in your dotted path to the component function as the first argument. -Additionally, you can pass in keyword arguments into your component function. For example, after reading the code below, pay attention to how the function definition for `hello_world` (_in the previous example_) accepts a `recipient` argument. +Additionally, you can pass in `args` and `kwargs` into your component function. For example, after reading the code below, pay attention to how the function definition for `hello_world` (_in the previous example_) accepts a `recipient` argument. diff --git a/docs/src/contribute/code.md b/docs/src/contribute/code.md index 2a6b9feb..3575671b 100644 --- a/docs/src/contribute/code.md +++ b/docs/src/contribute/code.md @@ -10,7 +10,7 @@ If you plan to make code changes to this repository, you will need to install th Once done, you should clone this repository: -```bash +```bash linenums="0" git clone https://github.com/idom-team/django-idom.git cd django-idom ``` @@ -20,13 +20,13 @@ Then, by running the command below you can: - Install an editable version of the Python code - Download, build, and install Javascript dependencies -```bash +```bash linenums="0" pip install -e . -r requirements.txt ``` Finally, to verify that everything is working properly, you can manually run the development webserver. -```bash +```bash linenums="0" cd tests python manage.py runserver ``` diff --git a/docs/src/contribute/docs.md b/docs/src/contribute/docs.md index d918c4b9..666cf6e1 100644 --- a/docs/src/contribute/docs.md +++ b/docs/src/contribute/docs.md @@ -5,7 +5,7 @@ If you plan to make changes to this documentation, you will need to install the Once done, you should clone this repository: -```bash +```bash linenums="0" git clone https://github.com/idom-team/django-idom.git cd django-idom ``` @@ -15,13 +15,13 @@ Then, by running the command below you can: - Install an editable version of the documentation - Self-host a test server for the documentation -```bash +```bash linenums="0" pip install -r ./requirements/build-docs.txt --upgrade ``` Finally, to verify that everything is working properly, you can manually run the docs preview webserver. -```bash +```bash linenums="0" mkdocs serve ``` diff --git a/docs/src/contribute/running-tests.md b/docs/src/contribute/running-tests.md index 8f806e08..5d4c48cf 100644 --- a/docs/src/contribute/running-tests.md +++ b/docs/src/contribute/running-tests.md @@ -7,7 +7,7 @@ If you plan to run tests, you will need to install the following dependencies fi Once done, you should clone this repository: -```bash +```bash linenums="0" git clone https://github.com/idom-team/django-idom.git cd django-idom pip install -r ./requirements/test-run.txt --upgrade @@ -15,12 +15,12 @@ pip install -r ./requirements/test-run.txt --upgrade Then, by running the command below you can run the full test suite: -``` +```bash linenums="0" nox -s test ``` Or, if you want to run the tests in the foreground: -``` +```bash linenums="0" nox -s test -- --headed ``` diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index 7892a120..c21b4dfd 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -25,3 +25,6 @@ idom asgi postfixed postprocessing +serializable +postprocessor +preprocessor diff --git a/docs/src/features/components.md b/docs/src/features/components.md index 9f444d8d..849635ab 100644 --- a/docs/src/features/components.md +++ b/docs/src/features/components.md @@ -77,8 +77,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl - Requires manual intervention to change request methods beyond `GET`. - IDOM events cannot conveniently be attached to converted view HTML. - - Does not currently load any HTML contained with a `` tag - - Has no option to automatically intercept local anchor link (such as `#!html `) click events + - Has no option to automatically intercept local anchor link (such as `#!html `) click events. _Please note these limitations do not exist when using `compatibility` mode._ diff --git a/docs/src/features/decorators.md b/docs/src/features/decorators.md index e643ec77..19ff8726 100644 --- a/docs/src/features/decorators.md +++ b/docs/src/features/decorators.md @@ -4,19 +4,16 @@ ## Auth Required -You can limit access to a component to users with a specific `auth_attribute` by using this decorator. +You can limit access to a component to users with a specific `auth_attribute` by using this decorator (with or without parentheses). By default, this decorator checks if the user is logged in, and his/her account has not been deactivated. This decorator is commonly used to selectively render a component only if a user [`is_staff`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.User.is_staff) or [`is_superuser`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.User.is_superuser). -This decorator can be used with or without parentheses. - === "components.py" ```python from django_idom.decorators import auth_required - from django_idom.hooks import use_websocket from idom import component, html @component @@ -70,7 +67,6 @@ This decorator can be used with or without parentheses. ```python from django_idom.decorators import auth_required - from django_idom.hooks import use_websocket from idom import component, html @component @@ -87,7 +83,6 @@ This decorator can be used with or without parentheses. ```python from django_idom.decorators import auth_required - from django_idom.hooks import use_websocket from idom import component, html @@ -120,7 +115,6 @@ This decorator can be used with or without parentheses. ```python from django_idom.decorators import auth_required - from django_idom.hooks import use_websocket from idom import component, html @component diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md index 07040d0b..5e1f9731 100644 --- a/docs/src/features/hooks.md +++ b/docs/src/features/hooks.md @@ -399,20 +399,22 @@ The function you provide into this hook will have no return value. {% include-markdown "../../includes/orm.md" start="" end="" %} -## Use Websocket +## Use Origin + +This is a shortcut that returns the Websocket's `origin`. -You can fetch the Django Channels [websocket](https://channels.readthedocs.io/en/stable/topics/consumers.html#asyncjsonwebsocketconsumer) at any time by using `use_websocket`. +You can expect this hook to provide strings such as `http://example.com`. === "components.py" ```python from idom import component, html - from django_idom.hooks import use_websocket + from django_idom.hooks import use_origin @component def my_component(): - my_websocket = use_websocket() - return html.div(my_websocket) + my_origin = use_origin() + return html.div(my_origin) ``` ??? example "See Interface" @@ -425,22 +427,22 @@ You can fetch the Django Channels [websocket](https://channels.readthedocs.io/en | Type | Description | | --- | --- | - | `IdomWebsocket` | The component's websocket. | + | `str | None` | A string containing the browser's current origin, obtained from websocket headers (if available). | -## Use Scope +## Use Connection -This is a shortcut that returns the Websocket's [`scope`](https://channels.readthedocs.io/en/stable/topics/consumers.html#scope). +You can fetch the Django Channels [websocket](https://channels.readthedocs.io/en/stable/topics/consumers.html#asyncjsonwebsocketconsumer) at any time by using `use_connection`. === "components.py" ```python from idom import component, html - from django_idom.hooks import use_scope + from django_idom.hooks import use_connection @component def my_component(): - my_scope = use_scope() - return html.div(my_scope) + my_connection = use_connection() + return html.div(my_connection) ``` ??? example "See Interface" @@ -453,24 +455,22 @@ This is a shortcut that returns the Websocket's [`scope`](https://channels.readt | Type | Description | | --- | --- | - | `dict[str, Any]` | The websocket's `scope`. | + | `Connection` | The component's websocket. | -## Use Location - -This is a shortcut that returns the Websocket's `path`. +## Use Scope -You can expect this hook to provide strings such as `/idom/my_path`. +This is a shortcut that returns the Websocket's [`scope`](https://channels.readthedocs.io/en/stable/topics/consumers.html#scope). === "components.py" ```python from idom import component, html - from django_idom.hooks import use_location + from django_idom.hooks import use_scope @component def my_component(): - my_location = use_location() - return html.div(my_location) + my_scope = use_scope() + return html.div(my_scope) ``` ??? example "See Interface" @@ -483,30 +483,24 @@ You can expect this hook to provide strings such as `/idom/my_path`. | Type | Description | | --- | --- | - | `Location` | A object containing the current URL's `pathname` and `search` query. | + | `MutableMapping[str, Any]` | The websocket's `scope`. | -??? info "This hook's behavior will be changed in a future update" - - This hook will be updated to return the browser's currently active path. This change will come in alongside IDOM URL routing support. - - Check out [idom-team/idom-router#2](https://github.com/idom-team/idom-router/issues/2) for more information. - -## Use Origin +## Use Location -This is a shortcut that returns the Websocket's `origin`. +This is a shortcut that returns the Websocket's `path`. -You can expect this hook to provide strings such as `http://example.com`. +You can expect this hook to provide strings such as `/idom/my_path`. === "components.py" ```python from idom import component, html - from django_idom.hooks import use_origin + from django_idom.hooks import use_location @component def my_component(): - my_origin = use_origin() - return html.div(my_origin) + my_location = use_location() + return html.div(my_location) ``` ??? example "See Interface" @@ -519,4 +513,10 @@ You can expect this hook to provide strings such as `http://example.com`. | Type | Description | | --- | --- | - | `str | None` | A string containing the browser's current origin, obtained from websocket headers (if available). | + | `Location` | A object containing the current URL's `pathname` and `search` query. | + +??? info "This hook's behavior will be changed in a future update" + + This hook will be updated to return the browser's currently active path. This change will come in alongside IDOM URL routing support. + + Check out [idom-team/idom-router#2](https://github.com/idom-team/idom-router/issues/2) for more information. diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md index 4eb83fe1..826da81d 100644 --- a/docs/src/features/settings.md +++ b/docs/src/features/settings.md @@ -9,16 +9,17 @@ ```python - # If "idom" cache is not configured, then we will use "default" instead + # If "idom" cache is not configured, then "default" will be used + # IDOM expects a multiprocessing-safe and thread-safe cache backend. CACHES = { "idom": {"BACKEND": ...}, } - # Maximum seconds between two reconnection attempts that would cause the client give up. - # 0 will disable reconnection. - IDOM_WS_MAX_RECONNECT_TIMEOUT = 604800 + # Maximum seconds between reconnection attempts before giving up. + # Use `0` to prevent component reconnection. + IDOM_RECONNECT_MAX = 259200 - # The URL for IDOM to serve websockets + # The URL for IDOM to serve the component rendering websocket IDOM_WEBSOCKET_URL = "idom/" # Dotted path to the default postprocessor function, or `None` diff --git a/docs/src/features/templatetag.md b/docs/src/features/templatetag.md index 6aa58023..7dc34c2e 100644 --- a/docs/src/features/templatetag.md +++ b/docs/src/features/templatetag.md @@ -4,6 +4,8 @@ ## Component +The `component` template tag can be used to insert any number of IDOM components onto your page. + === "my-template.html" {% include-markdown "../../../README.md" start="" end="" %} @@ -12,7 +14,7 @@ ??? warning "Do not use context variables for the IDOM component name" - Our pre-processor relies on the template tag containing a string. + Our preprocessor relies on the template tag containing a string. **Do not** use Django template/context variables for the component path. Failure to follow this warning will result in unexpected behavior. @@ -44,7 +46,7 @@ For this template tag, there are two reserved keyword arguments: `class` and `key` - `class` allows you to apply a HTML class to the top-level component div. This is useful for styling purposes. - - `key` allows you to force the component to use a [specific key value](https://idom-docs.herokuapp.com/docs/guides/understanding-idom/why-idom-needs-keys.html?highlight=key). You typically won't need to set this. + - `key` allows you to force the component to use a [specific key value](https://idom-docs.herokuapp.com/docs/guides/creating-interfaces/rendering-data/index.html#organizing-items-with-keys). Using `key` within a template tag is effectively useless. === "my-template.html" @@ -63,7 +65,8 @@ | Name | Type | Description | Default | | --- | --- | --- | --- | | `dotted_path` | `str` | The dotted path to the component to render. | N/A | - | `**kwargs` | `Any` | The keyword arguments to pass to the component. | N/A | + | `*args` | `Any` | The positional arguments to provide to the component. | N/A | + | `**kwargs` | `Any` | The keyword arguments to provide to the component. | N/A | **Returns** @@ -91,28 +94,8 @@ ``` - But keep in mind, in scenarios where you are trying to create a Single Page Application (SPA) within Django, you will only have one central component within your `#!html ` tag. + Please note that components separated like this will not be able to interact with each other, except through database queries. - Additionally, the components in the example above will not be able to interact with each other, except through database queries. + Additionally, in scenarios where you are trying to create a Single Page Application (SPA) within Django, you will only have one component within your `#!html ` tag. - - -??? question "Can I use positional arguments instead of keyword arguments?" - - You can only pass in **keyword arguments** within the template tag. Due to technical limitations, **positional arguments** are not supported at this time. - - - - -??? question "What is a "template tag"?" - - You can think of template tags as Django's way of allowing you to run Python code within your HTML. Django-IDOM uses a `#!jinja {% component ... %}` template tag to perform it's magic. - - Keep in mind, in order to use the `#!jinja {% component ... %}` tag, you will need to first call `#!jinja {% load idom %}` to gain access to it. - - === "my-template.html" - - {% include-markdown "../../../README.md" start="" end="" %} - - diff --git a/docs/src/features/utils.md b/docs/src/features/utils.md new file mode 100644 index 00000000..9a0aca8b --- /dev/null +++ b/docs/src/features/utils.md @@ -0,0 +1,60 @@ +???+ summary + + Utility functions that you can use when needed. + +## Django Query Postprocessor + +This is the default postprocessor for the `use_query` hook. + +This postprocessor is designed to prevent Django's `SynchronousOnlyException` by recursively fetching all fields within a `Model` or `QuerySet` to prevent [lazy execution](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy). + +=== "components.py" + + ```python + from example_project.my_app.models import TodoItem + from idom import component + from django_idom.hooks import use_query + from django_idom.types import QueryOptions + from django_idom.utils import django_query_postprocessor + + def get_items(): + return TodoItem.objects.all() + + @component + def todo_list(): + # These `QueryOptions` are functionally equivalent to Django-IDOM's default values + item_query = use_query( + QueryOptions( + postprocessor=django_query_postprocessor, + postprocessor_kwargs={"many_to_many": True, "many_to_one": True}, + ), + get_items, + ) + + ... + ``` + +=== "models.py" + + ```python + from django.db import models + + class TodoItem(models.Model): + text = models.CharField(max_length=255) + ``` + +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | `data` | `QuerySet | Model` | The `Model` or `QuerySet` to recursively fetch fields from. | N/A | + | `many_to_many` | `bool` | Whether or not to recursively fetch `ManyToManyField` relationships. | `True` | + | `many_to_one` | `bool` | Whether or not to recursively fetch `ForeignKey` relationships. | `True` | + + **Returns** + + | Type | Description | + | --- | --- | + | `QuerySet | Model` | The `Model` or `QuerySet` with all fields fetched. | diff --git a/docs/src/getting-started/installation.md b/docs/src/getting-started/installation.md index 1224df32..f311abef 100644 --- a/docs/src/getting-started/installation.md +++ b/docs/src/getting-started/installation.md @@ -2,13 +2,13 @@ Django-IDOM can be installed from PyPI to an existing **Django project** with minimal configuration. -## Step 0: Set up a Django Project +## Step 0: Create a Django Project These docs assumes you have already created [a **Django project**](https://docs.djangoproject.com/en/dev/intro/tutorial01/), which involves creating and installing at least one **Django app**. If not, check out this [9 minute YouTube tutorial](https://www.youtube.com/watch?v=ZsJRXS_vrw0) created by _IDG TECHtalk_. ## Step 1: Install from PyPI -```bash +```bash linenums="0" pip install django-idom ``` @@ -96,3 +96,11 @@ Register IDOM's Websocket using `IDOM_WEBSOCKET_PATH`. ??? question "Where is my `asgi.py`?" If you do not have an `asgi.py`, follow the [`channels` installation guide](https://channels.readthedocs.io/en/stable/installation.html). + +## Step 5: Run Migrations + +Run Django's database migrations to initialize Django-IDOM's database table. + +```bash linenums="0" +python manage.py migrate +``` diff --git a/docs/src/getting-started/reference-component.md b/docs/src/getting-started/reference-component.md index f7c03ecb..1576f3ee 100644 --- a/docs/src/getting-started/reference-component.md +++ b/docs/src/getting-started/reference-component.md @@ -16,10 +16,6 @@ {% include-markdown "../features/templatetag.md" start="" end="" %} -{% include-markdown "../features/templatetag.md" start="" end="" %} - -{% include-markdown "../features/templatetag.md" start="" end="" %} - ??? question "Where is my templates folder?" If you do not have a `templates` folder in your **Django app**, you can simply create one! Keep in mind, templates within this folder will not be detected by Django unless you [add the corresponding **Django app** to `settings.py:INSTALLED_APPS`](https://docs.djangoproject.com/en/dev/ref/applications/#configuring-applications). diff --git a/mkdocs.yml b/mkdocs.yml index f51bc051..2a60cb5c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,7 @@ nav: - Components: features/components.md - Hooks: features/hooks.md - Decorators: features/decorators.md + - Utilities: features/utils.md - Template Tag: features/templatetag.md - Settings: features/settings.md - Contribute: diff --git a/pyproject.toml b/pyproject.toml index a8a2fb0a..c9321d8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,11 +10,15 @@ ensure_newline_before_comments = "True" include_trailing_comma = "True" line_length = 88 lines_after_imports = 2 -extend_skip_glob = ["*/migrations/*"] +extend_skip_glob = ["*/migrations/*", '.nox/*', '.venv/*', 'build/*'] [tool.mypy] +exclude = [ + 'migrations/.*', +] ignore_missing_imports = true warn_unused_configs = true warn_redundant_casts = true warn_unused_ignores = true check_untyped_defs = true +incremental = false diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index d2329eb6..441e7a04 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,4 +1,5 @@ channels >=4.0.0 -idom >=0.40.2, <0.41.0 +idom >=0.43.0, <0.44.0 aiofile >=3.0 +dill >=0.3.5 typing_extensions diff --git a/src/django_idom/__init__.py b/src/django_idom/__init__.py index c1991661..ca378289 100644 --- a/src/django_idom/__init__.py +++ b/src/django_idom/__init__.py @@ -1,12 +1,10 @@ from django_idom import components, decorators, hooks, types, utils -from django_idom.types import IdomWebsocket from django_idom.websocket.paths import IDOM_WEBSOCKET_PATH __version__ = "2.2.1" __all__ = [ "IDOM_WEBSOCKET_PATH", - "IdomWebsocket", "hooks", "components", "decorators", diff --git a/src/django_idom/apps.py b/src/django_idom/apps.py index 6a963664..f91b50b7 100644 --- a/src/django_idom/apps.py +++ b/src/django_idom/apps.py @@ -1,6 +1,12 @@ +import logging + from django.apps import AppConfig +from django.db.utils import OperationalError + +from django_idom.utils import ComponentPreloader, db_cleanup -from django_idom.utils import ComponentPreloader + +_logger = logging.getLogger(__name__) class DjangoIdomConfig(AppConfig): @@ -8,4 +14,13 @@ class DjangoIdomConfig(AppConfig): def ready(self): # Populate the IDOM component registry when Django is ready - ComponentPreloader().register_all() + ComponentPreloader().run() + + # Delete expired database entries + # Suppress exceptions to avoid issues with `manage.py` commands such as + # `test`, `migrate`, `makemigrations`, or any custom user created commands + # where the database may not be ready. + try: + db_cleanup(immediate=True) + except OperationalError: + _logger.debug("IDOM database was not ready at startup. Skipping cleanup...") diff --git a/src/django_idom/components.py b/src/django_idom/components.py index 163eb47b..fd9d5292 100644 --- a/src/django_idom/components.py +++ b/src/django_idom/components.py @@ -11,9 +11,8 @@ from idom import component, hooks, html, utils from idom.types import ComponentType, Key, VdomDict -from django_idom.config import IDOM_CACHE, IDOM_VIEW_COMPONENT_IFRAMES from django_idom.types import ViewComponentIframe -from django_idom.utils import _generate_obj_name, render_view +from django_idom.utils import generate_obj_name, render_view class _ViewComponentConstructor(Protocol): @@ -33,6 +32,8 @@ def _view_to_component( args: Sequence | None, kwargs: dict | None, ): + from django_idom.config import IDOM_VIEW_COMPONENT_IFRAMES + converted_view, set_converted_view = hooks.use_state( cast(Union[VdomDict, None], None) ) @@ -47,8 +48,8 @@ def _view_to_component( # Render the view render within a hook @hooks.use_effect( dependencies=[ - json.dumps(vars(_request), default=lambda x: _generate_obj_name(x)), - json.dumps([_args, _kwargs], default=lambda x: _generate_obj_name(x)), + json.dumps(vars(_request), default=lambda x: generate_obj_name(x)), + json.dumps([_args, _kwargs], default=lambda x: generate_obj_name(x)), ] ) async def async_render(): @@ -62,6 +63,7 @@ async def async_render(): set_converted_view( utils.html_to_vdom( response.content.decode("utf-8").strip(), + utils.del_html_head_body_transform, *transforms, strict=strict_parsing, ) @@ -130,7 +132,7 @@ def view_to_component( perfectly adhere to HTML5. Returns: - Callable: A function that takes `request: HttpRequest | None, *args: Any, key: Key | None, **kwargs: Any` + A function that takes `request: HttpRequest | None, *args: Any, key: Key | None, **kwargs: Any` and returns an IDOM component. """ @@ -197,6 +199,8 @@ def django_js(static_path: str, key: Key | None = None): def _cached_static_contents(static_path: str): + from django_idom.config import IDOM_CACHE + # Try to find the file within Django's static files abs_path = find(static_path) if not abs_path: diff --git a/src/django_idom/config.py b/src/django_idom/config.py index 0e1d4cd7..a7b4ca8d 100644 --- a/src/django_idom/config.py +++ b/src/django_idom/config.py @@ -4,12 +4,14 @@ from django.conf import settings from django.core.cache import DEFAULT_CACHE_ALIAS, BaseCache, caches +from idom.config import IDOM_DEBUG_MODE from idom.core.types import ComponentConstructor -from django_idom.defaults import _DEFAULT_QUERY_POSTPROCESSOR from django_idom.types import Postprocessor, ViewComponentIframe +from django_idom.utils import import_dotted_path +IDOM_DEBUG_MODE.set_current(getattr(settings, "DEBUG")) IDOM_REGISTERED_COMPONENTS: Dict[str, ComponentConstructor] = {} IDOM_VIEW_COMPONENT_IFRAMES: Dict[str, ViewComponentIframe] = {} IDOM_WEBSOCKET_URL = getattr( @@ -17,14 +19,20 @@ "IDOM_WEBSOCKET_URL", "idom/", ) -IDOM_WS_MAX_RECONNECT_TIMEOUT = getattr( +IDOM_RECONNECT_MAX = getattr( settings, - "IDOM_WS_MAX_RECONNECT_TIMEOUT", - 604800, + "IDOM_RECONNECT_MAX", + 259200, # Default to 3 days ) IDOM_CACHE: BaseCache = ( caches["idom"] if "idom" in getattr(settings, "CACHES", {}) else caches[DEFAULT_CACHE_ALIAS] ) -IDOM_DEFAULT_QUERY_POSTPROCESSOR: Postprocessor | None = _DEFAULT_QUERY_POSTPROCESSOR +IDOM_DEFAULT_QUERY_POSTPROCESSOR: Postprocessor | None = import_dotted_path( + getattr( + settings, + "IDOM_DEFAULT_QUERY_POSTPROCESSOR", + "django_idom.utils.django_query_postprocessor", + ) +) diff --git a/src/django_idom/decorators.py b/src/django_idom/decorators.py index 6d01c990..fb78f74e 100644 --- a/src/django_idom/decorators.py +++ b/src/django_idom/decorators.py @@ -5,7 +5,7 @@ from idom.core.types import ComponentType, VdomDict -from django_idom.hooks import use_websocket +from django_idom.hooks import use_scope def auth_required( @@ -27,9 +27,9 @@ def auth_required( def decorator(component): @wraps(component) def _wrapped_func(*args, **kwargs): - websocket = use_websocket() + scope = use_scope() - if getattr(websocket.scope["user"], auth_attribute): + if getattr(scope["user"], auth_attribute): return component(*args, **kwargs) return fallback(*args, **kwargs) if callable(fallback) else fallback diff --git a/src/django_idom/defaults.py b/src/django_idom/defaults.py deleted file mode 100644 index bc0530bb..00000000 --- a/src/django_idom/defaults.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import annotations - -from typing import Any, Callable - -from django.conf import settings - -from django_idom.utils import _import_dotted_path - - -_DEFAULT_QUERY_POSTPROCESSOR: Callable[..., Any] | None = _import_dotted_path( - getattr( - settings, - "IDOM_DEFAULT_QUERY_POSTPROCESSOR", - "django_idom.utils.django_query_postprocessor", - ) -) diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py index 4455f779..dd17ca16 100644 --- a/src/django_idom/hooks.py +++ b/src/django_idom/hooks.py @@ -7,6 +7,7 @@ Awaitable, Callable, DefaultDict, + MutableMapping, Sequence, Union, cast, @@ -15,18 +16,21 @@ from channels.db import database_sync_to_async as _database_sync_to_async from idom import use_callback, use_ref +from idom.backend.hooks import use_connection as _use_connection +from idom.backend.hooks import use_location as _use_location +from idom.backend.hooks import use_scope as _use_scope from idom.backend.types import Location -from idom.core.hooks import Context, create_context, use_context, use_effect, use_state +from idom.core.hooks import use_effect, use_state from django_idom.types import ( - IdomWebsocket, + Connection, Mutation, Query, QueryOptions, _Params, _Result, ) -from django_idom.utils import _generate_obj_name +from django_idom.utils import generate_obj_name _logger = logging.getLogger(__name__) @@ -34,7 +38,6 @@ 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) @@ -42,16 +45,13 @@ def use_location() -> Location: """Get the current route as a `Location` object""" - # TODO: Use the browser's current page, rather than the WS route - scope = use_scope() - search = scope["query_string"].decode() - return Location(scope["path"], f"?{search}" if search else "") + return _use_location() def use_origin() -> str | None: """Get the current origin as a string. If the browser did not send an origin header, this will be None.""" - scope = use_scope() + scope = _use_scope() try: return next( ( @@ -65,17 +65,14 @@ def use_origin() -> str | None: return None -def use_scope() -> dict[str, Any]: +def use_scope() -> MutableMapping[str, Any]: """Get the current ASGI scope dictionary""" - return use_websocket().scope + return _use_scope() -def use_websocket() -> IdomWebsocket: - """Get the current IdomWebsocket object""" - websocket = use_context(WebsocketContext) - if websocket is None: - raise RuntimeError("No websocket. Are you running with a Django server?") - return websocket +def use_connection() -> Connection: + """Get the current `Connection` object""" + return _use_connection() @overload @@ -165,7 +162,7 @@ def execute_query() -> None: set_loading(False) set_error(e) _logger.exception( - f"Failed to execute query: {_generate_obj_name(query) or query}" + f"Failed to execute query: {generate_obj_name(query) or query}" ) return finally: @@ -208,7 +205,7 @@ def execute_mutation() -> None: set_loading(False) set_error(e) _logger.exception( - f"Failed to execute mutation: {_generate_obj_name(mutate) or mutate}" + f"Failed to execute mutation: {generate_obj_name(mutate) or mutate}" ) else: set_loading(False) diff --git a/src/django_idom/http/views.py b/src/django_idom/http/views.py index 57233bab..d8858f4c 100644 --- a/src/django_idom/http/views.py +++ b/src/django_idom/http/views.py @@ -5,13 +5,14 @@ from django.http import HttpRequest, HttpResponse, HttpResponseNotFound from idom.config import IDOM_WED_MODULES_DIR -from django_idom.config import IDOM_CACHE, IDOM_VIEW_COMPONENT_IFRAMES -from django_idom.utils import render_view +from django_idom.utils import create_cache_key, render_view async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse: """Gets JavaScript required for IDOM modules at runtime. These modules are returned from cache if available.""" + from django_idom.config import IDOM_CACHE + web_modules_dir = IDOM_WED_MODULES_DIR.current path = os.path.abspath(web_modules_dir.joinpath(*file.split("/"))) @@ -23,7 +24,7 @@ async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse: # Fetch the file from cache, if available last_modified_time = os.stat(path).st_mtime - cache_key = f"django_idom:web_module:{str(path).lstrip(str(web_modules_dir))}" + cache_key = create_cache_key("web_module", str(path).lstrip(str(web_modules_dir))) response = await IDOM_CACHE.aget(cache_key, version=int(last_modified_time)) if response is None: async with async_open(path, "r") as fp: @@ -40,6 +41,8 @@ async def view_to_component_iframe( ) -> HttpResponse: """Returns a view that was registered by view_to_component. This view is intended to be used as iframe, for compatibility purposes.""" + from django_idom.config import IDOM_VIEW_COMPONENT_IFRAMES + # Get the view from IDOM_REGISTERED_IFRAMES iframe = IDOM_VIEW_COMPONENT_IFRAMES.get(view_path) if not iframe: diff --git a/src/django_idom/migrations/0001_initial.py b/src/django_idom/migrations/0001_initial.py new file mode 100644 index 00000000..3ad19d56 --- /dev/null +++ b/src/django_idom/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 4.1.3 on 2023-01-10 00:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="ComponentParams", + fields=[ + ( + "uuid", + models.UUIDField( + editable=False, primary_key=True, serialize=False, unique=True + ), + ), + ("data", models.BinaryField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/src/django_idom/migrations/0002_rename_created_at_componentparams_last_accessed.py b/src/django_idom/migrations/0002_rename_created_at_componentparams_last_accessed.py new file mode 100644 index 00000000..80207bce --- /dev/null +++ b/src/django_idom/migrations/0002_rename_created_at_componentparams_last_accessed.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.5 on 2023-01-11 01:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("django_idom", "0001_initial"), + ] + + operations = [ + migrations.RenameField( + model_name="componentparams", + old_name="created_at", + new_name="last_accessed", + ), + ] diff --git a/src/django_idom/migrations/__init__.py b/src/django_idom/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/django_idom/models.py b/src/django_idom/models.py new file mode 100644 index 00000000..1e67f368 --- /dev/null +++ b/src/django_idom/models.py @@ -0,0 +1,7 @@ +from django.db import models + + +class ComponentParams(models.Model): + uuid = models.UUIDField(primary_key=True, editable=False, unique=True) # type: ignore + data = models.BinaryField(editable=False) # type: ignore + last_accessed = models.DateTimeField(auto_now_add=True) # type: ignore diff --git a/src/django_idom/templates/idom/component.html b/src/django_idom/templates/idom/component.html index fc8ba6f8..7fd82c22 100644 --- a/src/django_idom/templates/idom/component.html +++ b/src/django_idom/templates/idom/component.html @@ -2,13 +2,12 @@
diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index b6bb6868..4309177b 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -1,13 +1,13 @@ -import json -from typing import Any -from urllib.parse import urlencode from uuid import uuid4 +import dill as pickle from django import template from django.urls import reverse -from django_idom.config import IDOM_WEBSOCKET_URL, IDOM_WS_MAX_RECONNECT_TIMEOUT -from django_idom.utils import _register_component +from django_idom import models +from django_idom.config import IDOM_RECONNECT_MAX, IDOM_WEBSOCKET_URL +from django_idom.types import ComponentParamData +from django_idom.utils import _register_component, func_has_params IDOM_WEB_MODULES_URL = reverse("idom:web_modules", args=["x"])[:-1][1:] @@ -15,14 +15,15 @@ @register.inclusion_tag("idom/component.html") -def component(dotted_path: str, **kwargs: Any): +def component(dotted_path: str, *args, **kwargs): """This tag is used to embed an existing IDOM component into your HTML template. Args: dotted_path: The dotted path to the component to render. + *args: The positional arguments to provide to the component. Keyword Args: - **kwargs: The keyword arguments to pass to the component. + **kwargs: The keyword arguments to provide to the component. Example :: @@ -34,17 +35,29 @@ def component(dotted_path: str, **kwargs: Any): """ - _register_component(dotted_path) - + component = _register_component(dotted_path) + uuid = uuid4().hex class_ = kwargs.pop("class", "") - json_kwargs = json.dumps(kwargs, separators=(",", ":")) + kwargs.pop("key", "") # `key` is effectively useless for the root node + + # Store the component's args/kwargs in the database if needed + # This will be fetched by the websocket consumer later + try: + if func_has_params(component, *args, **kwargs): + params = ComponentParamData(args, kwargs) + model = models.ComponentParams(uuid=uuid, data=pickle.dumps(params)) + model.full_clean() + model.save() + except TypeError as e: + raise TypeError( + f"The provided parameters are incompatible with component '{dotted_path}'." + ) from e return { "class": class_, "idom_websocket_url": IDOM_WEBSOCKET_URL, "idom_web_modules_url": IDOM_WEB_MODULES_URL, - "idom_ws_max_reconnect_timeout": IDOM_WS_MAX_RECONNECT_TIMEOUT, - "idom_mount_uuid": uuid4().hex, - "idom_component_id": dotted_path, - "idom_component_params": urlencode({"kwargs": json_kwargs}), + "idom_reconnect_max": IDOM_RECONNECT_MAX, + "idom_mount_uuid": uuid, + "idom_component_path": f"{dotted_path}/{uuid}/", } diff --git a/src/django_idom/types.py b/src/django_idom/types.py index 7705c98b..1427d62a 100644 --- a/src/django_idom/types.py +++ b/src/django_idom/types.py @@ -6,6 +6,7 @@ Awaitable, Callable, Generic, + MutableMapping, Optional, Protocol, Sequence, @@ -16,12 +17,23 @@ from django.db.models.base import Model from django.db.models.query import QuerySet from django.views.generic import View +from idom.types import Connection as _Connection from typing_extensions import ParamSpec -from django_idom.defaults import _DEFAULT_QUERY_POSTPROCESSOR - -__all__ = ["_Result", "_Params", "_Data", "IdomWebsocket", "Query", "Mutation"] +__all__ = [ + "_Result", + "_Params", + "_Data", + "ComponentWebsocket", + "Query", + "Mutation", + "Connection", + "ViewComponentIframe", + "Postprocessor", + "QueryOptions", + "ComponentParamData", +] _Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]]) _Params = ParamSpec("_Params") @@ -29,13 +41,15 @@ @dataclass -class IdomWebsocket: - """Websocket returned by the `use_websocket` hook.""" +class ComponentWebsocket: + """Carrier type for the `use_connection` hook.""" - scope: dict close: Callable[[Optional[int]], Awaitable[None]] disconnect: Callable[[int], Awaitable[None]] - view_id: str + dotted_path: str + + +Connection = _Connection[ComponentWebsocket] @dataclass @@ -74,7 +88,9 @@ def __call__(self, data: Any) -> Any: class QueryOptions: """Configuration options that can be provided to `use_query`.""" - postprocessor: Postprocessor | None = _DEFAULT_QUERY_POSTPROCESSOR + from django_idom.config import IDOM_DEFAULT_QUERY_POSTPROCESSOR + + postprocessor: Postprocessor | None = IDOM_DEFAULT_QUERY_POSTPROCESSOR """A callable that can modify the query `data` after the query has been executed. The first argument of postprocessor must be the query `data`. All proceeding arguments @@ -87,5 +103,14 @@ class QueryOptions: additionally can be configured via `postprocessor_kwargs` to recursively fetch `many_to_many` and `many_to_one` fields.""" - postprocessor_kwargs: dict[str, Any] = field(default_factory=lambda: {}) + postprocessor_kwargs: MutableMapping[str, Any] = field(default_factory=lambda: {}) """Keyworded arguments directly passed into the `postprocessor` for configuration.""" + + +@dataclass +class ComponentParamData: + """Container used for serializing component parameters. + This dataclass is pickled & stored in the database, then unpickled when needed.""" + + args: Sequence + kwargs: MutableMapping[str, Any] diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py index 52a4eed0..a0963b35 100644 --- a/src/django_idom/utils.py +++ b/src/django_idom/utils.py @@ -1,9 +1,11 @@ from __future__ import annotations import contextlib +import inspect import logging import os import re +from datetime import datetime, timedelta from fnmatch import fnmatch from importlib import import_module from inspect import iscoroutinefunction @@ -16,6 +18,7 @@ from django.db.models.query import QuerySet from django.http import HttpRequest, HttpResponse from django.template import engines +from django.utils import timezone from django.utils.encoding import smart_str from django.views import View @@ -34,6 +37,7 @@ + _component_kwargs + r"\s*%}" ) +DATE_FORMAT = "%Y-%m-%d %H:%M:%S" async def render_view( @@ -74,17 +78,21 @@ async def render_view( return response -def _register_component(dotted_path: str) -> None: +def _register_component(dotted_path: str) -> Callable: + """Adds a component to the mapping of registered components. + This should only be called on startup to maintain synchronization during mulitprocessing. + """ from django_idom.config import IDOM_REGISTERED_COMPONENTS if dotted_path in IDOM_REGISTERED_COMPONENTS: - return + return IDOM_REGISTERED_COMPONENTS[dotted_path] - IDOM_REGISTERED_COMPONENTS[dotted_path] = _import_dotted_path(dotted_path) + IDOM_REGISTERED_COMPONENTS[dotted_path] = import_dotted_path(dotted_path) _logger.debug("IDOM has registered component %s", dotted_path) + return IDOM_REGISTERED_COMPONENTS[dotted_path] -def _import_dotted_path(dotted_path: str) -> Callable: +def import_dotted_path(dotted_path: str) -> Callable: """Imports a dotted path and returns the callable.""" module_name, component_name = dotted_path.rsplit(".", 1) @@ -99,24 +107,28 @@ def _import_dotted_path(dotted_path: str) -> Callable: class ComponentPreloader: - def register_all(self): + """Preloads all IDOM components found within Django templates. + This should only be `run` once on startup to maintain synchronization during mulitprocessing. + """ + + def run(self): """Registers all IDOM components found within Django templates.""" # Get all template folder paths - paths = self._get_paths() + paths = self.get_paths() # Get all HTML template files - templates = self._get_templates(paths) + templates = self.get_templates(paths) # Get all components - components = self._get_components(templates) + components = self.get_components(templates) # Register all components - self._register_components(components) + self.register_components(components) - def _get_loaders(self): + def get_loaders(self): """Obtains currently configured template loaders.""" template_source_loaders = [] for e in engines.all(): if hasattr(e, "engine"): template_source_loaders.extend( - e.engine.get_template_loaders(e.engine.loaders) # type: ignore + e.engine.get_template_loaders(e.engine.loaders) ) loaders = [] for loader in template_source_loaders: @@ -126,10 +138,10 @@ def _get_loaders(self): loaders.append(loader) return loaders - def _get_paths(self) -> set[str]: + def get_paths(self) -> set[str]: """Obtains a set of all template directories.""" paths: set[str] = set() - for loader in self._get_loaders(): + for loader in self.get_loaders(): with contextlib.suppress(ImportError, AttributeError, TypeError): module = import_module(loader.__module__) get_template_sources = getattr(module, "get_template_sources", None) @@ -138,7 +150,7 @@ def _get_paths(self) -> set[str]: paths.update(smart_str(origin) for origin in get_template_sources("")) return paths - def _get_templates(self, paths: set[str]) -> set[str]: + def get_templates(self, paths: set[str]) -> set[str]: """Obtains a set of all HTML template paths.""" extensions = [".html"] templates: set[str] = set() @@ -153,7 +165,7 @@ def _get_templates(self, paths: set[str]) -> set[str]: return templates - def _get_components(self, templates: set[str]) -> set[str]: + def get_components(self, templates: set[str]) -> set[str]: """Obtains a set of all IDOM components by parsing HTML templates.""" components: set[str] = set() for template in templates: @@ -177,7 +189,7 @@ def _get_components(self, templates: set[str]) -> set[str]: ) return components - def _register_components(self, components: set[str]) -> None: + def register_components(self, components: set[str]) -> None: """Registers all IDOM components in an iterable.""" for component in components: try: @@ -194,7 +206,7 @@ def _register_components(self, components: set[str]) -> None: ) -def _generate_obj_name(object: Any) -> str | None: +def generate_obj_name(object: Any) -> str | None: """Makes a best effort to create a name for an object. Useful for JSON serialization of Python objects.""" if hasattr(object, "__module__"): @@ -210,7 +222,18 @@ def django_query_postprocessor( ) -> QuerySet | Model: """Recursively fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily. - Some behaviors can be modified through `query_options` attributes.""" + Behaviors can be modified through `QueryOptions` within your `use_query` hook. + + Args: + data: The `Model` or `QuerySet` to recursively fetch fields from. + + Keyword Args: + many_to_many: Whether or not to recursively fetch `ManyToManyField` relationships. + many_to_one: Whether or not to recursively fetch `ForeignKey` relationships. + + Returns: + The `Model` or `QuerySet` with all fields fetched. + """ # `QuerySet`, which is an iterable of `Model`/`QuerySet` instances # https://github.com/typeddjango/django-stubs/issues/704 @@ -257,3 +280,59 @@ def django_query_postprocessor( ) return data + + +def func_has_params(func: Callable, *args, **kwargs) -> bool: + """Checks if a function has any args or kwarg parameters. + + Can optionally validate whether a set of args/kwargs would work on the given function. + """ + signature = inspect.signature(func) + + # Check if the function has any args/kwargs + if not args and not kwargs: + return str(signature) != "()" + + # Check if the function has the given args/kwargs + signature.bind(*args, **kwargs) + return True + + +def create_cache_key(*args): + """Creates a cache key string that starts with `django_idom` contains + all *args separated by `:`.""" + + if not args: + raise ValueError("At least one argument is required to create a cache key.") + + return f"django_idom:{':'.join(str(arg) for arg in args)}" + + +def db_cleanup(immediate: bool = False): + """Deletes expired component parameters from the database. + This function may be expanded in the future to include additional cleanup tasks.""" + from .config import IDOM_CACHE, IDOM_RECONNECT_MAX + from .models import ComponentParams + + cache_key: str = create_cache_key("last_cleaned") + now_str: str = datetime.strftime(timezone.now(), DATE_FORMAT) + cleaned_at_str: str = IDOM_CACHE.get(cache_key) + cleaned_at: datetime = timezone.make_aware( + datetime.strptime(cleaned_at_str or now_str, DATE_FORMAT) + ) + clean_needed_by = cleaned_at + timedelta(seconds=IDOM_RECONNECT_MAX) + expires_by: datetime = timezone.now() - timedelta(seconds=IDOM_RECONNECT_MAX) + + # Component params exist in the DB, but we don't know when they were last cleaned + if not cleaned_at_str and ComponentParams.objects.all(): + _logger.warning( + "IDOM has detected component sessions in the database, " + "but no timestamp was found in cache. This may indicate that " + "the cache has been cleared." + ) + + # Delete expired component parameters + # Use timestamps in cache (`cleaned_at_str`) as a no-dependency rate limiter + if immediate or not cleaned_at_str or timezone.now() >= clean_needed_by: + ComponentParams.objects.filter(last_accessed__lte=expires_by).delete() + IDOM_CACHE.set(cache_key, now_str) diff --git a/src/django_idom/websocket/consumer.py b/src/django_idom/websocket/consumer.py index c4c78971..fa90ca48 100644 --- a/src/django_idom/websocket/consumer.py +++ b/src/django_idom/websocket/consumer.py @@ -1,19 +1,23 @@ """Anything used to construct a websocket endpoint""" +from __future__ import annotations + import asyncio -import json import logging -from typing import Any -from urllib.parse import parse_qsl +from datetime import timedelta +from typing import Any, MutableMapping, Sequence +import dill as pickle from channels.auth import login from channels.db import database_sync_to_async as convert_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer +from django.utils import timezone +from idom.backend.hooks import ConnectionContext +from idom.backend.types import Connection, Location from idom.core.layout import Layout, LayoutEvent from idom.core.serve import serve_json_patch -from django_idom.config import IDOM_REGISTERED_COMPONENTS -from django_idom.hooks import WebsocketContext -from django_idom.types import IdomWebsocket +from django_idom.types import ComponentParamData, ComponentWebsocket +from django_idom.utils import db_cleanup, func_has_params _logger = logging.getLogger(__name__) @@ -23,9 +27,11 @@ class IdomAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer): """Communicates with the browser to perform actions on-demand.""" async def connect(self) -> None: + from django.contrib.auth.models import AbstractBaseUser + await super().connect() - user = self.scope.get("user") + user: AbstractBaseUser = self.scope.get("user") if user and user.is_authenticated: try: await login(self.scope, user) @@ -48,22 +54,64 @@ async def receive_json(self, content: Any, **kwargs: Any) -> None: await self._idom_recv_queue.put(LayoutEvent(**content)) async def _run_dispatch_loop(self): - view_id = self.scope["url_route"]["kwargs"]["view_id"] - + from django_idom import models + from django_idom.config import IDOM_RECONNECT_MAX, IDOM_REGISTERED_COMPONENTS + + scope = self.scope + dotted_path = scope["url_route"]["kwargs"]["dotted_path"] + uuid = scope["url_route"]["kwargs"]["uuid"] + search = scope["query_string"].decode() + self._idom_recv_queue: asyncio.Queue = asyncio.Queue() + connection = Connection( # For `use_connection` + scope=scope, + location=Location( + pathname=scope["path"], + search=f"?{search}" if (search and (search != "undefined")) else "", + ), + carrier=ComponentWebsocket(self.close, self.disconnect, dotted_path), + ) + now = timezone.now() + component_args: Sequence[Any] = tuple() + component_kwargs: MutableMapping[str, Any] = {} + + # Verify the component has already been registered try: - component_constructor = IDOM_REGISTERED_COMPONENTS[view_id] + component_constructor = IDOM_REGISTERED_COMPONENTS[dotted_path] except KeyError: - _logger.warning(f"Unknown IDOM view ID {view_id!r}") + _logger.warning( + f"Attempt to access invalid IDOM component: {dotted_path!r}" + ) return - query_dict = dict(parse_qsl(self.scope["query_string"].decode())) - component_kwargs = json.loads(query_dict.get("kwargs", "{}")) - - # Provide developer access to parts of this websocket - socket = IdomWebsocket(self.scope, self.close, self.disconnect, view_id) - + # Fetch the component's args/kwargs from the database, if needed try: - component_instance = component_constructor(**component_kwargs) + if func_has_params(component_constructor): + try: + # Always clean up expired entries first + await convert_to_async(db_cleanup)() + + # Get the queries from a DB + params_query = await models.ComponentParams.objects.aget( + uuid=uuid, + last_accessed__gt=now - timedelta(seconds=IDOM_RECONNECT_MAX), + ) + params_query.last_accessed = timezone.now() + await convert_to_async(params_query.save)() + except models.ComponentParams.DoesNotExist: + _logger.warning( + f"Browser has attempted to access '{dotted_path}', " + f"but the component has already expired beyond IDOM_RECONNECT_MAX. " + "If this was expected, this warning can be ignored." + ) + return + component_params: ComponentParamData = pickle.loads(params_query.data) + component_args = component_params.args + component_kwargs = component_params.kwargs + + # Generate the initial component instance + component_instance = component_constructor( + *component_args, **component_kwargs + ) except Exception: _logger.exception( f"Failed to construct component {component_constructor} " @@ -71,12 +119,12 @@ async def _run_dispatch_loop(self): ) return - self._idom_recv_queue = recv_queue = asyncio.Queue() # type: ignore + # Begin serving the IDOM component try: await serve_json_patch( - Layout(WebsocketContext(component_instance, value=socket)), + Layout(ConnectionContext(component_instance, value=connection)), self.send_json, - recv_queue.get, + self._idom_recv_queue.get, ) except Exception: await self.close() diff --git a/src/django_idom/websocket/paths.py b/src/django_idom/websocket/paths.py index f337c83e..fab68aee 100644 --- a/src/django_idom/websocket/paths.py +++ b/src/django_idom/websocket/paths.py @@ -6,7 +6,7 @@ IDOM_WEBSOCKET_PATH = path( - f"{IDOM_WEBSOCKET_URL}/", IdomAsyncWebsocketConsumer.as_asgi() + f"{IDOM_WEBSOCKET_URL}//", IdomAsyncWebsocketConsumer.as_asgi() ) """A URL path for :class:`IdomAsyncWebsocketConsumer`. diff --git a/src/js/package-lock.json b/src/js/package-lock.json index a11a055a..fc51d27a 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -1,552 +1,552 @@ { - "name": "js", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "dependencies": { - "idom-client-react": "^0.40.2", - "react": "^17.0.2", - "react-dom": "^17.0.2" - }, - "devDependencies": { - "prettier": "^2.2.1", - "rollup": "^2.56.3", - "rollup-plugin-commonjs": "^10.1.0", - "rollup-plugin-node-resolve": "^5.2.0", - "rollup-plugin-replace": "^2.2.0" - } - }, - "node_modules/@types/estree": { - "version": "0.0.48", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.48.tgz", - "integrity": "sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew==", - "dev": true - }, - "node_modules/@types/node": { - "version": "15.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.2.tgz", - "integrity": "sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww==", - "dev": true - }, - "node_modules/@types/resolve": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", - "integrity": "sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/builtin-modules": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz", - "integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/estree-walker": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", - "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", - "dev": true - }, - "node_modules/fast-json-patch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.0.tgz", - "integrity": "sha512-IhpytlsVTRndz0hU5t0/MGzS/etxLlfrpG5V5M9mVbuj9TrJLWaMfsox9REM5rkuGX0T+5qjpe8XA1o0gZ42nA==" - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/htm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", - "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" - }, - "node_modules/idom-client-react": { - "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" - }, - "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" - } - }, - "node_modules/is-core-module": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz", - "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==", - "dev": true, - "dependencies": { - "has": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", - "dev": true - }, - "node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "dev": true, - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/magic-string": { - "version": "0.25.7", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", - "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", - "dev": true, - "dependencies": { - "sourcemap-codec": "^1.4.4" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/prettier": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz", - "integrity": "sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==", - "dev": true, - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" - }, - "peerDependencies": { - "react": "17.0.2" - } - }, - "node_modules/resolve": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", - "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", - "dev": true, - "dependencies": { - "is-core-module": "^2.2.0", - "path-parse": "^1.0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/rollup": { - "version": "2.56.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.56.3.tgz", - "integrity": "sha512-Au92NuznFklgQCUcV96iXlxUbHuB1vQMaH76DHl5M11TotjOHwqk9CwcrT78+Tnv4FN9uTBxq6p4EJoYkpyekg==", - "dev": true, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup-plugin-commonjs": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-10.1.0.tgz", - "integrity": "sha512-jlXbjZSQg8EIeAAvepNwhJj++qJWNJw1Cl0YnOqKtP5Djx+fFGkp3WRh+W0ASCaFG5w1jhmzDxgu3SJuVxPF4Q==", - "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-commonjs.", - "dev": true, - "dependencies": { - "estree-walker": "^0.6.1", - "is-reference": "^1.1.2", - "magic-string": "^0.25.2", - "resolve": "^1.11.0", - "rollup-pluginutils": "^2.8.1" - }, - "peerDependencies": { - "rollup": ">=1.12.0" - } - }, - "node_modules/rollup-plugin-node-resolve": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-5.2.0.tgz", - "integrity": "sha512-jUlyaDXts7TW2CqQ4GaO5VJ4PwwaV8VUGA7+km3n6k6xtOEacf61u0VXwN80phY/evMcaS+9eIeJ9MOyDxt5Zw==", - "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-node-resolve.", - "dev": true, - "dependencies": { - "@types/resolve": "0.0.8", - "builtin-modules": "^3.1.0", - "is-module": "^1.0.0", - "resolve": "^1.11.1", - "rollup-pluginutils": "^2.8.1" - }, - "peerDependencies": { - "rollup": ">=1.11.0" - } - }, - "node_modules/rollup-plugin-replace": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-replace/-/rollup-plugin-replace-2.2.0.tgz", - "integrity": "sha512-/5bxtUPkDHyBJAKketb4NfaeZjL5yLZdeUihSfbF2PQMz+rSTEb8ARKoOl3UBT4m7/X+QOXJo3sLTcq+yMMYTA==", - "deprecated": "This module has moved and is now available at @rollup/plugin-replace. Please update your dependencies. This version is no longer maintained.", - "dev": true, - "dependencies": { - "magic-string": "^0.25.2", - "rollup-pluginutils": "^2.6.0" - } - }, - "node_modules/rollup-pluginutils": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", - "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", - "dev": true, - "dependencies": { - "estree-walker": "^0.6.1" - } - }, - "node_modules/scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "dev": true - } - }, - "dependencies": { - "@types/estree": { - "version": "0.0.48", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.48.tgz", - "integrity": "sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew==", - "dev": true - }, - "@types/node": { - "version": "15.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.2.tgz", - "integrity": "sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww==", - "dev": true - }, - "@types/resolve": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", - "integrity": "sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "builtin-modules": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz", - "integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==", - "dev": true - }, - "estree-walker": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", - "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", - "dev": true - }, - "fast-json-patch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.0.tgz", - "integrity": "sha512-IhpytlsVTRndz0hU5t0/MGzS/etxLlfrpG5V5M9mVbuj9TrJLWaMfsox9REM5rkuGX0T+5qjpe8XA1o0gZ42nA==" - }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "optional": true - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "htm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", - "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" - }, - "idom-client-react": { - "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" - } - }, - "is-core-module": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz", - "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", - "dev": true - }, - "is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "dev": true, - "requires": { - "@types/estree": "*" - } - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "magic-string": { - "version": "0.25.7", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", - "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", - "dev": true, - "requires": { - "sourcemap-codec": "^1.4.4" - } - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "prettier": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz", - "integrity": "sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==", - "dev": true - }, - "react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" - } - }, - "resolve": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", - "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", - "dev": true, - "requires": { - "is-core-module": "^2.2.0", - "path-parse": "^1.0.6" - } - }, - "rollup": { - "version": "2.56.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.56.3.tgz", - "integrity": "sha512-Au92NuznFklgQCUcV96iXlxUbHuB1vQMaH76DHl5M11TotjOHwqk9CwcrT78+Tnv4FN9uTBxq6p4EJoYkpyekg==", - "dev": true, - "requires": { - "fsevents": "~2.3.2" - } - }, - "rollup-plugin-commonjs": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-10.1.0.tgz", - "integrity": "sha512-jlXbjZSQg8EIeAAvepNwhJj++qJWNJw1Cl0YnOqKtP5Djx+fFGkp3WRh+W0ASCaFG5w1jhmzDxgu3SJuVxPF4Q==", - "dev": true, - "requires": { - "estree-walker": "^0.6.1", - "is-reference": "^1.1.2", - "magic-string": "^0.25.2", - "resolve": "^1.11.0", - "rollup-pluginutils": "^2.8.1" - } - }, - "rollup-plugin-node-resolve": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-5.2.0.tgz", - "integrity": "sha512-jUlyaDXts7TW2CqQ4GaO5VJ4PwwaV8VUGA7+km3n6k6xtOEacf61u0VXwN80phY/evMcaS+9eIeJ9MOyDxt5Zw==", - "dev": true, - "requires": { - "@types/resolve": "0.0.8", - "builtin-modules": "^3.1.0", - "is-module": "^1.0.0", - "resolve": "^1.11.1", - "rollup-pluginutils": "^2.8.1" - } - }, - "rollup-plugin-replace": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-replace/-/rollup-plugin-replace-2.2.0.tgz", - "integrity": "sha512-/5bxtUPkDHyBJAKketb4NfaeZjL5yLZdeUihSfbF2PQMz+rSTEb8ARKoOl3UBT4m7/X+QOXJo3sLTcq+yMMYTA==", - "dev": true, - "requires": { - "magic-string": "^0.25.2", - "rollup-pluginutils": "^2.6.0" - } - }, - "rollup-pluginutils": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", - "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", - "dev": true, - "requires": { - "estree-walker": "^0.6.1" - } - }, - "scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "dev": true - } - } + "name": "js", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "dependencies": { + "idom-client-react": "^0.43.0", + "react": "^17.0.2", + "react-dom": "^17.0.2" + }, + "devDependencies": { + "prettier": "^2.2.1", + "rollup": "^2.56.3", + "rollup-plugin-commonjs": "^10.1.0", + "rollup-plugin-node-resolve": "^5.2.0", + "rollup-plugin-replace": "^2.2.0" + } + }, + "node_modules/@types/estree": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.48.tgz", + "integrity": "sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew==", + "dev": true + }, + "node_modules/@types/node": { + "version": "15.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.2.tgz", + "integrity": "sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww==", + "dev": true + }, + "node_modules/@types/resolve": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", + "integrity": "sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/builtin-modules": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz", + "integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true + }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/htm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", + "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" + }, + "node_modules/idom-client-react": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.43.0.tgz", + "integrity": "sha512-SjVR7wmqsNO5ymKKOsLsQUA+cN12+KuG56xLkCDVyEnlDxUytIOzpzQ3qBsAeMbRJkT/BFURim7UeKnAgWgoLw==", + "dependencies": { + "fast-json-patch": "^3.1.1", + "htm": "^3.0.3" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/is-core-module": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz", + "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==", + "dev": true, + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", + "dev": true + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/magic-string": { + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", + "dev": true, + "dependencies": { + "sourcemap-codec": "^1.4.4" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/prettier": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz", + "integrity": "sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/react": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", + "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", + "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "scheduler": "^0.20.2" + }, + "peerDependencies": { + "react": "17.0.2" + } + }, + "node_modules/resolve": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", + "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "dev": true, + "dependencies": { + "is-core-module": "^2.2.0", + "path-parse": "^1.0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "2.56.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.56.3.tgz", + "integrity": "sha512-Au92NuznFklgQCUcV96iXlxUbHuB1vQMaH76DHl5M11TotjOHwqk9CwcrT78+Tnv4FN9uTBxq6p4EJoYkpyekg==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-commonjs": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-10.1.0.tgz", + "integrity": "sha512-jlXbjZSQg8EIeAAvepNwhJj++qJWNJw1Cl0YnOqKtP5Djx+fFGkp3WRh+W0ASCaFG5w1jhmzDxgu3SJuVxPF4Q==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-commonjs.", + "dev": true, + "dependencies": { + "estree-walker": "^0.6.1", + "is-reference": "^1.1.2", + "magic-string": "^0.25.2", + "resolve": "^1.11.0", + "rollup-pluginutils": "^2.8.1" + }, + "peerDependencies": { + "rollup": ">=1.12.0" + } + }, + "node_modules/rollup-plugin-node-resolve": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-5.2.0.tgz", + "integrity": "sha512-jUlyaDXts7TW2CqQ4GaO5VJ4PwwaV8VUGA7+km3n6k6xtOEacf61u0VXwN80phY/evMcaS+9eIeJ9MOyDxt5Zw==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-node-resolve.", + "dev": true, + "dependencies": { + "@types/resolve": "0.0.8", + "builtin-modules": "^3.1.0", + "is-module": "^1.0.0", + "resolve": "^1.11.1", + "rollup-pluginutils": "^2.8.1" + }, + "peerDependencies": { + "rollup": ">=1.11.0" + } + }, + "node_modules/rollup-plugin-replace": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-replace/-/rollup-plugin-replace-2.2.0.tgz", + "integrity": "sha512-/5bxtUPkDHyBJAKketb4NfaeZjL5yLZdeUihSfbF2PQMz+rSTEb8ARKoOl3UBT4m7/X+QOXJo3sLTcq+yMMYTA==", + "deprecated": "This module has moved and is now available at @rollup/plugin-replace. Please update your dependencies. This version is no longer maintained.", + "dev": true, + "dependencies": { + "magic-string": "^0.25.2", + "rollup-pluginutils": "^2.6.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/scheduler": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", + "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "dev": true + } + }, + "dependencies": { + "@types/estree": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.48.tgz", + "integrity": "sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew==", + "dev": true + }, + "@types/node": { + "version": "15.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.2.tgz", + "integrity": "sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww==", + "dev": true + }, + "@types/resolve": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", + "integrity": "sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "builtin-modules": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz", + "integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==", + "dev": true + }, + "estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true + }, + "fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==" + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "htm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", + "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" + }, + "idom-client-react": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.43.0.tgz", + "integrity": "sha512-SjVR7wmqsNO5ymKKOsLsQUA+cN12+KuG56xLkCDVyEnlDxUytIOzpzQ3qBsAeMbRJkT/BFURim7UeKnAgWgoLw==", + "requires": { + "fast-json-patch": "^3.1.1", + "htm": "^3.0.3" + } + }, + "is-core-module": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz", + "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", + "dev": true + }, + "is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "requires": { + "@types/estree": "*" + } + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "magic-string": { + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", + "dev": true, + "requires": { + "sourcemap-codec": "^1.4.4" + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "prettier": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz", + "integrity": "sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==", + "dev": true + }, + "react": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", + "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "react-dom": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", + "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "scheduler": "^0.20.2" + } + }, + "resolve": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", + "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "dev": true, + "requires": { + "is-core-module": "^2.2.0", + "path-parse": "^1.0.6" + } + }, + "rollup": { + "version": "2.56.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.56.3.tgz", + "integrity": "sha512-Au92NuznFklgQCUcV96iXlxUbHuB1vQMaH76DHl5M11TotjOHwqk9CwcrT78+Tnv4FN9uTBxq6p4EJoYkpyekg==", + "dev": true, + "requires": { + "fsevents": "~2.3.2" + } + }, + "rollup-plugin-commonjs": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-10.1.0.tgz", + "integrity": "sha512-jlXbjZSQg8EIeAAvepNwhJj++qJWNJw1Cl0YnOqKtP5Djx+fFGkp3WRh+W0ASCaFG5w1jhmzDxgu3SJuVxPF4Q==", + "dev": true, + "requires": { + "estree-walker": "^0.6.1", + "is-reference": "^1.1.2", + "magic-string": "^0.25.2", + "resolve": "^1.11.0", + "rollup-pluginutils": "^2.8.1" + } + }, + "rollup-plugin-node-resolve": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-5.2.0.tgz", + "integrity": "sha512-jUlyaDXts7TW2CqQ4GaO5VJ4PwwaV8VUGA7+km3n6k6xtOEacf61u0VXwN80phY/evMcaS+9eIeJ9MOyDxt5Zw==", + "dev": true, + "requires": { + "@types/resolve": "0.0.8", + "builtin-modules": "^3.1.0", + "is-module": "^1.0.0", + "resolve": "^1.11.1", + "rollup-pluginutils": "^2.8.1" + } + }, + "rollup-plugin-replace": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-replace/-/rollup-plugin-replace-2.2.0.tgz", + "integrity": "sha512-/5bxtUPkDHyBJAKketb4NfaeZjL5yLZdeUihSfbF2PQMz+rSTEb8ARKoOl3UBT4m7/X+QOXJo3sLTcq+yMMYTA==", + "dev": true, + "requires": { + "magic-string": "^0.25.2", + "rollup-pluginutils": "^2.6.0" + } + }, + "rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "requires": { + "estree-walker": "^0.6.1" + } + }, + "scheduler": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", + "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "dev": true + } + } } diff --git a/src/js/package.json b/src/js/package.json index 4d4112a2..10b43551 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -1,23 +1,23 @@ { - "description": "test app for idom_django websocket server", - "main": "src/index.js", - "files": [ - "src/**/*.js" - ], - "scripts": { - "build": "rollup --config", - "format": "prettier --ignore-path .gitignore --write ." - }, - "devDependencies": { - "prettier": "^2.2.1", - "rollup": "^2.56.3", - "rollup-plugin-commonjs": "^10.1.0", - "rollup-plugin-node-resolve": "^5.2.0", - "rollup-plugin-replace": "^2.2.0" - }, - "dependencies": { - "idom-client-react": "^0.40.2", - "react": "^17.0.2", - "react-dom": "^17.0.2" - } + "description": "test app for idom_django websocket server", + "main": "src/index.js", + "files": [ + "src/**/*.js" + ], + "scripts": { + "build": "rollup --config", + "format": "prettier --ignore-path .gitignore --write ." + }, + "devDependencies": { + "prettier": "^2.2.1", + "rollup": "^2.56.3", + "rollup-plugin-commonjs": "^10.1.0", + "rollup-plugin-node-resolve": "^5.2.0", + "rollup-plugin-replace": "^2.2.0" + }, + "dependencies": { + "idom-client-react": "^0.43.0", + "react": "^17.0.2", + "react-dom": "^17.0.2" + } } diff --git a/src/js/rollup.config.js b/src/js/rollup.config.js index 646f8b69..2443f388 100644 --- a/src/js/rollup.config.js +++ b/src/js/rollup.config.js @@ -2,32 +2,28 @@ import resolve from "rollup-plugin-node-resolve"; import commonjs from "rollup-plugin-commonjs"; import replace from "rollup-plugin-replace"; -const { PRODUCTION } = process.env; - export default { - input: "src/index.js", - output: { - file: "../django_idom/static/django_idom/client.js", - format: "esm", - }, - plugins: [ - resolve(), - commonjs(), - replace({ - "process.env.NODE_ENV": JSON.stringify( - PRODUCTION ? "production" : "development" - ), - }), - ], - onwarn: function (warning) { - // Skip certain warnings + input: "src/index.js", + output: { + file: "../django_idom/static/django_idom/client.js", + format: "esm", + }, + plugins: [ + resolve(), + commonjs(), + replace({ + "process.env.NODE_ENV": JSON.stringify("production"), + }), + ], + onwarn: function (warning) { + // Skip certain warnings - // should intercept ... but doesn't in some rollup versions - if (warning.code === "THIS_IS_UNDEFINED") { - return; - } + // should intercept ... but doesn't in some rollup versions + if (warning.code === "THIS_IS_UNDEFINED") { + return; + } - // console.warn everything else - console.warn(warning.message); - }, + // console.warn everything else + console.warn(warning.message); + }, }; diff --git a/src/js/src/index.js b/src/js/src/index.js index 8f17c0d4..478bf8ca 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -1,37 +1,34 @@ import { mountLayoutWithWebSocket } from "idom-client-react"; // Set up a websocket at the base endpoint -let LOCATION = window.location; +const LOCATION = window.location; let WS_PROTOCOL = ""; if (LOCATION.protocol == "https:") { - WS_PROTOCOL = "wss://"; + WS_PROTOCOL = "wss://"; } else { - WS_PROTOCOL = "ws://"; + WS_PROTOCOL = "ws://"; } -let WS_ENDPOINT_URL = WS_PROTOCOL + LOCATION.host + "/"; +const WS_ENDPOINT_URL = WS_PROTOCOL + LOCATION.host + "/"; export function mountViewToElement( - mountPoint, - idomWebsocketUrl, - idomWebModulesUrl, - maxReconnectTimeout, - viewId, - queryParams + mountElement, + idomWebsocketUrl, + idomWebModulesUrl, + maxReconnectTimeout, + componentPath ) { - const fullWebsocketUrl = - WS_ENDPOINT_URL + idomWebsocketUrl + viewId + "/?" + queryParams; + const WS_URL = WS_ENDPOINT_URL + idomWebsocketUrl + componentPath; + const WEB_MODULE_URL = LOCATION.origin + "/" + idomWebModulesUrl; + const loadImportSource = (source, sourceType) => { + return import( + sourceType == "NAME" ? `${WEB_MODULE_URL}${source}` : source + ); + }; - const fullWebModulesUrl = LOCATION.origin + "/" + idomWebModulesUrl; - const loadImportSource = (source, sourceType) => { - return import( - sourceType == "NAME" ? `${fullWebModulesUrl}${source}` : source - ); - }; - - mountLayoutWithWebSocket( - mountPoint, - fullWebsocketUrl, - loadImportSource, - maxReconnectTimeout - ); + mountLayoutWithWebSocket( + mountElement, + WS_URL, + loadImportSource, + maxReconnectTimeout + ); } diff --git a/tests/test_app/admin.py b/tests/test_app/admin.py index 5e35a515..e0701a96 100644 --- a/tests/test_app/admin.py +++ b/tests/test_app/admin.py @@ -1,6 +1,8 @@ from django.contrib import admin from test_app.models import ForiegnChild, RelationalChild, RelationalParent, TodoItem +from django_idom.models import ComponentParams + @admin.register(TodoItem) class TodoItemAdmin(admin.ModelAdmin): @@ -20,3 +22,8 @@ class RelationalParentAdmin(admin.ModelAdmin): @admin.register(ForiegnChild) class ForiegnChildAdmin(admin.ModelAdmin): pass + + +@admin.register(ComponentParams) +class ComponentParamsAdmin(admin.ModelAdmin): + list_display = ("uuid", "last_accessed") diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 37ae1cda..38ba30a5 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -8,14 +8,14 @@ import django_idom from django_idom.components import view_to_component -from django_idom.hooks import use_mutation, use_query from . import views +from .types import TestObject @component def hello_world(): - return html._(html.h1({"id": "hello-world"}, "Hello World!"), html.hr()) + return html._(html.div({"id": "hello-world"}, "Hello World!"), html.hr()) @component @@ -23,6 +23,7 @@ def button(): count, set_count = hooks.use_state(0) return html._( html.div( + "button:", html.button( {"id": "counter-inc", "onClick": lambda event: set_count(count + 1)}, "Click me!", @@ -40,7 +41,27 @@ def button(): def parameterized_component(x, y): total = x + y return html._( - html.h1({"id": "parametrized-component", "data-value": total}, total), + html.div( + {"id": "parametrized-component", "data-value": total}, + f"parameterized_component: {total}", + ), + html.hr(), + ) + + +@component +def object_in_templatetag(my_object: TestObject): + success = bool(my_object and my_object.value) + co_name = inspect.currentframe().f_code.co_name # type: ignore + return html._( + html.div( + { + "id": co_name, + "data-success": success, + }, + f"{co_name}: ", + str(my_object), + ), html.hr(), ) @@ -57,18 +78,25 @@ def parameterized_component(x, y): @component def simple_button(): return html._( + "simple_button:", SimpleButton({"id": "simple-button"}), html.hr(), ) @component -def use_websocket(): - ws = django_idom.hooks.use_websocket() - success = bool(ws.scope and ws.close and ws.disconnect and ws.view_id) +def use_connection(): + ws = django_idom.hooks.use_connection() + success = bool( + ws.scope + and getattr(ws, "location", None) + and getattr(ws.carrier, "close", None) + and getattr(ws.carrier, "disconnect", None) + and getattr(ws.carrier, "dotted_path", None) + ) return html.div( - {"id": "use-websocket", "data-success": success}, - f"use_websocket: {ws}", + {"id": "use-connection", "data-success": success}, + f"use_connection: {ws}", html.hr(), ) @@ -191,8 +219,8 @@ def get_foriegn_child_query(): @component def relational_query(): - foriegn_child = use_query(get_foriegn_child_query) - relational_parent = use_query(get_relational_parent_query) + foriegn_child = django_idom.hooks.use_query(get_foriegn_child_query) + relational_parent = django_idom.hooks.use_query(get_relational_parent_query) if not relational_parent.data or not foriegn_child.data: return @@ -239,8 +267,8 @@ def toggle_todo_mutation(item: TodoItem): @component def todo_list(): input_value, set_input_value = hooks.use_state("") - items = use_query(get_todo_query) - toggle_item = use_mutation(toggle_todo_mutation) + items = django_idom.hooks.use_query(get_todo_query) + toggle_item = django_idom.hooks.use_mutation(toggle_todo_mutation) if items.error: rendered_items = html.h2(f"Error when loading - {items.error}") @@ -254,7 +282,7 @@ def todo_list(): _render_todo_items([i for i in items.data if i.done], toggle_item), ) - add_item = use_mutation(add_todo_mutation, refetch=get_todo_query) + add_item = django_idom.hooks.use_mutation(add_todo_mutation, refetch=get_todo_query) if add_item.loading: mutation_status = html.h2("Working...") diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index 6cb4dc82..bbc4be6a 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -80,6 +80,14 @@ }, } +# Cache +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "LOCATION": os.path.join(BASE_DIR, "cache"), + } +} + # Password validation # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index ca196297..8b7344b2 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -23,8 +23,9 @@

IDOM Test Page

{% component "test_app.components.hello_world" class="hello-world" %}
{% component "test_app.components.button" class="button" %}
{% component "test_app.components.parameterized_component" class="parametarized-component" x=123 y=456 %}
+
{% component "test_app.components.object_in_templatetag" my_object %}
{% component "test_app.components.simple_button" %}
-
{% component "test_app.components.use_websocket" %}
+
{% component "test_app.components.use_connection" %}
{% component "test_app.components.use_scope" %}
{% component "test_app.components.use_location" %}
{% component "test_app.components.use_origin" %}
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index e5a77827..359b7dbe 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -9,7 +9,7 @@ CLICK_DELAY = 250 # Delay in miliseconds. Needed for GitHub Actions. -class TestIdomCapabilities(ChannelsLiveServerTestCase): +class ComponentTests(ChannelsLiveServerTestCase): @classmethod def setUpClass(cls): if sys.platform == "win32": @@ -47,11 +47,14 @@ def test_counter(self): def test_parametrized_component(self): self.page.locator("#parametrized-component[data-value='579']").wait_for() + def test_object_in_templatetag(self): + self.page.locator("#object_in_templatetag[data-success=true]").wait_for() + def test_component_from_web_module(self): self.page.wait_for_selector("#simple-button") - def test_use_websocket(self): - self.page.locator("#use-websocket[data-success=true]").wait_for() + def test_use_connection(self): + self.page.locator("#use-connection[data-success=true]").wait_for() def test_use_scope(self): self.page.locator("#use-scope[data-success=true]").wait_for() diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py new file mode 100644 index 00000000..3709de08 --- /dev/null +++ b/tests/test_app/tests/test_database.py @@ -0,0 +1,46 @@ +from time import sleep +from typing import Any +from uuid import uuid4 + +import dill as pickle +from django.test import TransactionTestCase + +from django_idom import utils +from django_idom.models import ComponentParams +from django_idom.types import ComponentParamData + + +class DatabaseTests(TransactionTestCase): + def test_component_params(self): + # Make sure the ComponentParams table is empty + self.assertEqual(ComponentParams.objects.count(), 0) + params_1 = self._save_params_to_db(1) + + # Check if a component params are in the database + self.assertEqual(ComponentParams.objects.count(), 1) + self.assertEqual(pickle.loads(ComponentParams.objects.first().data), params_1) # type: ignore + + # Force `params_1` to expire + from django_idom import config + + config.IDOM_RECONNECT_MAX = 1 + sleep(config.IDOM_RECONNECT_MAX + 0.1) + + # Create a new, non-expired component params + params_2 = self._save_params_to_db(2) + self.assertEqual(ComponentParams.objects.count(), 2) + + # Delete the first component params based on expiration time + utils.db_cleanup() # Don't use `immediate` to test cache timestamping logic + + # Make sure `params_1` has expired + self.assertEqual(ComponentParams.objects.count(), 1) + self.assertEqual(pickle.loads(ComponentParams.objects.first().data), params_2) # type: ignore + + def _save_params_to_db(self, value: Any) -> ComponentParamData: + param_data = ComponentParamData((value,), {"test_value": value}) + model = ComponentParams(uuid4().hex, data=pickle.dumps(param_data)) + model.full_clean() + model.save() + + return param_data diff --git a/tests/test_app/types.py b/tests/test_app/types.py new file mode 100644 index 00000000..438c69d0 --- /dev/null +++ b/tests/test_app/types.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass +class TestObject: + value: int = 0 diff --git a/tests/test_app/views.py b/tests/test_app/views.py index 2d16ae97..b172132a 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -4,9 +4,11 @@ from django.shortcuts import render from django.views.generic import TemplateView, View +from .types import TestObject + def base_template(request): - return render(request, "base.html", {}) + return render(request, "base.html", {"my_object": TestObject(1)}) def view_to_component_sync_func(request): pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy