diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml new file mode 100644 index 00000000..a87f2f1c --- /dev/null +++ b/.github/workflows/publish-docs.yml @@ -0,0 +1,15 @@ +name: Publish Docs +on: + push: + branches: + - main +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: 3.x + - run: pip install requirements/build-docs.txt + - run: mkdocs gh-deploy --force diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eeb2f49..6a696e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,11 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + + + ## [Unreleased] -- Nothing (yet) +- Nothing (yet) + +## [1.0.0] - 2022-05-22 + +### Added + +- Django-specific hooks! `use_websocket`, `use_scope`, and `use_location` are now available within the `django_idom.hooks` module. +- Documentation has been placed into a formal docs webpage. +- Logging for when a component fails to import, or if no components were found within Django. + +### Changed + +- `idom_component` template tag has been renamed to `component` +- Bumped the minimum IDOM version to 0.38.0 + +### Removed + +- `websocket` parameter for components has been removed. Functionally, it is replaced with `django_idom.hooks.use_websocket`. ## [0.0.5] - 2022-04-04 ### Changed -- Bumped the minimum IDOM version to 0.37.2 +- Bumped the minimum IDOM version to 0.37.2 ### Fixed -- ModuleNotFoundError: No module named `idom.core.proto` caused by IDOM 0.37.2 +- ModuleNotFoundError: No module named `idom.core.proto` caused by IDOM 0.37.2 ## [0.0.4] - 2022-03-05 ### Changed -- Bumped the minimum IDOM version to 0.37.1 +- Bumped the minimum IDOM version to 0.37.1 ## [0.0.3] - 2022-02-19 ### Changed -- Bumped the minimum IDOM version to 0.36.3 +- Bumped the minimum IDOM version to 0.36.3 ## [0.0.2] - 2022-01-30 ### Added -- Ability to declare the HTML class of the top-level component `div` -- `name = ...` parameter to IDOM HTTP paths for use with `django.urls.reverse()` -- Cache versioning to automatically invalidate old web module files from the cache backend -- Automatic pre-population of the IDOM component registry -- Type hinting for `IdomWebsocket` +- Ability to declare the HTML class of the top-level component `div` +- `name = ...` parameter to IDOM HTTP paths for use with `django.urls.reverse()` +- Cache versioning to automatically invalidate old web module files from the cache backend +- Automatic pre-population of the IDOM component registry +- Type hinting for `IdomWebsocket` ### Changed -- Fetching web modules from disk and/or cache is now fully async -- Static files are now contained within a `django_idom/` parent folder -- Upgraded IDOM to version `0.36.0` -- Minimum Django version required is now `4.0` -- Minimum Python version required is now `3.8` +- Fetching web modules from disk and/or cache is now fully async +- Static files are now contained within a `django_idom/` parent folder +- Upgraded IDOM to version `0.36.0` +- Minimum Django version required is now `4.0` +- Minimum Python version required is now `3.8` ### Removed -- `IDOM_WEB_MODULES_PATH` has been replaced with Django `include(...)` -- `IDOM_WS_MAX_RECONNECT_DELAY` has been renamed to `IDOM_WS_MAX_RECONNECT_TIMEOUT` -- `idom_web_modules` cache backend has been renamed to `idom` +- `IDOM_WEB_MODULES_PATH` has been replaced with Django `include(...)` +- `IDOM_WS_MAX_RECONNECT_DELAY` has been renamed to `IDOM_WS_MAX_RECONNECT_TIMEOUT` +- `idom_web_modules` cache backend has been renamed to `idom` ### Fixed -- Increase test timeout values to prevent false positives -- Windows compatibility for building Django-IDOM +- Increase test timeout values to prevent false positives +- Windows compatibility for building Django-IDOM ### Security -- Fixed potential directory travesal attack on the IDOM web modules URL +- Fixed potential directory travesal attack on the IDOM web modules URL ## [0.0.1] - 2021-08-18 ### Added -- Support for IDOM within the Django +- Support for IDOM within the Django -[unreleased]: https://github.com/idom-team/django-idom/compare/0.0.2...HEAD +[unreleased]: https://github.com/idom-team/django-idom/compare/1.0.0...HEAD +[1.0.0]: https://github.com/idom-team/django-idom/compare/0.0.5...1.0.0 [0.0.5]: https://github.com/idom-team/django-idom/compare/0.0.4...0.0.5 [0.0.4]: https://github.com/idom-team/django-idom/compare/0.0.3...0.0.4 [0.0.3]: https://github.com/idom-team/django-idom/compare/0.0.2...0.0.3 diff --git a/README.md b/README.md index e24d5a07..0e72bd99 100644 --- a/README.md +++ b/README.md @@ -1,184 +1,79 @@ -# Django IDOM · [![Tests](https://github.com/idom-team/django-idom/workflows/Test/badge.svg?event=push)](https://github.com/idom-team/django-idom/actions?query=workflow%3ATest) [![PyPI Version](https://img.shields.io/pypi/v/django-idom.svg)](https://pypi.python.org/pypi/django-idom) [![License](https://img.shields.io/badge/License-MIT-purple.svg)](https://github.com/idom-team/django-idom/blob/main/LICENSE) - -`django-idom` allows Django to integrate with [IDOM](https://github.com/idom-team/idom), a reactive Python web framework for building **interactive websites without needing a single line of Javascript**. - -**You can try IDOM now in a Jupyter Notebook:** - -Binder - - -# Quick Example - -## `example_app/components.py` - -This is where you'll define your [IDOM](https://github.com/idom-team/idom) components. Ultimately though, you should -feel free to organize your component modules as you wish. Any components created will ultimately be referenced -by Python dotted path in `your-template.html`. - -```python -from idom import component, html -from django_idom import IdomWebsocket - -# Components are CamelCase by ReactJS convention -@component -def Hello(websocket: IdomWebsocket, greeting_recipient: str): - return html.h1(f"Hello {greeting_recipient}!") -``` - -## [`example_app/templates/your-template.html`](https://docs.djangoproject.com/en/dev/topics/templates/) - -In your templates, you may add IDOM components into your HTML by using the `idom_component` -template tag. This tag requires the dotted path to the component function. - -Additonally, you can pass in keyworded arguments into your component function. - -In context this will look a bit like the following... - -```jinja -{% load idom %} - - - - - Example Project - - - - {% idom_component "my_django_project.example_app.components.Hello" greeting_recipient="World" %} - - -``` - -# Installation - -Install `django-idom` via pip. + -```bash -pip install django-idom -``` - -You'll also need to modify a few files in your Django project. - -## [`settings.py`](https://docs.djangoproject.com/en/dev/topics/settings/) - -In your settings you'll need to add `channels` and `django_idom` to [`INSTALLED_APPS`](https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-INSTALLED_APPS). - -```python -INSTALLED_APPS = [ - ..., - "channels", - "django_idom", -] - -# Ensure ASGI_APPLICATION is set properly based on your project name! -ASGI_APPLICATION = "my_django_project.asgi.application" -``` - -**Optional:** You can also configure IDOM settings. +# Django IDOM · [![Tests](https://github.com/idom-team/django-idom/workflows/Test/badge.svg?event=push)](https://github.com/idom-team/django-idom/actions?query=workflow%3ATest) [![PyPI Version](https://img.shields.io/pypi/v/django-idom.svg)](https://pypi.python.org/pypi/django-idom) [![License](https://img.shields.io/badge/License-MIT-purple.svg)](https://github.com/idom-team/django-idom/blob/main/LICENSE) -```python -# If "idom" cache is not configured, then we'll use "default" instead -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: int = 604800 +Django-IDOM connects your project to a ReactJS frontend, allowing you to create **interactive websites without needing JavaScript!** -# The URL for IDOM to serve websockets -IDOM_WEBSOCKET_URL: str = "idom/" -``` +Following ReactJS styling, web elements are combined into [reusable "components"](https://idom-docs.herokuapp.com/docs/guides/creating-interfaces/your-first-components/index.html#parametrizing-components). These components can utilize [hooks](https://idom-docs.herokuapp.com/docs/reference/hooks-api.html) and [events](https://idom-docs.herokuapp.com/docs/guides/adding-interactivity/responding-to-events/index.html#async-event-handlers) to create infinitely complex web pages. -## [`urls.py`](https://docs.djangoproject.com/en/dev/topics/http/urls/) +When needed, IDOM can [use components directly from NPM](https://idom-docs.herokuapp.com/docs/guides/escape-hatches/javascript-components.html#dynamically-loaded-components). For additional flexibility, components can also be [fully developed in JavaScript](https://idom-docs.herokuapp.com/docs/guides/escape-hatches/javascript-components.html#custom-javascript-components). -Add IDOM HTTP paths to your `urlpatterns`. +Any Python web framework with Websockets can support IDOM. See below for what frameworks are supported out of the box. -```python -from django.urls import include, path +| Supported Frameworks | Supported Frameworks (External) | +| ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`Flask`, `FastAPI`, `Sanic`, `Tornado`](https://idom-docs.herokuapp.com/docs/guides/getting-started/installing-idom.html#officially-supported-servers) | [`Django`](https://github.com/idom-team/django-idom), [`Plotly-Dash`](https://github.com/idom-team/idom-dash), [`Jupyter`](https://github.com/idom-team/idom-jupyter) | -urlpatterns = [ - path("idom/", include("django_idom.http.urls")), - ... -] -``` + -## [`asgi.py`](https://docs.djangoproject.com/en/dev/howto/deployment/asgi/) +--- -Register IDOM's websocket using `IDOM_WEBSOCKET_PATH`. +# At a Glance -_Note: If you do not have an `asgi.py`, follow the [`channels` installation guide](https://channels.readthedocs.io/en/stable/installation.html)._ +## `my_app/components.py` -```python + -import os -from django.core.asgi import get_asgi_application +You'll need a file to define your [IDOM](https://github.com/idom-team/idom) components. We recommend creating a `components.py` file within your chosen **Django app** to start out. -# Ensure DJANGO_SETTINGS_MODULE is set properly based on your project name! -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "my_django_project.settings") -django_asgi_app = get_asgi_application() + + -from channels.auth import AuthMiddlewareStack -from channels.routing import ProtocolTypeRouter, URLRouter -from channels.sessions import SessionMiddlewareStack -from django_idom import IDOM_WEBSOCKET_PATH +```python title="components.py" +from idom import component, html -application = ProtocolTypeRouter( - { - "http": django_asgi_app, - "websocket": SessionMiddlewareStack( - AuthMiddlewareStack(URLRouter([IDOM_WEBSOCKET_PATH])) - ), - } -) +@component +def HelloWorld(recipient: str): + return html.h1(f"Hello {recipient}!") ``` -# Developer Guide + -If you plan to make code changes to this repository, you'll need to install the -following dependencies first: +## [`my_app/templates/my-template.html`](https://docs.djangoproject.com/en/dev/topics/templates/) -- [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: +In your **Django app**'s HTML located within your `templates` folder, 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. -```bash -git clone https://github.com/idom-team/django-idom.git -cd django-idom -``` - -Then, by running the command below you can: +Additonally, 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 `HelloWorld` (_in the previous example_) accepts a `recipient` argument. -- Install an editable version of the Python code + + -- Download, build, and install Javascript dependencies - -```bash -pip install -e . -r requirements.txt +```jinja title="my-template.html" +{% load idom %} + + + + {% component "example_project.my_app.components.HelloWorld" recipient="World" %} + + ``` -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: +# Resources -``` -nox -s test -``` + -To run the tests using a headless browser: +Follow the links below to find out more about this project. -``` -nox -s test -- --headless -``` +- [Try it Now](https://mybinder.org/v2/gh/idom-team/idom-jupyter/main?urlpath=lab/tree/notebooks/introduction.ipynb) - Check out IDOM in a Jupyter Notebook. +- [Documentation](https://idom-team.github.io/django-idom) - Learn how to install, run, and use IDOM. +- [Community Forum](https://github.com/idom-team/idom/discussions) - Ask questions, share ideas, and show off projects. + diff --git a/docs/changelog/index.md b/docs/changelog/index.md new file mode 100644 index 00000000..a6e2f878 --- /dev/null +++ b/docs/changelog/index.md @@ -0,0 +1,11 @@ +--- +hide: + - navigation + - toc +--- + +!!! note "Attribution" + + {% include-markdown "../../CHANGELOG.md" start="" end="" %} + +{% include-markdown "../../CHANGELOG.md" start="" %} diff --git a/docs/contribute/django-idom.md b/docs/contribute/django-idom.md new file mode 100644 index 00000000..4284ef66 --- /dev/null +++ b/docs/contribute/django-idom.md @@ -0,0 +1,35 @@ +???+ tip "Looking to contribute features that are not Django specific?" + + Everything within the `django-idom` repository must be specific to Django integration. Check out the [IDOM Core documentation](https://idom-docs.herokuapp.com/docs/about/contributor-guide.html) to contribute general features, such as: components, hooks, events, etc. + +If you plan to make code changes to this repository, you'll need to install the following dependencies first: + +- [Python 3.8+](https://www.python.org/downloads/) +- [Git](https://git-scm.com/downloads) +- [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 can manually run the development webserver. + +```bash +cd tests +python manage.py runserver +``` + +Navigate to `http://127.0.0.1:8000` to see if the tests are rendering correctly. diff --git a/docs/contribute/docs.md b/docs/contribute/docs.md new file mode 100644 index 00000000..231fb603 --- /dev/null +++ b/docs/contribute/docs.md @@ -0,0 +1,28 @@ +If you plan to make changes to this documentation, you'll need to install the following dependencies first: + +- [Python 3.8+](https://www.python.org/downloads/) +- [Git](https://git-scm.com/downloads) + +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 documentation +- Self-host a test server for the documentation + +```bash +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 +mkdocs serve +``` + +Navigate to `http://127.0.0.1:8000` to view a preview of the documentation. diff --git a/docs/contribute/running-tests.md b/docs/contribute/running-tests.md new file mode 100644 index 00000000..287b9301 --- /dev/null +++ b/docs/contribute/running-tests.md @@ -0,0 +1,11 @@ +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 +``` + +If you want to run the tests in the background (headless): + +``` +nox -s test -- --headless +``` diff --git a/docs/features/hooks.md b/docs/features/hooks.md new file mode 100644 index 00000000..a024e816 --- /dev/null +++ b/docs/features/hooks.md @@ -0,0 +1,51 @@ +# Django Hooks + +## Use Websocket + +You can fetch the Django Channels websocket at any time by using `use_websocket`. + +```python title="components.py" +from idom import component, html +from django_idom.hooks import use_websocket + +@component +def MyComponent(): + my_websocket = use_websocket() + return html.div(my_websocket) +``` + +--- + +## Use Scope + +This is a shortcut that returns the Websocket's `scope`. + +```python title="components.py" +from idom import component, html +from django_idom.hooks import use_scope + +@component +def MyComponent(): + my_scope = use_scope() + return html.div(my_scope) +``` + +--- + +## Use Location + +??? info "This hook's behavior will be changed in a future update" + + This hook will eventually be updated to return the client's current webpage URL. This will come in alongside our built-in [Single Page Application (SPA) support](https://github.com/idom-team/idom/issues/569). + +This is a shortcut that returns the Websocket's `path`. + +```python title="components.py" +from idom import component, html +from django_idom.hooks import use_location + +@component +def MyComponent(): + my_location = use_location() + return html.div(my_location) +``` diff --git a/docs/features/templatetag.md b/docs/features/templatetag.md new file mode 100644 index 00000000..26a2f4ad --- /dev/null +++ b/docs/features/templatetag.md @@ -0,0 +1,84 @@ +Integrated within Django IDOM, we bundle a template tag. Within this tag, you can pass in keyword arguments directly into your component. + +{% include-markdown "../../README.md" start="" end="" %} + + + +??? warning "Do not use context variables for the IDOM component name" + + Our pre-processor relies on the template tag containing a string. + + **Do not** use a Django context variable for the path string. Failure to follow this warning will result in a performance penalty and also jankiness when using the Django autoreloader. + + For example, **do not** do the following: + + ```python title="views.py" + def example_view(): + context_vars = {"DontDoThis": "example_project.my_app.components.HelloWorld"} + return render(request, "my-template.html", context_vars) + ``` + + ```jinja title="my-template.html" + + {% component DontDoThis recipient="World" %} + + + {% component "example_project.my_app.components.HelloWorld" recipient="World" %} + ``` + + + + +??? info "Reserved keyword arguments: `class` and `key`" + + 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. + + ```jinja title="my-template.html" + ... + {% component "example.components.MyComponent" class="my-html-class" key=123 %} + ... + ``` + + + + +??? question "Can I use multiple components on one page?" + + You can add as many components to a webpage as needed by using the template tag multiple times. Retrofitting legacy sites to use IDOM will typically involve many components on one page. + + ```jinja + {% load idom %} + + + + {% component "example_project.my_app.components.HelloWorld" recipient="World" %} + {% component "example_project.my_app_2.components.ClassComponent" class="bold small-font" %} +
{% component "example_project.my_app_3.components.SimpleComponent" %}
+ + + ``` + + 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. + + + + +??? 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'll need to first call `#!jinja {% load idom %}` to gain access to it. + + {% include-markdown "../../README.md" start="" end="" %} + + diff --git a/docs/getting-started/create-component.md b/docs/getting-started/create-component.md new file mode 100644 index 00000000..ca6e2a18 --- /dev/null +++ b/docs/getting-started/create-component.md @@ -0,0 +1,17 @@ +???+ summary + + Create a component function using our decorator. + +--- + +{% include-markdown "../../README.md" start="" end="" %} + +{% include-markdown "../../README.md" start="" end="" %} + +??? question "What should I name my IDOM files and functions?" + + You have full freedom in naming/placement of your files and functions. + + You should determine the best way to sort your Python modules and component functions to fit your needs. + + Ultimately, components are referenced by Python dotted path in `my-template.html` (_see next step_). So, at minimum this path needs to be valid to Python's `importlib`. diff --git a/docs/getting-started/initial-steps.md b/docs/getting-started/initial-steps.md new file mode 100644 index 00000000..daef5d4d --- /dev/null +++ b/docs/getting-started/initial-steps.md @@ -0,0 +1,19 @@ +???+ summary + + Set up a Django Project with at least one app. + +--- + +If you've reached this point, you should have already [installed Django-IDOM](../installation/index.md) through the previous steps. + +For the examples within this section, we will assume you've placed the files [generated by `startapp`](https://docs.djangoproject.com/en/dev/intro/tutorial01/#creating-the-polls-app) directly into your **Django project** folder. This is common for small projects. + +??? question "How do I organize my Django project for IDOM?" + + Django-IDOM has no project structure requirements. Organize everything as you wish, just like any Django project. + +??? question "I've never used Django, what do I need to learn?" + + {% include-markdown "../installation/index.md" start="" end="" %} + + Afterwards, make sure to follow the [Django-IDOM installation instructions](../installation/index.md). diff --git a/docs/getting-started/learn-more.md b/docs/getting-started/learn-more.md new file mode 100644 index 00000000..abbc1099 --- /dev/null +++ b/docs/getting-started/learn-more.md @@ -0,0 +1,11 @@ +# :confetti_ball: Congratulations :confetti_ball: + +If you followed the previous steps, you've now created a "Hello World" component! + +The docs you are reading only covers our Django integration. + +To learn more about our advanced features, such as interactive events and hooks, check out the [IDOM Core Documentation](https://idom-docs.herokuapp.com/docs/guides/creating-interfaces/index.html)! + +| Learn More | +| --- | +| [Django-IDOM — Exclusive Features](../features/hooks.md){ .md-button } [IDOM Core — Hooks, Events, and More](https://idom-docs.herokuapp.com/docs/guides/creating-interfaces/index.html){ .md-button } | diff --git a/docs/getting-started/reference-component.md b/docs/getting-started/reference-component.md new file mode 100644 index 00000000..af7dd77c --- /dev/null +++ b/docs/getting-started/reference-component.md @@ -0,0 +1,23 @@ +???+ summary + + Decide where the component will be displayed by using our template tag. + +--- + +{% include-markdown "../../README.md" start="" end="" %} + +{% include-markdown "../../README.md" start="" end="" %} + +{% include-markdown "../features/templatetag.md" start="" end="" %} + +{% include-markdown "../features/templatetag.md" start="" end="" %} + +{% 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/docs/getting-started/render-view.md b/docs/getting-started/render-view.md new file mode 100644 index 00000000..03fb8d6f --- /dev/null +++ b/docs/getting-started/render-view.md @@ -0,0 +1,39 @@ +???+ summary + + Select your template containing an IDOM component, and render it using a Django view. + +--- + +We will assume you've [created a Django View](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view) before, but here's a simple example below. + +Within your **Django app**'s `views.py` file, you'll need to create a function to render the HTML template containing your IDOM components. + +In this example, we will create a view that renders `my-template.html` (_from the previous step_). + +```python title="views.py" +from django.shortcuts import render + +def index(request): + return render(request, "my-template.html") +``` + +We will add this new view into your [`urls.py`](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view). + +```python title="urls.py" +from django.urls import path +from example_project.my_app import views + +urlpatterns = [ + path("example/", views.index), +] +``` + +Now, navigate to `http://127.0.0.1:8000/example/`. If you copy-pasted the component from the previous example, you will now see your component display "Hello World". + +??? question "Which urls.py do I add my views to?" + + For simple Django projects, you can easily add all of your views directly into the **Django project**'s `urls.py`. However, as you start increase your project's complexity you might end up with way too much within one file. + + Once you reach that point, we recommend creating an individual `urls.py` within each of your **Django apps**. + + Then, within your **Django project**'s `urls.py` you will use Django's [`include` function](https://docs.djangoproject.com/en/dev/ref/urls/) to link it all together. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..39874a5f --- /dev/null +++ b/docs/index.md @@ -0,0 +1,17 @@ +--- +hide: + - navigation + - toc +--- + +{% include-markdown "../README.md" start="" end="" %} + +## It's React for Django Developers. + +--- + +{% include-markdown "../README.md" start="" end="" %} + +## Resources + +{% include-markdown "../README.md" start="" end="" %} diff --git a/docs/installation/index.md b/docs/installation/index.md new file mode 100644 index 00000000..cdf9820b --- /dev/null +++ b/docs/installation/index.md @@ -0,0 +1,105 @@ + + +These docs assumes you have created [a basic **Django project**](https://docs.djangoproject.com/en/dev/intro/tutorial01/), which also involves generating/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_. + + + +## Install from PyPI + +```bash +pip install django-idom +``` + +You'll also need to modify a few files in your Django project... + +--- + +## Configure [`settings.py`](https://docs.djangoproject.com/en/dev/topics/settings/) + +In your settings you'll need to add `django_idom` to [`INSTALLED_APPS`](https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-INSTALLED_APPS). + +```python title="settings.py" +INSTALLED_APPS = [ + "django_idom", + ... +] +``` + +??? warning "Enabling ASGI on Django (Required)" + + Django-IDOM requires ASGI in order to use Websockets. + + If you haven't [enabled ASGI](https://channels.readthedocs.io/en/stable/installation.html) on your Django project yet, you'll need to add `channels` to `INSTALLED_APPS` and set your `ASGI_APPLICATION` variable. + ```python title="settings.py" + INSTALLED_APPS = [ + "channels", + ... + ] + ASGI_APPLICATION = "example_project.asgi.application" + ``` + +??? note "Configure IDOM settings (Optional)" + + Below are a handful of values you can change within `settings.py` to modify the behavior of IDOM. + + ```python title="settings.py" + # If "idom" cache is not configured, then we'll use "default" instead + 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 + + # The URL for IDOM to serve websockets + IDOM_WEBSOCKET_URL = "idom/" + ``` + +--- + +## Configure [`urls.py`](https://docs.djangoproject.com/en/dev/topics/http/urls/) + +Add IDOM HTTP paths to your `urlpatterns`. + +```python title="urls.py" +from django.urls import include, path + +urlpatterns = [ + path("idom/", include("django_idom.http.urls")), + ... +] +``` + +--- + +## Configure [`asgi.py`](https://docs.djangoproject.com/en/dev/howto/deployment/asgi/) + +Register IDOM's Websocket using `IDOM_WEBSOCKET_PATH`. + +```python title="asgi.py" +import os +from django.core.asgi import get_asgi_application + +# Ensure DJANGO_SETTINGS_MODULE is set properly based on your project name! +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") +django_asgi_app = get_asgi_application() + +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.sessions import SessionMiddlewareStack +from django_idom import IDOM_WEBSOCKET_PATH + +application = ProtocolTypeRouter( + { + "http": django_asgi_app, + "websocket": SessionMiddlewareStack( + AuthMiddlewareStack(URLRouter([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). diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..487f21ae --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,69 @@ +--- +nav: + - Home: index.md + - Installation: installation/index.md + - Getting Started: + - 1. Initial Steps: getting-started/initial-steps.md + - 2. Create a Component: getting-started/create-component.md + - 3. Use the Template Tag: getting-started/reference-component.md + - 4. Render Your View: getting-started/render-view.md + - 5. Learn More: getting-started/learn-more.md + - Exclusive Features: + - Hooks: features/hooks.md + - Template Tag: features/templatetag.md + - Contribute: + - Code: contribute/django-idom.md + - Docs: contribute/docs.md + - Running Tests: contribute/running-tests.md + - Changelog: changelog/index.md + +theme: + name: material + palette: + - scheme: slate + toggle: + icon: material/toggle-switch + name: Switch to light mode + primary: deep-orange + accent: deep-orange + - scheme: default + toggle: + icon: material/toggle-switch-off-outline + name: Switch to dark mode + primary: black + features: + - navigation.instant + - navigation.tracking + - navigation.tabs + - toc.integrate + - navigation.top + + icon: + repo: fontawesome/brands/github + +markdown_extensions: + - toc: + permalink: true + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg + - pymdownx.highlight + - pymdownx.superfences + - pymdownx.details + - pymdownx.inlinehilite + - admonition + - attr_list + +plugins: + - search + - include-markdown + - git-revision-date-localized: + fallback_to_build_date: true + +site_name: Django IDOM Docs +site_author: Archmonger +site_description: React for Django developers. +repo_url: https://github.com/idom-team/django-idom +site_url: https://idom-team.github.io/django-idom +repo_name: idom-team/django-idom +edit_uri: edit/docs diff --git a/requirements/build-docs.txt b/requirements/build-docs.txt new file mode 100644 index 00000000..ce3fba84 --- /dev/null +++ b/requirements/build-docs.txt @@ -0,0 +1,4 @@ +mkdocs +mkdocs-git-revision-date-localized-plugin +mkdocs-material +mkdocs-include-markdown-plugin \ No newline at end of file diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 98683e1a..7b9f9420 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,3 +1,3 @@ -channels <4.0.0 -idom >=0.37.2, <0.38.0 -aiofile >=3.0, <4.0 +channels >=3.0.0 +idom >=0.38.0, <0.39.0 +aiofile >=3.0 diff --git a/src/django_idom/__init__.py b/src/django_idom/__init__.py index eb1e19d8..af2dec38 100644 --- a/src/django_idom/__init__.py +++ b/src/django_idom/__init__.py @@ -1,6 +1,7 @@ +from . import hooks from .websocket.consumer import IdomWebsocket from .websocket.paths import IDOM_WEBSOCKET_PATH -__version__ = "0.0.5" -__all__ = ["IDOM_WEBSOCKET_PATH", "IdomWebsocket"] +__version__ = "1.0.0" +__all__ = ["IDOM_WEBSOCKET_PATH", "IdomWebsocket", "hooks"] diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py new file mode 100644 index 00000000..5d088223 --- /dev/null +++ b/src/django_idom/hooks.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +from typing import Awaitable, Callable, Dict, Optional, Type, Union + +from idom.backend.types import Location +from idom.core.hooks import Context, create_context, use_context + + +@dataclass +class IdomWebsocket: + scope: dict + close: Callable[[Optional[int]], Awaitable[None]] + disconnect: Callable[[int], Awaitable[None]] + view_id: str + + +WebsocketContext: Type[Context[Union[IdomWebsocket, None]]] = create_context( + None, "WebSocketContext" +) + + +def use_location() -> Location: + """Get the current route as a string""" + # 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 "") + + +def use_scope() -> Dict: + """Get the current ASGI scope dictionary""" + return use_websocket().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 diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index 28def13e..5707733d 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -14,7 +14,7 @@ @register.inclusion_tag("idom/component.html") -def idom_component(_component_id_, **kwargs): +def component(_component_id_, **kwargs): _register_component(_component_id_) class_ = kwargs.pop("class", "") diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py index 178995c9..81783013 100644 --- a/src/django_idom/utils.py +++ b/src/django_idom/utils.py @@ -1,3 +1,4 @@ +import contextlib import logging import os import re @@ -11,7 +12,7 @@ from django_idom.config import IDOM_REGISTERED_COMPONENTS -COMPONENT_REGEX = re.compile(r"{% *idom_component ((\"[^\"']*\")|('[^\"']*')).*?%}") +COMPONENT_REGEX = re.compile(r"{% *component +((\"[^\"']*\")|('[^\"']*'))(.*?)%}") _logger = logging.getLogger(__name__) @@ -36,6 +37,7 @@ def _register_component(full_component_name: str) -> None: ) from error IDOM_REGISTERED_COMPONENTS[full_component_name] = component + _logger.debug("IDOM has registered component %s", full_component_name) class ComponentPreloader: @@ -70,15 +72,12 @@ def _get_paths(self) -> Set: """Obtains a set of all template directories.""" paths = set() for loader in self._get_loaders(): - try: + with contextlib.suppress(ImportError, AttributeError, TypeError): module = import_module(loader.__module__) get_template_sources = getattr(module, "get_template_sources", None) if get_template_sources is None: get_template_sources = loader.get_template_sources paths.update(smart_str(origin) for origin in get_template_sources("")) - except (ImportError, AttributeError, TypeError): - pass - return paths def _get_templates(self, paths: Set) -> Set: @@ -91,7 +90,7 @@ def _get_templates(self, paths: Set) -> Set: os.path.join(root, name) for name in files if not name.startswith(".") - and any(fnmatch(name, "*%s" % glob) for glob in extensions) + and any(fnmatch(name, f"*{glob}") for glob in extensions) ) return templates @@ -100,7 +99,7 @@ def _get_components(self, templates: Set) -> Set: """Obtains a set of all IDOM components by parsing HTML templates.""" components = set() for template in templates: - try: + with contextlib.suppress(Exception): with open(template, "r", encoding="utf-8") as template_file: match = COMPONENT_REGEX.findall(template_file.read()) if not match: @@ -108,16 +107,29 @@ def _get_components(self, templates: Set) -> Set: components.update( [group[0].replace('"', "").replace("'", "") for group in match] ) - except Exception: - pass - + if not components: + _logger.warning( + "\033[93m" + "IDOM did not find any components! " + "You are either not using any IDOM components, " + "using the template tag incorrectly, " + "or your HTML templates are not registered with Django." + "\033[0m" + ) return components def _register_components(self, components: Set) -> None: """Registers all IDOM components in an iterable.""" for component in components: try: + _logger.info("IDOM preloader has detected component %s", component) _register_component(component) - _logger.info("IDOM has registered component %s", component) except Exception: - _logger.warning("IDOM failed to register component %s", component) + _logger.error( + "\033[91m" + "IDOM failed to register component '%s'! " + "This component path may not be valid, " + "or an exception may have occurred while importing." + "\033[0m", + component, + ) diff --git a/src/django_idom/websocket/consumer.py b/src/django_idom/websocket/consumer.py index 8f773a38..aa988afa 100644 --- a/src/django_idom/websocket/consumer.py +++ b/src/django_idom/websocket/consumer.py @@ -2,30 +2,22 @@ import asyncio import json import logging -from dataclasses import dataclass -from typing import Any, Awaitable, Callable, Optional +from typing import Any from urllib.parse import parse_qsl from channels.auth import login from channels.db import database_sync_to_async as convert_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer -from idom.core.dispatcher import dispatch_single_view 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 IdomWebsocket, WebsocketContext _logger = logging.getLogger(__name__) -@dataclass -class IdomWebsocket: - scope: dict - close: Callable[[Optional[int]], Awaitable[None]] - disconnect: Callable[[int], Awaitable[None]] - view_id: str - - class IdomAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer): """Communicates with the browser to perform actions on-demand.""" @@ -70,7 +62,7 @@ async def _run_dispatch_loop(self): socket = IdomWebsocket(self.scope, self.close, self.disconnect, view_id) try: - component_instance = component_constructor(socket, **component_kwargs) + component_instance = component_constructor(**component_kwargs) except Exception: _logger.exception( f"Failed to construct component {component_constructor} " @@ -80,8 +72,8 @@ async def _run_dispatch_loop(self): self._idom_recv_queue = recv_queue = asyncio.Queue() try: - await dispatch_single_view( - Layout(component_instance), + await serve_json_patch( + Layout(WebsocketContext(component_instance, value=socket)), self.send_json, recv_queue.get, ) diff --git a/src/django_idom/websocket/paths.py b/src/django_idom/websocket/paths.py index 0d2920e0..f337c83e 100644 --- a/src/django_idom/websocket/paths.py +++ b/src/django_idom/websocket/paths.py @@ -6,8 +6,9 @@ IDOM_WEBSOCKET_PATH = path( - IDOM_WEBSOCKET_URL + "/", IdomAsyncWebsocketConsumer.as_asgi() + f"{IDOM_WEBSOCKET_URL}/", IdomAsyncWebsocketConsumer.as_asgi() ) + """A URL path for :class:`IdomAsyncWebsocketConsumer`. Required in order for IDOM to know the websocket path. diff --git a/tests/test_app/components.py b/tests/test_app/components.py index d3452efb..2efd878c 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -1,13 +1,15 @@ import idom +import django_idom + @idom.component -def HelloWorld(websocket): +def HelloWorld(): return idom.html.h1({"id": "hello-world"}, "Hello World!") @idom.component -def Button(websocket): +def Button(): count, set_count = idom.hooks.use_state(0) return idom.html.div( idom.html.button( @@ -22,7 +24,7 @@ def Button(websocket): @idom.component -def ParametrizedComponent(websocket, x, y): +def ParametrizedComponent(x, y): total = x + y return idom.html.h1({"id": "parametrized-component", "data-value": total}, total) @@ -32,5 +34,40 @@ def ParametrizedComponent(websocket, x, y): @idom.component -def SimpleBarChart(websocket): +def SimpleBarChart(): return VictoryBar() + + +@idom.component +def UseWebsocket(): + ws = django_idom.hooks.use_websocket() + ws.scope = "..." + success = bool(ws.scope and ws.close and ws.disconnect and ws.view_id) + return idom.html.div( + {"id": "use-websocket", "data-success": success}, + idom.html.hr(), + f"UseWebsocket: {ws}", + idom.html.hr(), + ) + + +@idom.component +def UseScope(): + scope = django_idom.hooks.use_scope() + success = len(scope) >= 10 and scope["type"] == "websocket" + return idom.html.div( + {"id": "use-scope", "data-success": success}, + f"UseScope: {scope}", + idom.html.hr(), + ) + + +@idom.component +def UseLocation(): + location = django_idom.hooks.use_location() + success = bool(location) + return idom.html.div( + {"id": "use-location", "data-success": success}, + f"UseLocation: {location}", + idom.html.hr(), + ) diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index 29eadffa..fbacac81 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -121,3 +121,20 @@ "django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] + +# Logging +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "loggers": { + "django_idom": { + "handlers": ["console"], + "level": "DEBUG", + }, + }, +} diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index 5b2311d2..0c52a8cc 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -12,11 +12,13 @@

