diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 00000000..fb6655d0
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @idom-team/django
diff --git a/.gitignore b/.gitignore
index 434278f2..1a06cffb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
+# Django IDOM Build Artifacts
+src/django_idom/static/js
+
# Django #
logs
*.log
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 00000000..4b763b80
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,76 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, sex characteristics, gender identity and expression,
+level of experience, education, socio-economic status, nationality, personal
+appearance, race, religion, or sexual identity and orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+ advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+ address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the project team at ryan.morshead@gmail.com. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see
+https://www.contributor-covenant.org/faq
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..2a6bd62d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2021 Ryan S. Morshead
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 00000000..a43e500c
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,2 @@
+recursive-include src/django_idom/static/ *
+recursive-include src/django_idom/templates/ *.html
diff --git a/README.md b/README.md
index 943a2aed..08c80f6d 100644
--- a/README.md
+++ b/README.md
@@ -10,32 +10,23 @@
-A package for building highly interactive user interfaces in pure Python inspired by
-[ReactJS](https://reactjs.org/).
+`django-idom` allows you to integrate [IDOM](https://github.com/idom-team/idom) into
+Django applications. IDOM is a pure Python library inspired by
+[ReactJS](https://reactjs.org/) for creating responsive web interfaces.
-**Be sure to [read the IDOM Documentation](https://idom-docs.herokuapp.com)!**
-
-If you have ideas or find a bug, be sure to post an
-[issue](https://github.com/idom-team/django-idom/issues)
-or create a
-[pull request](https://github.com/idom-team/django-idom/pulls). Thanks in advance!
-
-
+**You can try IDOM now in a Jupyter Notebook:**
+
+
+
-Click the badge above to get started! It will take you to a [Jupyter Notebooks](https://jupyter.org/)
-hosted by [Binder](https://mybinder.org/) with some great examples.
-### Or Install it Now
+# Install Django IDOM
```bash
pip install django-idom
@@ -43,10 +34,219 @@ pip install django-idom
# Django Integration
-This version of IDOM can be directly integrated into Django. For example
+To integrate IDOM into your application you'll need to modify or add the following files to `your_project`:
+
+```
+your_project/
+├── __init__.py
+├── asgi.py
+├── settings.py
+├── urls.py
+└── example_app/
+ ├── __init__.py
+ ├── idom.py
+ ├── templates/
+ │ └── your-template.html
+ └── urls.py
+```
+
+## `asgi.py`
+
+Follow the [`channels`](https://channels.readthedocs.io/en/stable/)
+[installation guide](https://channels.readthedocs.io/en/stable/installation.html) in
+order to create ASGI websockets within Django. Then, we will add a path for IDOM's
+websocket consumer using `IDOM_WEBSOCKET_PATH`.
+
+_Note: If you wish to change the route where this websocket is served from, see the
+available [settings](#settings.py)._
+
+```python
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+from django_idom import IDOM_WEB_MODULES_PATH
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings")
+
+# Fetch ASGI application before importing dependencies that require ORM models.
+http_asgi_app = get_asgi_application()
+
+from channels.routing import ProtocolTypeRouter, URLRouter
+
+application = ProtocolTypeRouter(
+ {
+ "http": http_asgi_app,
+ "websocket": URLRouter(
+ # add a path for IDOM's websocket
+ [IDOM_WEB_MODULES_PATH]
+ ),
+ }
+)
+```
+
+## `settings.py`
+
+In your settings you'll need to add `django_idom` to the
+[`INSTALLED_APPS`](https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-INSTALLED_APPS)
+list:
```python
-# Example code goes here
+INSTALLED_APPS = [
+ ...,
+ "django_idom",
+]
```
-For examples on how to use IDOM, [read the IDOM Documentation](https://idom-docs.herokuapp.com).
+You may configure additional options as well:
+
+```python
+# the base URL for all IDOM-releated resources
+IDOM_BASE_URL: str = "_idom/"
+
+# Set cache size limit for loading JS files for IDOM.
+# Only applies when not using Django's caching framework (see below).
+IDOM_WEB_MODULE_LRU_CACHE_SIZE: int | None = None
+
+# Configure a cache for loading JS files
+CACHES = {
+ # Configure a cache for loading JS files for IDOM
+ "idom_web_modules": {"BACKEND": ...},
+ # If the above cache is not configured, then we'll use the "default" instead
+ "default": {"BACKEND": ...},
+}
+```
+
+## `urls.py`
+
+You'll need to include IDOM's static web modules path using `IDOM_WEB_MODULES_PATH`.
+Similarly to the `IDOM_WEBSOCKET_PATH`. If you wish to change the route where this
+websocket is served from, see the available [settings](#settings.py).
+
+```python
+from django_idom import IDOM_WEB_MODULES_PATH
+
+urlpatterns = [
+ IDOM_WEB_MODULES_PATH,
+ ...
+]
+```
+
+## `example_app/components.py`
+
+This is where, by a convention similar to that of
+[`views.py`](https://docs.djangoproject.com/en/3.2/topics/http/views/), you'll define
+your [IDOM](https://github.com/idom-team/idom) components. Ultimately though, you should
+feel free to organize your component modules you wish. The components created here will
+ultimately be referenced by name in `your-template.html`. `your-template.html`.
+
+```python
+import idom
+
+@idom.component
+def Hello(greeting_recipient): # component names are camelcase by convention
+ return Header(f"Hello {greeting_recipient}!")
+```
+
+## `example_app/templates/your-template.html`
+
+In your templates, you may inject a view of an IDOM component into your templated HTML
+by using the `idom_component` template tag. This tag which requires the name of a component
+to render (of the form `module_name.ComponentName`) and keyword arguments you'd like to
+pass it from the template.
+
+```python
+idom_component module_name.ComponentName param_1="something" param_2="something-else"
+```
+
+In context this will look a bit like the following...
+
+```jinja
+
+{% load static %}
+{% load idom %}
+
+
+
+
+ ...
+ {% idom_component "your_project.example_app.components.Hello" greeting_recipient="World" %}
+
+
+```
+
+## `example_app/views.py`
+
+You can then serve `your-template.html` from a view just
+[like any other](https://docs.djangoproject.com/en/3.2/intro/tutorial03/#write-views-that-actually-do-something).
+
+```python
+from django.http import HttpResponse
+from django.template import loader
+
+
+def your_view(request):
+ context = {}
+ return HttpResponse(
+ loader.get_template("your-template.html").render(context, request)
+ )
+```
+
+## `example_app/urls.py`
+
+Include your view in the list of urlpatterns
+
+```python
+from django.urls import path
+from .views import your_view # define this view like any other HTML template view
+
+urlpatterns = [
+ path("", your_view),
+ ...
+]
+```
+
+# Developer Guide
+
+If you plan to make code changes to this repository, you'll need to install the
+following dependencies first:
+
+- [NPM](https://docs.npmjs.com/try-the-latest-stable-version-of-npm) for
+ installing and managing Javascript
+- [ChromeDriver](https://chromedriver.chromium.org/downloads) for testing with
+ [Selenium](https://www.seleniumhq.org/)
+
+Once done, you should clone this repository:
+
+```bash
+git clone https://github.com/idom-team/django-idom.git
+cd django-idom
+```
+
+Then, by running the command below you can:
+
+- Install an editable version of the Python code
+
+- Download, build, and install Javascript dependencies
+
+```bash
+pip install -e . -r requirements.txt
+```
+
+Finally, to verify that everything is working properly, you'll want to run the test suite.
+
+## Running The Tests
+
+This repo uses [Nox](https://nox.thea.codes/en/stable/) to run scripts which can
+be found in `noxfile.py`. For a full test of available scripts run `nox -l`. To run the full test suite simple execute:
+
+```
+nox -s test
+```
+
+To run the tests using a headless browser:
+
+```
+nox -s test -- --headless
+```
diff --git a/noxfile.py b/noxfile.py
index 4a943345..b28665df 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -16,20 +16,17 @@
@nox.session(reuse_venv=True)
def manage(session: Session) -> None:
- session.install("-r", "requirements.txt")
+ """Run a manage.py command for tests/test_app"""
+ session.install("-r", "requirements/test-env.txt")
session.install("idom[stable]")
session.install("-e", ".")
session.chdir("tests")
-
- build_js_on_commands = ["runserver"]
- if set(session.posargs).intersection(build_js_on_commands):
- session.run("python", "manage.py", "build_js")
-
session.run("python", "manage.py", *session.posargs)
@nox.session(reuse_venv=True)
def format(session: Session) -> None:
+ """Run automatic code formatters"""
install_requirements_file(session, "check-style")
session.run("black", ".")
session.run("isort", ".")
@@ -51,9 +48,16 @@ def test_suite(session: Session) -> None:
session.chdir(HERE / "tests")
session.env["IDOM_DEBUG_MODE"] = "1"
- session.env["SELENIUM_HEADLESS"] = str(int("--headless" in session.posargs))
- session.run("python", "manage.py", "build_js")
- session.run("python", "manage.py", "test")
+
+ posargs = session.posargs[:]
+ if "--headless" in posargs:
+ posargs.remove("--headless")
+ session.env["SELENIUM_HEADLESS"] = "1"
+
+ if "--no-debug-mode" not in posargs:
+ posargs.append("--debug-mode")
+
+ session.run("python", "manage.py", "test", *posargs)
@nox.session
diff --git a/setup.py b/setup.py
index dbdbbf67..adeb5937 100644
--- a/setup.py
+++ b/setup.py
@@ -1,8 +1,25 @@
-import os
+from __future__ import print_function
+
+import pipes
+import shutil
+import subprocess
import sys
+import traceback
+from distutils import log
+from distutils.command.build import build # type: ignore
+from distutils.command.sdist import sdist # type: ignore
from pathlib import Path
from setuptools import find_packages, setup
+from setuptools.command.develop import develop
+
+
+if sys.platform == "win32":
+ from subprocess import list2cmdline
+else:
+
+ def list2cmdline(cmd_list):
+ return " ".join(map(pipes.quote, cmd_list))
# the name of the project
@@ -31,6 +48,7 @@
"license": "MIT",
"platforms": "Linux, Mac OS X, Windows",
"keywords": ["interactive", "widgets", "DOM", "React"],
+ "include_package_data": True,
"zip_safe": False,
"classifiers": [
"Framework :: Django",
@@ -52,14 +70,14 @@
# Library Version
# -----------------------------------------------------------------------------
-with open(os.path.join(package_dir, "__init__.py")) as f:
- for line in f.read().split("\n"):
- if line.startswith("__version__ = "):
- package["version"] = eval(line.split("=", 1)[1])
- break
- else:
- print("No version found in %s/__init__.py" % package_dir)
- sys.exit(1)
+
+for line in (package_dir / "__init__.py").read_text().split("\n"):
+ if line.startswith("__version__ = "):
+ package["version"] = eval(line.split("=", 1)[1])
+ break
+else:
+ print("No version found in %s/__init__.py" % package_dir)
+ sys.exit(1)
# -----------------------------------------------------------------------------
@@ -87,6 +105,42 @@
package["long_description_content_type"] = "text/markdown"
+# ----------------------------------------------------------------------------
+# Build Javascript
+# ----------------------------------------------------------------------------
+
+
+def build_javascript_first(cls):
+ class Command(cls):
+ def run(self):
+ log.info("Installing Javascript...")
+ try:
+ js_dir = str(src_dir / "js")
+ npm = shutil.which("npm") # this is required on windows
+ if npm is None:
+ raise RuntimeError("NPM is not installed.")
+ for args in (f"{npm} install", f"{npm} run build"):
+ args_list = args.split()
+ log.info(f"> {list2cmdline(args_list)}")
+ subprocess.run(args_list, cwd=js_dir, check=True)
+ except Exception:
+ log.error("Failed to install Javascript")
+ log.error(traceback.format_exc())
+ raise
+ else:
+ log.info("Successfully installed Javascript")
+ super().run()
+
+ return Command
+
+
+package["cmdclass"] = {
+ "sdist": build_javascript_first(sdist),
+ "build": build_javascript_first(build),
+ "develop": build_javascript_first(develop),
+}
+
+
# -----------------------------------------------------------------------------
# Install It
# -----------------------------------------------------------------------------
diff --git a/src/django_idom/__init__.py b/src/django_idom/__init__.py
index 60932c7a..90a4aba1 100644
--- a/src/django_idom/__init__.py
+++ b/src/django_idom/__init__.py
@@ -1,5 +1,5 @@
-from .websocket_consumer import IdomAsyncWebSocketConsumer
+from .paths import IDOM_WEB_MODULES_PATH, IDOM_WEBSOCKET_PATH
__version__ = "0.0.1"
-__all__ = ["IdomAsyncWebSocketConsumer"]
+__all__ = ["IDOM_WEB_MODULES_PATH", "IDOM_WEBSOCKET_PATH"]
diff --git a/src/django_idom/config.py b/src/django_idom/config.py
new file mode 100644
index 00000000..9b2d9f0a
--- /dev/null
+++ b/src/django_idom/config.py
@@ -0,0 +1,27 @@
+from typing import Dict
+
+from django.conf import settings
+from django.core.cache import DEFAULT_CACHE_ALIAS
+from idom.core.proto import ComponentConstructor
+
+
+IDOM_REGISTERED_COMPONENTS: Dict[str, ComponentConstructor] = {}
+
+IDOM_BASE_URL = getattr(settings, "IDOM_BASE_URL", "_idom/")
+IDOM_WEBSOCKET_URL = IDOM_BASE_URL + "websocket/"
+IDOM_WEB_MODULES_URL = IDOM_BASE_URL + "web_module/"
+
+_CACHES = getattr(settings, "CACHES", {})
+if _CACHES:
+ if "idom_web_modules" in getattr(settings, "CACHES", {}):
+ IDOM_WEB_MODULE_CACHE = "idom_web_modules"
+ else:
+ IDOM_WEB_MODULE_CACHE = DEFAULT_CACHE_ALIAS
+else:
+ IDOM_WEB_MODULE_CACHE = None
+
+
+# the LRU cache size for the route serving IDOM_WEB_MODULES_DIR files
+IDOM_WEB_MODULE_LRU_CACHE_SIZE = getattr(
+ settings, "IDOM_WEB_MODULE_LRU_CACHE_SIZE", None
+)
diff --git a/src/django_idom/paths.py b/src/django_idom/paths.py
new file mode 100644
index 00000000..62a346e1
--- /dev/null
+++ b/src/django_idom/paths.py
@@ -0,0 +1,27 @@
+from django.urls import path
+
+from . import views
+from .config import IDOM_WEB_MODULES_URL, IDOM_WEBSOCKET_URL
+from .websocket_consumer import IdomAsyncWebSocketConsumer
+
+
+IDOM_WEBSOCKET_PATH = path(
+ IDOM_WEBSOCKET_URL + "/", IdomAsyncWebSocketConsumer.as_asgi()
+)
+"""A URL resolver for :class:`IdomAsyncWebSocketConsumer`
+
+While this is relatively uncommon in most Django apps, because the URL of the
+websocket must be defined by the setting ``IDOM_WEBSOCKET_URL``. There's no need
+to allow users to configure the URL themselves.
+"""
+
+
+IDOM_WEB_MODULES_PATH = path(
+ IDOM_WEB_MODULES_URL + "", views.web_modules_file
+)
+"""A URL resolver for static web modules required by IDOM
+
+While this is relatively uncommon in most Django apps, because the URL of the
+websocket must be defined by the setting ``IDOM_WEBSOCKET_URL``. There's no need
+to allow users to configure the URL themselves.
+"""
diff --git a/src/django_idom/templates/idom/view.html b/src/django_idom/templates/idom/view.html
new file mode 100644
index 00000000..adfc3a6d
--- /dev/null
+++ b/src/django_idom/templates/idom/view.html
@@ -0,0 +1,13 @@
+{% load static %}
+
+
diff --git a/tests/test_app/management/__init__.py b/src/django_idom/templatetags/__init__.py
similarity index 100%
rename from tests/test_app/management/__init__.py
rename to src/django_idom/templatetags/__init__.py
diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py
new file mode 100644
index 00000000..d294d2da
--- /dev/null
+++ b/src/django_idom/templatetags/idom.py
@@ -0,0 +1,54 @@
+import json
+import sys
+from importlib import import_module
+from urllib.parse import urlencode
+from uuid import uuid4
+
+from django import template
+
+from django_idom.config import (
+ IDOM_REGISTERED_COMPONENTS,
+ IDOM_WEB_MODULES_URL,
+ IDOM_WEBSOCKET_URL,
+)
+
+
+register = template.Library()
+
+
+@register.inclusion_tag("idom/view.html")
+def idom_component(_component_id_, **kwargs):
+ _register_component(_component_id_)
+
+ json_kwargs = json.dumps(kwargs, separators=(",", ":"))
+
+ return {
+ "idom_websocket_url": IDOM_WEBSOCKET_URL,
+ "idom_web_modules_url": IDOM_WEB_MODULES_URL,
+ "idom_mount_uuid": uuid4().hex,
+ "idom_component_id": _component_id_,
+ "idom_component_params": urlencode({"kwargs": json_kwargs}),
+ }
+
+
+def _register_component(full_component_name: str) -> None:
+ module_name, component_name = full_component_name.rsplit(".", 1)
+
+ if module_name in sys.modules:
+ module = sys.modules[module_name]
+ else:
+ try:
+ module = import_module(module_name)
+ except ImportError as error:
+ raise RuntimeError(
+ f"Failed to import {module_name!r} while loading {component_name!r}"
+ ) from error
+
+ try:
+ component = getattr(module, component_name)
+ except AttributeError as error:
+ raise RuntimeError(
+ f"Module {module_name!r} has no component named {component_name!r}"
+ ) from error
+
+ IDOM_REGISTERED_COMPONENTS[full_component_name] = component
diff --git a/src/django_idom/views.py b/src/django_idom/views.py
new file mode 100644
index 00000000..6ee19851
--- /dev/null
+++ b/src/django_idom/views.py
@@ -0,0 +1,44 @@
+import asyncio
+import functools
+import os
+
+from django.core.cache import caches
+from django.http import HttpRequest, HttpResponse
+from idom.config import IDOM_WED_MODULES_DIR
+
+from .config import IDOM_WEB_MODULE_CACHE, IDOM_WEB_MODULE_LRU_CACHE_SIZE
+
+
+if IDOM_WEB_MODULE_CACHE is None:
+
+ def async_lru_cache(*lru_cache_args, **lru_cache_kwargs):
+ def async_lru_cache_decorator(async_function):
+ @functools.lru_cache(*lru_cache_args, **lru_cache_kwargs)
+ def cached_async_function(*args, **kwargs):
+ coroutine = async_function(*args, **kwargs)
+ return asyncio.ensure_future(coroutine)
+
+ return cached_async_function
+
+ return async_lru_cache_decorator
+
+ @async_lru_cache(IDOM_WEB_MODULE_LRU_CACHE_SIZE)
+ async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse:
+ file_path = IDOM_WED_MODULES_DIR.current.joinpath(*file.split("/"))
+ return HttpResponse(file_path.read_text(), content_type="text/javascript")
+
+
+else:
+ _web_module_cache = caches[IDOM_WEB_MODULE_CACHE]
+
+ async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse:
+ file = IDOM_WED_MODULES_DIR.current.joinpath(*file.split("/")).absolute()
+ last_modified_time = os.stat(file).st_mtime
+ cache_key = f"{file}:{last_modified_time}"
+
+ response = _web_module_cache.get(cache_key)
+ if response is None:
+ response = HttpResponse(file.read_text(), content_type="text/javascript")
+ _web_module_cache.set(cache_key, response, timeout=None)
+
+ return response
diff --git a/src/django_idom/websocket_consumer.py b/src/django_idom/websocket_consumer.py
index 2fae2869..31f1aa38 100644
--- a/src/django_idom/websocket_consumer.py
+++ b/src/django_idom/websocket_consumer.py
@@ -1,20 +1,24 @@
"""Anything used to construct a websocket endpoint"""
import asyncio
+import json
+import logging
from typing import Any
+from urllib.parse import parse_qsl
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from idom.core.dispatcher import dispatch_single_view
from idom.core.layout import Layout, LayoutEvent
-from idom.core.proto import ComponentConstructor
+
+from .config import IDOM_REGISTERED_COMPONENTS
+
+
+_logger = logging.getLogger(__name__)
class IdomAsyncWebSocketConsumer(AsyncJsonWebsocketConsumer):
"""Communicates with the browser to perform actions on-demand."""
- def __init__(
- self, component: ComponentConstructor, *args: Any, **kwargs: Any
- ) -> None:
- self._idom_component_constructor = component
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
async def connect(self) -> None:
@@ -32,10 +36,30 @@ 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"]
+
+ try:
+ component_constructor = IDOM_REGISTERED_COMPONENTS[view_id]
+ except KeyError:
+ _logger.warning(f"Uknown IDOM view ID {view_id!r}")
+ return
+
+ query_dict = dict(parse_qsl(self.scope["query_string"].decode()))
+ component_kwargs = json.loads(query_dict.get("kwargs", "{}"))
+
+ try:
+ component_instance = component_constructor(**component_kwargs)
+ except Exception:
+ _logger.exception(
+ f"Failed to construct component {component_constructor} "
+ f"with parameters {component_kwargs}"
+ )
+ return
+
self._idom_recv_queue = recv_queue = asyncio.Queue()
try:
await dispatch_single_view(
- Layout(self._idom_component_constructor()),
+ Layout(component_instance),
self.send_json,
recv_queue.get,
)
diff --git a/tests/js/.gitignore b/src/js/.gitignore
similarity index 100%
rename from tests/js/.gitignore
rename to src/js/.gitignore
diff --git a/tests/js/package-lock.json b/src/js/package-lock.json
similarity index 97%
rename from tests/js/package-lock.json
rename to src/js/package-lock.json
index 6639bf96..6ff0f5ee 100644
--- a/tests/js/package-lock.json
+++ b/src/js/package-lock.json
@@ -1,14 +1,14 @@
{
- "name": "tests",
- "version": "1.0.0",
+ "name": "django-idom-client",
+ "version": "0.0.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
- "name": "tests",
- "version": "1.0.0",
+ "name": "django-idom-client",
+ "version": "0.0.1",
"dependencies": {
- "idom-client-react": "^0.8.2"
+ "idom-client-react": "^0.8.5"
},
"devDependencies": {
"prettier": "^2.2.1",
@@ -100,16 +100,16 @@
"integrity": "sha512-VRdvxX3tmrXuT/Ovt59NMp/ORMFi4bceFMDjos1PV4E0mV+5votuID8R60egR9A4U8nLt238R/snlJGz3UYiTQ=="
},
"node_modules/idom-client-react": {
- "version": "0.8.2",
- "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.8.2.tgz",
- "integrity": "sha512-pK4FjyfVIaOVA/R0sj6Ulvpo3FATFU11TbnoqgzGbXjjY7kYPuR3x2pa/M6MY/Ot9yUb2Nsz9Gr1Vj8QPJ6GwA==",
+ "version": "0.8.5",
+ "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.8.5.tgz",
+ "integrity": "sha512-/S/J+BPGnQ4YrXWBD0mT0IqigDHrXglTX91qDyIjg6aoQwcprJ8ms9WFwdXcx0/QY85s08+/FP4FnF8lcqwx9w==",
"dependencies": {
"fast-json-patch": "^3.0.0-1",
"htm": "^3.0.3"
},
"peerDependencies": {
- "react": "^16.13.1",
- "react-dom": "^16.13.1"
+ "react": ">=16",
+ "react-dom": ">=16"
}
},
"node_modules/is-core-module": {
@@ -405,9 +405,9 @@
"integrity": "sha512-VRdvxX3tmrXuT/Ovt59NMp/ORMFi4bceFMDjos1PV4E0mV+5votuID8R60egR9A4U8nLt238R/snlJGz3UYiTQ=="
},
"idom-client-react": {
- "version": "0.8.2",
- "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.8.2.tgz",
- "integrity": "sha512-pK4FjyfVIaOVA/R0sj6Ulvpo3FATFU11TbnoqgzGbXjjY7kYPuR3x2pa/M6MY/Ot9yUb2Nsz9Gr1Vj8QPJ6GwA==",
+ "version": "0.8.5",
+ "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.8.5.tgz",
+ "integrity": "sha512-/S/J+BPGnQ4YrXWBD0mT0IqigDHrXglTX91qDyIjg6aoQwcprJ8ms9WFwdXcx0/QY85s08+/FP4FnF8lcqwx9w==",
"requires": {
"fast-json-patch": "^3.0.0-1",
"htm": "^3.0.3"
diff --git a/tests/js/package.json b/src/js/package.json
similarity index 84%
rename from tests/js/package.json
rename to src/js/package.json
index 3844cf32..087045c9 100644
--- a/tests/js/package.json
+++ b/src/js/package.json
@@ -1,6 +1,6 @@
{
- "name": "tests",
- "version": "1.0.0",
+ "name": "django-idom-client",
+ "version": "0.0.1",
"description": "test app for idom_django websocket server",
"main": "src/index.js",
"files": [
@@ -10,14 +10,14 @@
"build": "rollup --config",
"format": "prettier --ignore-path .gitignore --write ."
},
- "dependencies": {
- "idom-client-react": "^0.8.2"
- },
"devDependencies": {
"prettier": "^2.2.1",
"rollup": "^2.35.1",
"rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-replace": "^2.2.0"
+ },
+ "dependencies": {
+ "idom-client-react": "^0.8.5"
}
}
diff --git a/tests/js/rollup.config.js b/src/js/rollup.config.js
similarity index 92%
rename from tests/js/rollup.config.js
rename to src/js/rollup.config.js
index ad597a61..9eb08a1c 100644
--- a/tests/js/rollup.config.js
+++ b/src/js/rollup.config.js
@@ -7,7 +7,7 @@ const { PRODUCTION } = process.env;
export default {
input: "src/index.js",
output: {
- file: "../test_app/static/build.js",
+ file: "../django_idom/static/js/django-idom-client.js",
format: "esm",
},
plugins: [
diff --git a/src/js/src/index.js b/src/js/src/index.js
new file mode 100644
index 00000000..bddf2a55
--- /dev/null
+++ b/src/js/src/index.js
@@ -0,0 +1,31 @@
+import { mountLayoutWithWebSocket } from "idom-client-react";
+
+// Set up a websocket at the base endpoint
+let LOCATION = window.location;
+let WS_PROTOCOL = "";
+if (LOCATION.protocol == "https:") {
+ WS_PROTOCOL = "wss://";
+} else {
+ WS_PROTOCOL = "ws://";
+}
+let WS_ENDPOINT_URL = WS_PROTOCOL + LOCATION.host + "/";
+
+export function mountViewToElement(
+ mountPoint,
+ idomWebsocketUrl,
+ idomWebModulesUrl,
+ viewId,
+ queryParams
+) {
+ const fullWebsocketUrl =
+ WS_ENDPOINT_URL + idomWebsocketUrl + viewId + "/?" + queryParams;
+
+ const fullWebModulesUrl = LOCATION.origin + "/" + idomWebModulesUrl
+ const loadImportSource = (source, sourceType) => {
+ return import(
+ sourceType == "NAME" ? `${fullWebModulesUrl}${source}` : source
+ );
+ };
+
+ mountLayoutWithWebSocket(mountPoint, fullWebsocketUrl, loadImportSource);
+}
diff --git a/tests/.gitignore b/tests/.gitignore
index bf4a6d58..60615839 100644
--- a/tests/.gitignore
+++ b/tests/.gitignore
@@ -1 +1 @@
-test_app/static/build.js
+*.sqlite3
diff --git a/tests/js/src/index.js b/tests/js/src/index.js
deleted file mode 100644
index 613515dc..00000000
--- a/tests/js/src/index.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import { mountLayoutWithWebSocket } from "idom-client-react";
-
-// Set up a websocket at the base endpoint
-let LOCATION = window.location;
-let WS_PROTOCOL = "";
-if (LOCATION.protocol == "https:") {
- WS_PROTOCOL = "wss://";
-} else {
- WS_PROTOCOL = "ws://";
-}
-let WS_ENDPOINT_URL = WS_PROTOCOL + LOCATION.host;
-
-mountLayoutWithWebSocket(document.getElementById("mount"), WS_ENDPOINT_URL);
diff --git a/tests/test_app/asgi.py b/tests/test_app/asgi.py
index 113b147f..dcb8112c 100644
--- a/tests/test_app/asgi.py
+++ b/tests/test_app/asgi.py
@@ -9,12 +9,9 @@
import os
-from django.conf.urls import url
from django.core.asgi import get_asgi_application
-from django_idom import IdomAsyncWebSocketConsumer # noqa: E402
-
-from .views import Root
+from django_idom import IDOM_WEBSOCKET_PATH
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings")
@@ -28,8 +25,6 @@
application = ProtocolTypeRouter(
{
"http": http_asgi_app,
- "websocket": URLRouter(
- [url("", IdomAsyncWebSocketConsumer.as_asgi(component=Root))]
- ),
+ "websocket": URLRouter([IDOM_WEBSOCKET_PATH]),
}
)
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
new file mode 100644
index 00000000..f242b9f1
--- /dev/null
+++ b/tests/test_app/components.py
@@ -0,0 +1,36 @@
+import idom
+
+
+@idom.component
+def HelloWorld():
+ return idom.html.h1({"id": "hello-world"}, "Hello World!")
+
+
+@idom.component
+def Button():
+ count, set_count = idom.hooks.use_state(0)
+ return idom.html.div(
+ idom.html.button(
+ {"id": "counter-inc", "onClick": lambda event: set_count(count + 1)},
+ "Click me!",
+ ),
+ idom.html.p(
+ {"id": "counter-num", "data-count": count},
+ f"Current count is: {count}",
+ ),
+ )
+
+
+@idom.component
+def ParametrizedComponent(x, y):
+ total = x + y
+ return idom.html.h1({"id": "parametrized-component", "data-value": total}, total)
+
+
+victory = idom.web.module_from_template("react", "victory-bar", fallback="...")
+VictoryBar = idom.web.export(victory, "VictoryBar")
+
+
+@idom.component
+def SimpleBarChart():
+ return VictoryBar()
diff --git a/tests/test_app/management/commands/__init__.py b/tests/test_app/management/commands/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_app/management/commands/build_js.py b/tests/test_app/management/commands/build_js.py
deleted file mode 100644
index 61af5ed9..00000000
--- a/tests/test_app/management/commands/build_js.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import subprocess
-from pathlib import Path
-
-from django.core.management.base import BaseCommand
-
-
-HERE = Path(__file__).parent
-JS_DIR = HERE.parent.parent.parent / "js"
-
-
-class Command(BaseCommand):
- help = "Build javascript source for test app"
-
- def handle(self, *args, **options):
- subprocess.run(["npm", "install"], cwd=JS_DIR, check=True)
- subprocess.run(["npm", "run", "build"], cwd=JS_DIR, check=True)
diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py
index 73eda0fa..ac7d6ab9 100644
--- a/tests/test_app/settings.py
+++ b/tests/test_app/settings.py
@@ -37,6 +37,7 @@
"django.contrib.messages",
"django.contrib.staticfiles",
"channels", # Websocket library
+ "django_idom", # Django compatible IDOM client
"test_app", # This test application
]
MIDDLEWARE = [
diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html
index d2a88607..6677c1b5 100644
--- a/tests/test_app/templates/base.html
+++ b/tests/test_app/templates/base.html
@@ -1,4 +1,4 @@
-{% load static %}
+{% load static %} {% load idom %}
@@ -15,7 +15,9 @@
IDOM Test Page
-
-
+ {% idom_component "test_app.components.HelloWorld" %}
+ {% idom_component "test_app.components.Button" %}
+ {% idom_component "test_app.components.ParametrizedComponent" x=123 y=456 %}
+ {% idom_component "test_app.components.SimpleBarChart" %}
diff --git a/tests/test_app/tests.py b/tests/test_app/tests.py
index 1a80ee5a..5520e7f4 100644
--- a/tests/test_app/tests.py
+++ b/tests/test_app/tests.py
@@ -2,6 +2,8 @@
from channels.testing import ChannelsLiveServerTestCase
from selenium import webdriver
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.ui import WebDriverWait
@@ -13,8 +15,11 @@ def setUp(self):
def tearDown(self) -> None:
self.driver.quit()
+ def wait(self, timeout=5):
+ return WebDriverWait(self.driver, timeout)
+
def wait_until(self, condition, timeout=5):
- WebDriverWait(self.driver, timeout).until(lambda driver: condition())
+ return self.wait(timeout).until(lambda driver: condition())
def test_hello_world(self):
self.driver.find_element_by_id("hello-world")
@@ -27,6 +32,17 @@ def test_counter(self):
self.wait_until(lambda: count.get_attribute("data-count") == str(i))
button.click()
+ def test_parametrized_component(self):
+ element = self.driver.find_element_by_id("parametrized-component")
+ self.assertEqual(element.get_attribute("data-value"), "579")
+
+ def test_component_from_web_module(self):
+ self.wait(10).until(
+ expected_conditions.visibility_of_element_located(
+ (By.CLASS_NAME, "VictoryContainer")
+ )
+ )
+
def make_driver(page_load_timeout, implicit_wait_timeout):
options = webdriver.ChromeOptions()
diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py
index 4449e447..cd4f3da0 100644
--- a/tests/test_app/urls.py
+++ b/tests/test_app/urls.py
@@ -19,7 +19,9 @@
"""
from django.urls import path
+from django_idom import IDOM_WEB_MODULES_PATH
+
from .views import base_template
-urlpatterns = [path("", base_template)]
+urlpatterns = [path("", base_template), IDOM_WEB_MODULES_PATH]
diff --git a/tests/test_app/views.py b/tests/test_app/views.py
index a996eb1e..248235a7 100644
--- a/tests/test_app/views.py
+++ b/tests/test_app/views.py
@@ -1,34 +1,7 @@
-import idom
from django.http import HttpResponse
from django.template import loader
def base_template(request):
- template = loader.get_template("base.html")
context = {}
- return HttpResponse(template.render(context, request))
-
-
-@idom.component
-def Root():
- return idom.html.div(HelloWorld(), Counter())
-
-
-@idom.component
-def HelloWorld():
- return idom.html.h1({"id": "hello-world"}, "Hello World!")
-
-
-@idom.component
-def Counter():
- count, set_count = idom.hooks.use_state(0)
- return idom.html.div(
- idom.html.button(
- {"id": "counter-inc", "onClick": lambda event: set_count(count + 1)},
- "Click me!",
- ),
- idom.html.p(
- {"id": "counter-num", "data-count": count},
- f"Current count is: {count}",
- ),
- )
+ return HttpResponse(loader.get_template("base.html").render(context, request))