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 @@ License: MIT -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! - -

- - Try it Now - Binder - -

+**You can try IDOM now in a Jupyter Notebook:** + + Binder + -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))