IDOM Test Page

-
{% idom_component "test_app.components.HelloWorld" class="hello-world" %}
-
{% idom_component "test_app.components.Button" class="button" %}
-
{% idom_component "test_app.components.ParametrizedComponent" class="parametarized-component" x=123 y=456 %} -
-
{% idom_component "test_app.components.SimpleBarChart" class="simple-bar-chart" %}
+
{% component "test_app.components.HelloWorld" class="hello-world" %}
+
{% component "test_app.components.Button" class="button" %}
+
{% component "test_app.components.ParametrizedComponent" class="parametarized-component" x=123 y=456 %}
+
{% component "test_app.components.SimpleBarChart" %}
+
{% component "test_app.components.UseWebsocket" %}
+
{% component "test_app.components.UseScope" %}
+
{% component "test_app.components.UseLocation" %}
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index cb10cca9..fbfda829 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -5,7 +5,7 @@ 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 +from selenium.webdriver.support.wait import WebDriverWait # These tests are broken on Windows due to Selenium @@ -47,6 +47,18 @@ def test_component_from_web_module(self): ) ) + def test_use_websocket(self): + element = self.driver.find_element_by_id("use-websocket") + self.assertEqual(element.get_attribute("data-success"), "true") + + def test_use_scope(self): + element = self.driver.find_element_by_id("use-scope") + self.assertEqual(element.get_attribute("data-success"), "true") + + def test_use_location(self): + element = self.driver.find_element_by_id("use-location") + self.assertEqual(element.get_attribute("data-success"), "true") + def make_driver(page_load_timeout, implicit_wait_timeout): options = webdriver.ChromeOptions() diff --git a/tests/test_app/tests/test_regex.py b/tests/test_app/tests/test_regex.py index 3e418f1d..c0942dba 100644 --- a/tests/test_app/tests/test_regex.py +++ b/tests/test_app/tests/test_regex.py @@ -6,22 +6,23 @@ class RegexTests(TestCase): def test_component_regex(self): for component in { - r'{%idom_component "my.component"%}', - r"{%idom_component 'my.component'%}", - r'{% idom_component "my.component" %}', - r"{% idom_component 'my.component' %}", - r'{% idom_component "my.component" class="my_thing" %}', - r'{% idom_component "my.component" class="my_thing" attr="attribute" %}', + r'{%component "my.component"%}', + r'{%component "my.component"%}', + r"{%component 'my.component'%}", + r'{% component "my.component" %}', + r"{% component 'my.component' %}", + r'{% component "my.component" class="my_thing" %}', + r'{% component "my.component" class="my_thing" attr="attribute" %}', }: self.assertRegex(component, COMPONENT_REGEX) for fake_component in { r'{% not_a_real_thing "my.component" %}', - r"{% idom_component my.component %}", - r"""{% idom_component 'my.component" %}""", - r'{ idom_component "my.component" }', - r'{{ idom_component "my.component" }}', - r"idom_component", + r"{% component my.component %}", + r"""{% component 'my.component" %}""", + r'{ component "my.component" }', + r'{{ component "my.component" }}', + r"component", r"{%%}", r" ", r"",