From 41641aab4efed18a1c3202cdc605ab291536d126 Mon Sep 17 00:00:00 2001 From: Mark Bakhit <16909269+Archmonger@users.noreply.github.com> Date: Fri, 18 Aug 2023 02:19:20 -0700 Subject: [PATCH 01/68] Allow users to modify component's host URLs (#172) ### Added - **Distributed Computing:** ReactPy components can now optionally be rendered by a completely separate server! - `REACTPY_DEFAULT_HOSTS` setting can round-robin a list of ReactPy rendering hosts. - `host` argument has been added to the `component` template tag to force components to render on a specific host. - `reactpy_django.utils.register_component` function to manually register root components. - Useful if you have dedicated ReactPy rendering application(s) that do not use HTML templates. ### Changed - ReactPy will now provide a warning if your HTTP URLs are not on the same prefix as your websockets. - Cleaner logging output for detected ReactPy root components. ### Deprecated - `reactpy_django.REACTPY_WEBSOCKET_PATH` is deprecated. The similar replacement is `REACTPY_WEBSOCKET_ROUTE`. - `settings.py:REACTPY_WEBSOCKET_URL` is deprecated. The similar replacement is `REACTPY_URL_PREFIX`. ### Removed - Warning W007 (`REACTPY_WEBSOCKET_URL doesn't end with a slash`) has been removed. ReactPy now automatically handles slashes. - Warning W008 (`REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character`) has been removed. ReactPy now automatically handles this scenario. - Error E009 (`channels is not in settings.py:INSTALLED_APPS`) has been removed. Newer versions of `channels` do not require installation via `INSTALLED_APPS` to receive an ASGI webserver. --- .gitignore | 4 +- CHANGELOG.md | 24 +++- docs/python/configure-asgi-middleware.py | 4 +- docs/python/configure-asgi.py | 4 +- docs/python/register-component.py | 8 ++ docs/python/settings.py | 32 ----- docs/src/features/components.md | 2 +- docs/src/features/settings.md | 19 ++- docs/src/features/template-tag.md | 23 ++- docs/src/features/utils.md | 22 +++ docs/src/get-started/installation.md | 16 ++- pyproject.toml | 6 +- src/js/src/index.js | 53 +++++-- src/reactpy_django/__init__.py | 6 +- src/reactpy_django/checks.py | 123 +++++++++++----- src/reactpy_django/config.py | 23 ++- src/reactpy_django/exceptions.py | 4 + .../templates/reactpy/component.html | 9 +- src/reactpy_django/templatetags/reactpy.py | 131 +++++++++++------- src/reactpy_django/utils.py | 30 ++-- src/reactpy_django/websocket/consumer.py | 42 +++--- src/reactpy_django/websocket/paths.py | 12 +- tests/test_app/asgi.py | 7 +- tests/test_app/components.py | 20 ++- tests/test_app/settings.py | 9 +- tests/test_app/templates/base.html | 2 + tests/test_app/templates/host_port.html | 21 +++ .../templates/host_port_roundrobin.html | 23 +++ tests/test_app/tests/test_components.py | 84 ++++++++++- tests/test_app/urls.py | 6 +- tests/test_app/views.py | 31 +++++ 31 files changed, 584 insertions(+), 216 deletions(-) create mode 100644 docs/python/register-component.py delete mode 100644 docs/python/settings.py create mode 100644 tests/test_app/templates/host_port.html create mode 100644 tests/test_app/templates/host_port_roundrobin.html diff --git a/.gitignore b/.gitignore index a59a51e4..07d4c0cd 100644 --- a/.gitignore +++ b/.gitignore @@ -89,8 +89,8 @@ celerybeat-schedule.* *.sage.py # Environments -.env -.venv +.env*/ +.venv*/ env/ venv/ ENV/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f06d0a..70c5b054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,29 @@ Using the following categories, list your changes in this order: ## [Unreleased] -- Nothing (yet)! +### Added + +- **Distributed Computing:** ReactPy components can now optionally be rendered by a completely separate server! + - `REACTPY_DEFAULT_HOSTS` setting can round-robin a list of ReactPy rendering hosts. + - `host` argument has been added to the `component` template tag to force components to render on a specific host. +- `reactpy_django.utils.register_component` function to manually register root components. + - Useful if you have dedicated ReactPy rendering application(s) that do not use HTML templates. + +### Changed + +- ReactPy will now provide a warning if your HTTP URLs are not on the same prefix as your websockets. +- Cleaner logging output for auto-detected ReactPy root components. + +### Deprecated + +- `reactpy_django.REACTPY_WEBSOCKET_PATH` is deprecated. The identical replacement is `REACTPY_WEBSOCKET_ROUTE`. +- `settings.py:REACTPY_WEBSOCKET_URL` is deprecated. The similar replacement is `REACTPY_URL_PREFIX`. + +### Removed + +- Warning W007 (`REACTPY_WEBSOCKET_URL doesn't end with a slash`) has been removed. ReactPy now automatically handles slashes. +- Warning W008 (`REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character`) has been removed. ReactPy now automatically handles this scenario. +- Error E009 (`channels is not in settings.py:INSTALLED_APPS`) has been removed. Newer versions of `channels` do not require installation via `INSTALLED_APPS` to receive an ASGI webserver. ## [3.3.2] - 2023-08-13 diff --git a/docs/python/configure-asgi-middleware.py b/docs/python/configure-asgi-middleware.py index 3e8e6523..e817ee48 100644 --- a/docs/python/configure-asgi-middleware.py +++ b/docs/python/configure-asgi-middleware.py @@ -1,6 +1,6 @@ # Broken load order, only used for linting from channels.routing import ProtocolTypeRouter, URLRouter -from reactpy_django import REACTPY_WEBSOCKET_PATH +from reactpy_django import REACTPY_WEBSOCKET_ROUTE django_asgi_app = "" @@ -15,7 +15,7 @@ "websocket": SessionMiddlewareStack( AuthMiddlewareStack( URLRouter( - [REACTPY_WEBSOCKET_PATH], + [REACTPY_WEBSOCKET_ROUTE], ) ) ), diff --git a/docs/python/configure-asgi.py b/docs/python/configure-asgi.py index b574c684..8081d747 100644 --- a/docs/python/configure-asgi.py +++ b/docs/python/configure-asgi.py @@ -10,11 +10,11 @@ from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 -from reactpy_django import REACTPY_WEBSOCKET_PATH # noqa: E402 +from reactpy_django import REACTPY_WEBSOCKET_ROUTE # noqa: E402 application = ProtocolTypeRouter( { "http": django_asgi_app, - "websocket": URLRouter([REACTPY_WEBSOCKET_PATH]), + "websocket": URLRouter([REACTPY_WEBSOCKET_ROUTE]), } ) diff --git a/docs/python/register-component.py b/docs/python/register-component.py new file mode 100644 index 00000000..c8ad12e9 --- /dev/null +++ b/docs/python/register-component.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from reactpy_django.utils import register_component + + +class ExampleConfig(AppConfig): + def ready(self): + # Add components to the ReactPy component registry when Django is ready + register_component("example_project.my_app.components.hello_world") diff --git a/docs/python/settings.py b/docs/python/settings.py deleted file mode 100644 index c9a26f5a..00000000 --- a/docs/python/settings.py +++ /dev/null @@ -1,32 +0,0 @@ -# Cache used to store ReactPy web modules. -# ReactPy benefits from a fast, well indexed cache. -# We recommend redis or python-diskcache. -REACTPY_CACHE = "default" - -# Database ReactPy uses to store session data. -# ReactPy requires a multiprocessing-safe and thread-safe database. -# DATABASE_ROUTERS is mandatory if REACTPY_DATABASE is configured. -REACTPY_DATABASE = "default" -DATABASE_ROUTERS = ["reactpy_django.database.Router", ...] - -# Maximum seconds between reconnection attempts before giving up. -# Use `0` to prevent component reconnection. -REACTPY_RECONNECT_MAX = 259200 - -# The URL for ReactPy to serve the component rendering websocket. -REACTPY_WEBSOCKET_URL = "reactpy/" - -# Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function, -# or `None`. -REACTPY_DEFAULT_QUERY_POSTPROCESSOR = "reactpy_django.utils.django_query_postprocessor" - -# Dotted path to the Django authentication backend to use for ReactPy components. -# This is only needed if: -# 1. You are using `AuthMiddlewareStack` and... -# 2. You are using Django's `AUTHENTICATION_BACKENDS` setting and... -# 3. Your Django user model does not define a `backend` attribute -REACTPY_AUTH_BACKEND = "django.contrib.auth.backends.ModelBackend" - -# Whether to enable rendering ReactPy via a dedicated backhaul thread. -# This allows the webserver to process traffic while during ReactPy rendering. -REACTPY_BACKHAUL_THREAD = False diff --git a/docs/src/features/components.md b/docs/src/features/components.md index d7926803..900b9fe2 100644 --- a/docs/src/features/components.md +++ b/docs/src/features/components.md @@ -37,7 +37,7 @@ Convert any Django view into a ReactPy component by using this decorator. Compat It is your responsibility to ensure privileged information is not leaked via this method. - This can be done via directly writing conditionals into your view, or by adding decorators such as [`user_passes_test`](https://docs.djangoproject.com/en/dev/topics/auth/default/#django.contrib.auth.decorators.user_passes_test) to your views prior to using `view_to_component`. + You must implement a method to ensure only authorized users can access your view. This can be done via directly writing conditionals into your view, or by adding decorators such as [`user_passes_test`](https://docs.djangoproject.com/en/dev/topics/auth/default/#django.contrib.auth.decorators.user_passes_test) to your views. For example... === "Function Based View" diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md index 3abd6575..29ca81ad 100644 --- a/docs/src/features/settings.md +++ b/docs/src/features/settings.md @@ -6,16 +6,25 @@ ## Primary Configuration + + These are ReactPy-Django's default settings values. You can modify these values in your **Django project's** `settings.py` to change the behavior of ReactPy. -=== "settings.py" +| Setting | Default Value | Example Value(s) | Description | +| --- | --- | --- | --- | +| `REACTPY_CACHE` | `#!python "default"` | `#!python "my-reactpy-cache"` | Cache used to store ReactPy web modules. ReactPy benefits from a fast, well indexed cache.
We recommend installing [`redis`](https://redis.io/) or [`python-diskcache`](https://grantjenks.com/docs/diskcache/tutorial.html#djangocache). | +| `REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Database ReactPy uses to store session data. ReactPy requires a multiprocessing-safe and thread-safe database.
If configuring `REACTPY_DATABASE`, it is mandatory to also configure `DATABASE_ROUTERS` like such:
`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` | +| `REACTPY_RECONNECT_MAX` | `#!python 259200` | `#!python 96000`, `#!python 60`, `#!python 0` | Maximum seconds between reconnection attempts before giving up.
Use `#!python 0` to prevent reconnection. | +| `REACTPY_URL_PREFIX` | `#!python "reactpy/"` | `#!python "rp/"`, `#!python "render/reactpy/"` | The prefix to be used for all ReactPy websocket and HTTP URLs. | +| `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python "example_project.my_query_postprocessor"` | Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function. | +| `REACTPY_AUTH_BACKEND` | `#!python "django.contrib.auth.backends.ModelBackend"` | `#!python "example_project.auth.MyModelBackend"` | Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:
1. You are using `AuthMiddlewareStack` and...
2. You are using Django's `AUTHENTICATION_BACKENDS` setting and...
3. Your Django user model does not define a `backend` attribute. | +| `REACTPY_BACKHAUL_THREAD` | `#!python False` | `#!python True` | Whether to render ReactPy components in a dedicated thread. This allows the webserver to process web traffic while during ReactPy rendering.
Vastly improves throughput with web servers such as `hypercorn` and `uvicorn`. | +| `REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir" ]` | Default host(s) to use for ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing.
You can use the `host` argument in your [template tag](../features/template-tag.md#component) to override this default. | - ```python - {% include "../../python/settings.py" %} - ``` + ??? question "Do I need to modify my settings?" - The default configuration of ReactPy is adequate for the majority of use cases. + The default configuration of ReactPy is suitable for the majority of use cases. You should only consider changing settings when the necessity arises. diff --git a/docs/src/features/template-tag.md b/docs/src/features/template-tag.md index 9d4ca18c..f1424059 100644 --- a/docs/src/features/template-tag.md +++ b/docs/src/features/template-tag.md @@ -20,6 +20,7 @@ The `component` template tag can be used to insert any number of ReactPy compone | --- | --- | --- | --- | | `dotted_path` | `str` | The dotted path to the component to render. | N/A | | `*args` | `Any` | The positional arguments to provide to the component. | N/A | + | `host` | `str | None` | The host to use for the ReactPy connections. If set to `None`, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir` | `None` | | `**kwargs` | `Any` | The keyword arguments to provide to the component. | N/A | **Returns** @@ -73,6 +74,27 @@ The `component` template tag can be used to insert any number of ReactPy compone ``` + +??? question "Can I render components on a different server (distributed computing)?" + + Yes! By using the `host` keyword argument, you can render components from a completely separate ASGI server. + + === "my-template.html" + + ```jinja + ... + {% component "example_project.my_app.components.do_something" host="127.0.0.1:8001" %} + ... + ``` + + This configuration most commonly involves you deploying multiple instances of your project. But, you can also create dedicated Django project(s) that only render specific ReactPy components if you wish. + + Here's a couple of things to keep in mind: + + 1. If your host address are completely separate ( `origin1.com != origin2.com` ) you will need to [configure CORS headers](https://pypi.org/project/django-cors-headers/) on your main application during deployment. + 2. You will not need to register ReactPy HTTP or websocket paths on any applications that do not perform any component rendering. + 3. Your component will only be able to access `*args`/`**kwargs` you provide to the template tag if your applications share a common database. + ??? question "Can I use multiple components on one page?" @@ -98,7 +120,6 @@ The `component` template tag can be used to insert any number of ReactPy compone Additionally, in scenarios where you are trying to create a Single Page Application (SPA) within Django, you will only have one component within your `#!html ` tag. - ??? question "Can I use positional arguments instead of keyword arguments?" diff --git a/docs/src/features/utils.md b/docs/src/features/utils.md index 9cec1aa4..dfadb9f9 100644 --- a/docs/src/features/utils.md +++ b/docs/src/features/utils.md @@ -37,3 +37,25 @@ This postprocessor is designed to avoid Django's `SynchronousOnlyException` by r | Type | Description | | --- | --- | | `QuerySet | Model` | The `Model` or `QuerySet` with all fields fetched. | + +## Register Component + +The `register_component` function is used manually register a root component with ReactPy. + +You should always call `register_component` within a Django [`AppConfig.ready()` method](https://docs.djangoproject.com/en/4.2/ref/applications/#django.apps.AppConfig.ready) to retain compatibility with ASGI webserver workers. + +=== "apps.py" + + ```python + {% include "../../python/register-component.py" %} + ``` + +??? question "Do I need to register my components?" + + You typically will not need to use this function. + + For security reasons, ReactPy does not allow non-registered components to be root components. However, all components contained within Django templates are automatically considered root components. + + You only need to use this function if your host application does not contain any HTML templates that [reference](../features/template-tag.md#component) your components. + + A common scenario where this is needed is when you are modifying the [template tag `host = ...` argument](../features/template-tag.md#component) in order to configure a dedicated Django application as a rendering server for ReactPy. On this dedicated rendering server, you would need to manually register your components. diff --git a/docs/src/get-started/installation.md b/docs/src/get-started/installation.md index d9763992..bd368ec1 100644 --- a/docs/src/get-started/installation.md +++ b/docs/src/get-started/installation.md @@ -44,11 +44,7 @@ In your settings you will need to add `reactpy_django` to [`INSTALLED_APPS`](htt ??? note "Configure ReactPy settings (Optional)" - Below are a handful of values you can change within `settings.py` to modify the behavior of ReactPy. - - ```python linenums="0" - {% include "../../python/settings.py" %} - ``` + {% include "../features/settings.md" start="" end="" %} ## Step 3: Configure [`urls.py`](https://docs.djangoproject.com/en/dev/topics/http/urls/) @@ -62,7 +58,7 @@ Add ReactPy HTTP paths to your `urlpatterns`. ## Step 4: Configure [`asgi.py`](https://docs.djangoproject.com/en/dev/howto/deployment/asgi/) -Register ReactPy's Websocket using `REACTPY_WEBSOCKET_PATH`. +Register ReactPy's Websocket using `REACTPY_WEBSOCKET_ROUTE`. === "asgi.py" @@ -95,3 +91,11 @@ Run Django's database migrations to initialize ReactPy-Django's database table. ```bash linenums="0" python manage.py migrate ``` + +## Step 6: Check your configuration + +Run Django's check command to verify if ReactPy was set up correctly. + +```bash linenums="0" +python manage.py check +``` diff --git a/pyproject.toml b/pyproject.toml index 5cd9c3a7..0f9e87a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,15 +3,13 @@ requires = ["setuptools>=42", "wheel"] build-backend = "setuptools.build_meta" [tool.mypy] -exclude = [ - 'migrations/.*', -] +exclude = ['migrations/.*'] ignore_missing_imports = true warn_unused_configs = true warn_redundant_casts = true warn_unused_ignores = true check_untyped_defs = true -incremental = false +incremental = true [tool.ruff.isort] known-first-party = ["src", "tests"] diff --git a/src/js/src/index.js b/src/js/src/index.js index 64684d7a..2ee74e07 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -1,34 +1,59 @@ import { mountLayoutWithWebSocket } from "@reactpy/client"; // Set up a websocket at the base endpoint -const LOCATION = window.location; +let HTTP_PROTOCOL = window.location.protocol; let WS_PROTOCOL = ""; -if (LOCATION.protocol == "https:") { - WS_PROTOCOL = "wss://"; +if (HTTP_PROTOCOL == "https:") { + WS_PROTOCOL = "wss:"; } else { - WS_PROTOCOL = "ws://"; + WS_PROTOCOL = "ws:"; } -const WS_ENDPOINT_URL = WS_PROTOCOL + LOCATION.host + "/"; export function mountViewToElement( mountElement, - reactpyWebsocketUrl, - reactpyWebModulesUrl, - maxReconnectTimeout, - componentPath + reactpyHost, + reactpyUrlPrefix, + reactpyReconnectMax, + reactpyComponentPath, + reactpyResolvedWebModulesPath ) { - const WS_URL = WS_ENDPOINT_URL + reactpyWebsocketUrl + componentPath; - const WEB_MODULE_URL = LOCATION.origin + "/" + reactpyWebModulesUrl; + // Determine the Websocket route + let wsOrigin; + if (reactpyHost) { + wsOrigin = `${WS_PROTOCOL}//${reactpyHost}`; + } else { + wsOrigin = `${WS_PROTOCOL}//${window.location.host}`; + } + const websocketUrl = `${wsOrigin}/${reactpyUrlPrefix}/${reactpyComponentPath}`; + + // Determine the HTTP route + let httpOrigin; + let webModulesPath; + if (reactpyHost) { + httpOrigin = `${HTTP_PROTOCOL}//${reactpyHost}`; + webModulesPath = `${reactpyUrlPrefix}/web_module`; + } else { + httpOrigin = `${HTTP_PROTOCOL}//${window.location.host}`; + if (reactpyResolvedWebModulesPath) { + webModulesPath = reactpyResolvedWebModulesPath; + } else { + webModulesPath = `${reactpyUrlPrefix}/web_module`; + } + } + const webModuleUrl = `${httpOrigin}/${webModulesPath}`; + + // Function that loads the JavaScript web module, if needed const loadImportSource = (source, sourceType) => { return import( - sourceType == "NAME" ? `${WEB_MODULE_URL}${source}` : source + sourceType == "NAME" ? `${webModuleUrl}/${source}` : source ); }; + // Start rendering the component mountLayoutWithWebSocket( mountElement, - WS_URL, + websocketUrl, loadImportSource, - maxReconnectTimeout + reactpyReconnectMax ); } diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index fc3940e8..cfbbae80 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -3,11 +3,15 @@ import nest_asyncio from reactpy_django import checks, components, decorators, hooks, types, utils -from reactpy_django.websocket.paths import REACTPY_WEBSOCKET_PATH +from reactpy_django.websocket.paths import ( + REACTPY_WEBSOCKET_PATH, + REACTPY_WEBSOCKET_ROUTE, +) __version__ = "3.3.2" __all__ = [ "REACTPY_WEBSOCKET_PATH", + "REACTPY_WEBSOCKET_ROUTE", "hooks", "components", "decorators", diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index fc5a89a6..7ab9546e 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -1,8 +1,10 @@ +import contextlib import sys from django.contrib.staticfiles.finders import find from django.core.checks import Error, Tags, Warning, register from django.template import loader +from django.urls import NoReverseMatch @register(Tags.compatibility) @@ -10,6 +12,7 @@ def reactpy_warnings(app_configs, **kwargs): from django.conf import settings from django.urls import reverse + from reactpy_django import config from reactpy_django.config import REACTPY_FAILED_COMPONENTS warnings = [] @@ -40,22 +43,24 @@ def reactpy_warnings(app_configs, **kwargs): Warning( "ReactPy URLs have not been registered.", hint="""Add 'path("reactpy/", include("reactpy_django.http.urls"))' """ - "to your application's urlpatterns.", + "to your application's urlpatterns. If this application does not need " + "to render ReactPy components, you add this warning to SILENCED_SYSTEM_CHECKS.", id="reactpy_django.W002", ) ) - # Warn if REACTPY_BACKHAUL_THREAD is set to True on Linux with Daphne + # Warn if REACTPY_BACKHAUL_THREAD is set to True with Daphne if ( - sys.argv - and sys.argv[0].endswith("daphne") - and getattr(settings, "REACTPY_BACKHAUL_THREAD", False) - and sys.platform == "linux" - ): + sys.argv[0].endswith("daphne") + or ( + "runserver" in sys.argv + and "daphne" in getattr(settings, "INSTALLED_APPS", []) + ) + ) and getattr(settings, "REACTPY_BACKHAUL_THREAD", False): warnings.append( Warning( - "REACTPY_BACKHAUL_THREAD is enabled but you running with Daphne on Linux. " - "This configuration is known to be unstable.", + "Unstable configuration detected. REACTPY_BACKHAUL_THREAD is enabled " + "and you running with Daphne.", hint="Set settings.py:REACTPY_BACKHAUL_THREAD to False or use a different webserver.", id="reactpy_django.W003", ) @@ -96,28 +101,64 @@ def reactpy_warnings(app_configs, **kwargs): ) ) - # Check if REACTPY_WEBSOCKET_URL doesn't end with a slash - REACTPY_WEBSOCKET_URL = getattr(settings, "REACTPY_WEBSOCKET_URL", "reactpy/") - if isinstance(REACTPY_WEBSOCKET_URL, str): - if not REACTPY_WEBSOCKET_URL or not REACTPY_WEBSOCKET_URL.endswith("/"): - warnings.append( - Warning( - "REACTPY_WEBSOCKET_URL did not end with a forward slash.", - hint="Change your URL to be written in the following format: 'example_url/'", - id="reactpy_django.W007", - ) + # DELETED W007: Check if REACTPY_WEBSOCKET_URL doesn't end with a slash + # DELETED W008: Check if REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character + + # Removed Settings + if getattr(settings, "REACTPY_WEBSOCKET_URL", None): + warnings.append( + Warning( + "REACTPY_WEBSOCKET_URL has been removed.", + hint="Use REACTPY_URL_PREFIX instead.", + id="reactpy_django.W009", ) + ) - # Check if REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character - if not REACTPY_WEBSOCKET_URL or not REACTPY_WEBSOCKET_URL[0].isalnum(): + # Check if REACTPY_URL_PREFIX is being used properly in our HTTP URLs + with contextlib.suppress(NoReverseMatch): + full_path = reverse("reactpy:web_modules", kwargs={"file": "example"}).strip( + "/" + ) + reactpy_http_prefix = f'{full_path[: full_path.find("web_module/")].strip("/")}' + if reactpy_http_prefix != config.REACTPY_URL_PREFIX: warnings.append( Warning( - "REACTPY_WEBSOCKET_URL did not start with an alphanumeric character.", - hint="Change your URL to be written in the following format: 'example_url/'", - id="reactpy_django.W008", + "HTTP paths are not prefixed with REACTPY_URL_PREFIX. " + "Some ReactPy features may not work as expected.", + hint="Use one of the following solutions.\n" + "\t1) Utilize REACTPY_URL_PREFIX within your urls.py:\n" + f'\t path("{config.REACTPY_URL_PREFIX}/", include("reactpy_django.http.urls"))\n' + "\t2) Modify settings.py:REACTPY_URL_PREFIX to match your existing HTTP path:\n" + f'\t REACTPY_URL_PREFIX = "{reactpy_http_prefix}/"\n' + "\t3) If you not rendering components by this ASGI application, then remove " + "ReactPy HTTP and websocket routing. This is common for configurations that " + "rely entirely on `host` configuration in your template tag.", + id="reactpy_django.W010", ) ) + # Check if REACTPY_URL_PREFIX is empty + if not getattr(settings, "REACTPY_URL_PREFIX", "reactpy/"): + warnings.append( + Warning( + "REACTPY_URL_PREFIX should not be empty!", + hint="Change your REACTPY_URL_PREFIX to be written in the following format: '/example_url/'", + id="reactpy_django.W011", + ) + ) + + # Check if `daphne` is not in installed apps when using `runserver` + if "runserver" in sys.argv and "daphne" not in getattr( + settings, "INSTALLED_APPS", [] + ): + warnings.append( + Warning( + "You have not configured runserver to use ASGI.", + hint="Add daphne to settings.py:INSTALLED_APPS.", + id="reactpy_django.W012", + ) + ) + return warnings @@ -154,12 +195,12 @@ def reactpy_errors(app_configs, **kwargs): ) # All settings in reactpy_django.conf are the correct data type - if not isinstance(getattr(settings, "REACTPY_WEBSOCKET_URL", ""), str): + if not isinstance(getattr(settings, "REACTPY_URL_PREFIX", ""), str): errors.append( Error( - "Invalid type for REACTPY_WEBSOCKET_URL.", - hint="REACTPY_WEBSOCKET_URL should be a string.", - obj=settings.REACTPY_WEBSOCKET_URL, + "Invalid type for REACTPY_URL_PREFIX.", + hint="REACTPY_URL_PREFIX should be a string.", + obj=settings.REACTPY_URL_PREFIX, id="reactpy_django.E003", ) ) @@ -211,14 +252,30 @@ def reactpy_errors(app_configs, **kwargs): ) ) - # Check for dependencies - if "channels" not in settings.INSTALLED_APPS: + # DELETED E009: Check if `channels` is in INSTALLED_APPS + + if not isinstance(getattr(settings, "REACTPY_DEFAULT_HOSTS", []), list): errors.append( Error( - "Django Channels is not installed.", - hint="Add 'channels' to settings.py:INSTALLED_APPS.", - id="reactpy_django.E009", + "Invalid type for REACTPY_DEFAULT_HOSTS.", + hint="REACTPY_DEFAULT_HOSTS should be a list.", + obj=settings.REACTPY_DEFAULT_HOSTS, + id="reactpy_django.E010", ) ) + # Check of all values in the list are strings + if isinstance(getattr(settings, "REACTPY_DEFAULT_HOSTS", None), list): + for host in settings.REACTPY_DEFAULT_HOSTS: + if not isinstance(host, str): + errors.append( + Error( + f"Invalid type {type(host)} within REACTPY_DEFAULT_HOSTS.", + hint="REACTPY_DEFAULT_HOSTS should be a list of strings.", + obj=settings.REACTPY_DEFAULT_HOSTS, + id="reactpy_django.E011", + ) + ) + break + return errors diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index c0ee14be..24aff5f6 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -1,5 +1,7 @@ from __future__ import annotations +from itertools import cycle + from django.conf import settings from django.core.cache import DEFAULT_CACHE_ALIAS from django.db import DEFAULT_DB_ALIAS @@ -20,13 +22,20 @@ REACTPY_VIEW_COMPONENT_IFRAMES: dict[str, ViewComponentIframe] = {} -# Configurable through Django settings.py +# Remove in a future release REACTPY_WEBSOCKET_URL = getattr( settings, "REACTPY_WEBSOCKET_URL", "reactpy/", ) -REACTPY_RECONNECT_MAX = getattr( + +# Configurable through Django settings.py +REACTPY_URL_PREFIX: str = getattr( + settings, + "REACTPY_URL_PREFIX", + REACTPY_WEBSOCKET_URL, +).strip("/") +REACTPY_RECONNECT_MAX: int = getattr( settings, "REACTPY_RECONNECT_MAX", 259200, # Default to 3 days @@ -63,3 +72,13 @@ "REACTPY_BACKHAUL_THREAD", False, ) +_default_hosts: list[str] | None = getattr( + settings, + "REACTPY_DEFAULT_HOSTS", + None, +) +REACTPY_DEFAULT_HOSTS: cycle[str] | None = ( + cycle([host.strip("/") for host in _default_hosts if isinstance(host, str)]) + if _default_hosts + else None +) diff --git a/src/reactpy_django/exceptions.py b/src/reactpy_django/exceptions.py index 072f1d4f..5cdcb719 100644 --- a/src/reactpy_django/exceptions.py +++ b/src/reactpy_django/exceptions.py @@ -4,3 +4,7 @@ class ComponentParamError(TypeError): class ComponentDoesNotExistError(AttributeError): ... + + +class InvalidHostError(ValueError): + ... diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html index 7dae08eb..4010b80f 100644 --- a/src/reactpy_django/templates/reactpy/component.html +++ b/src/reactpy_django/templates/reactpy/component.html @@ -4,16 +4,17 @@ {% firstof reactpy_error "UnknownError" %}: "{% firstof reactpy_dotted_path "UnknownPath" %}" {% endif %} {% else %} -
+
{% endif %} diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index d8e94e2b..c174d1b1 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -1,31 +1,38 @@ +from __future__ import annotations + from logging import getLogger from uuid import uuid4 import dill as pickle from django import template -from django.urls import reverse - -from reactpy_django import models -from reactpy_django.config import ( - REACTPY_DEBUG_MODE, - REACTPY_RECONNECT_MAX, - REACTPY_WEBSOCKET_URL, +from django.http import HttpRequest +from django.urls import NoReverseMatch, reverse + +from reactpy_django import config, models +from reactpy_django.exceptions import ( + ComponentDoesNotExistError, + ComponentParamError, + InvalidHostError, ) -from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError from reactpy_django.types import ComponentParamData -from reactpy_django.utils import ( - _register_component, - check_component_args, - func_has_args, -) +from reactpy_django.utils import check_component_args, func_has_args -REACTPY_WEB_MODULES_URL = reverse("reactpy:web_modules", args=["x"])[:-1][1:] +try: + RESOLVED_WEB_MODULES_PATH = reverse("reactpy:web_modules", args=["/"]).strip("/") +except NoReverseMatch: + RESOLVED_WEB_MODULES_PATH = "" register = template.Library() _logger = getLogger(__name__) -@register.inclusion_tag("reactpy/component.html") -def component(dotted_path: str, *args, **kwargs): +@register.inclusion_tag("reactpy/component.html", takes_context=True) +def component( + context: template.RequestContext, + dotted_path: str, + *args, + host: str | None = None, + **kwargs, +): """This tag is used to embed an existing ReactPy component into your HTML template. Args: @@ -33,6 +40,9 @@ def component(dotted_path: str, *args, **kwargs): *args: The positional arguments to provide to the component. Keyword Args: + host: The host to use for the ReactPy connections. If set to `None`, \ + the host will be automatically configured. \ + Example values include: `localhost:8000`, `example.com`, `example.com/subdir` **kwargs: The keyword arguments to provide to the component. Example :: @@ -46,33 +56,50 @@ def component(dotted_path: str, *args, **kwargs): """ - # Register the component if needed - try: - component = _register_component(dotted_path) - uuid = uuid4().hex - class_ = kwargs.pop("class", "") - kwargs.pop("key", "") # `key` is effectively useless for the root node - - except Exception as e: - if isinstance(e, ComponentDoesNotExistError): - _logger.error(str(e)) - else: - _logger.exception( - "An unknown error has occurred while registering component '%s'.", - dotted_path, - ) - return failure_context(dotted_path, e) - - # Store the component's args/kwargs in the database if needed - # This will be fetched by the websocket consumer later + # Determine the host + request: HttpRequest | None = context.get("request") + perceived_host = (request.get_host() if request else "").strip("/") + host = ( + host + or (next(config.REACTPY_DEFAULT_HOSTS) if config.REACTPY_DEFAULT_HOSTS else "") + ).strip("/") + + # Check if this this component needs to rendered by the current ASGI app + use_current_app = not host or host.startswith(perceived_host) + + # Create context variables + uuid = uuid4().hex + class_ = kwargs.pop("class", "") + kwargs.pop("key", "") # `key` is effectively useless for the root node + + # Fail if user has a method in their host + if host.find("://") != -1: + protocol = host.split("://")[0] + msg = ( + f"Invalid host provided to component. Contains a protocol '{protocol}://'." + ) + _logger.error(msg) + return failure_context(dotted_path, InvalidHostError(msg)) + + # Fetch the component if needed + if use_current_app: + user_component = config.REACTPY_REGISTERED_COMPONENTS.get(dotted_path) + if not user_component: + msg = f"Component '{dotted_path}' is not registered as a root component. " + _logger.error(msg) + return failure_context(dotted_path, ComponentDoesNotExistError(msg)) + + # Store the component's args/kwargs in the database, if needed + # These will be fetched by the websocket consumer later try: - check_component_args(component, *args, **kwargs) - if func_has_args(component): - params = ComponentParamData(args, kwargs) - model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) - model.full_clean() - model.save() - + if use_current_app: + check_component_args(user_component, *args, **kwargs) + if func_has_args(user_component): + save_component_params(args, kwargs, uuid) + # Can't guarantee args will match up if the component is rendered by a different app. + # So, we just store any provided args/kwargs in the database. + elif args or kwargs: + save_component_params(args, kwargs, uuid) except Exception as e: if isinstance(e, ComponentParamError): _logger.error(str(e)) @@ -85,19 +112,27 @@ def component(dotted_path: str, *args, **kwargs): # Return the template rendering context return { - "class": class_, - "reactpy_websocket_url": REACTPY_WEBSOCKET_URL, - "reactpy_web_modules_url": REACTPY_WEB_MODULES_URL, - "reactpy_reconnect_max": REACTPY_RECONNECT_MAX, - "reactpy_mount_uuid": uuid, + "reactpy_class": class_, + "reactpy_uuid": uuid, + "reactpy_host": host or perceived_host, + "reactpy_url_prefix": config.REACTPY_URL_PREFIX, + "reactpy_reconnect_max": config.REACTPY_RECONNECT_MAX, "reactpy_component_path": f"{dotted_path}/{uuid}/", + "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH, } def failure_context(dotted_path: str, error: Exception): return { "reactpy_failure": True, - "reactpy_debug_mode": REACTPY_DEBUG_MODE, + "reactpy_debug_mode": config.REACTPY_DEBUG_MODE, "reactpy_dotted_path": dotted_path, "reactpy_error": type(error).__name__, } + + +def save_component_params(args, kwargs, uuid): + params = ComponentParamData(args, kwargs) + model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) + model.full_clean() + model.save() diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index f4e0f8e6..22844610 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -20,6 +20,7 @@ from django.utils import timezone from django.utils.encoding import smart_str from django.views import View +from reactpy.types import ComponentConstructor from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError @@ -82,10 +83,8 @@ async def render_view( return response -def _register_component(dotted_path: str) -> Callable: - """Adds a component to the mapping of registered components. - This should only be called on startup to maintain synchronization during mulitprocessing. - """ +def register_component(dotted_path: str) -> ComponentConstructor: + """Adds a component to the list of known registered components.""" from reactpy_django.config import ( REACTPY_FAILED_COMPONENTS, REACTPY_REGISTERED_COMPONENTS, @@ -101,7 +100,6 @@ def _register_component(dotted_path: str) -> Callable: raise ComponentDoesNotExistError( f"Error while fetching '{dotted_path}'. {(str(e).capitalize())}." ) from e - _logger.debug("ReactPy has registered component %s", dotted_path) return REACTPY_REGISTERED_COMPONENTS[dotted_path] @@ -204,16 +202,19 @@ def get_components(self, templates: set[str]) -> set[str]: def register_components(self, components: set[str]) -> None: """Registers all ReactPy components in an iterable.""" + if components: + _logger.debug("Auto-detected ReactPy root components:") for component in components: try: - _logger.info("ReactPy preloader has detected component %s", component) - _register_component(component) + _logger.debug("\t+ %s", component) + register_component(component) except Exception: _logger.exception( "\033[91m" - "ReactPy failed to register component '%s'! " + "ReactPy failed to register component '%s'!\n" "This component path may not be valid, " - "or an exception may have occurred while importing." + "or an exception may have occurred while importing.\n" + "See the traceback below for more information." "\033[0m", component, ) @@ -296,15 +297,12 @@ def django_query_postprocessor( return data -def func_has_args(func: Callable) -> bool: - """Checks if a function has any args or kwarg.""" - signature = inspect.signature(func) - - # Check if the function has any args/kwargs - return str(signature) != "()" +def func_has_args(func) -> bool: + """Checks if a function has any args or kwargs.""" + return bool(inspect.signature(func).parameters) -def check_component_args(func: Callable, *args, **kwargs): +def check_component_args(func, *args, **kwargs): """ Validate whether a set of args/kwargs would work on the given function. diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 579f351b..aa0c2006 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -152,27 +152,18 @@ async def run_dispatcher(self): # Fetch the component's args/kwargs from the database, if needed try: if func_has_args(component_constructor): - try: - # Always clean up expired entries first - await database_sync_to_async(db_cleanup, thread_sensitive=False)() - - # Get the queries from a DB - params_query = await models.ComponentSession.objects.aget( - uuid=uuid, - last_accessed__gt=now - - timedelta(seconds=REACTPY_RECONNECT_MAX), - ) - params_query.last_accessed = timezone.now() - await database_sync_to_async( - params_query.save, thread_sensitive=False - )() - except models.ComponentSession.DoesNotExist: - await asyncio.to_thread( - _logger.warning, - f"Component session for '{dotted_path}:{uuid}' not found. The " - "session may have already expired beyond REACTPY_RECONNECT_MAX.", - ) - return + # Always clean up expired entries first + await database_sync_to_async(db_cleanup, thread_sensitive=False)() + + # Get the queries from a DB + params_query = await models.ComponentSession.objects.aget( + uuid=uuid, + last_accessed__gt=now - timedelta(seconds=REACTPY_RECONNECT_MAX), + ) + params_query.last_accessed = timezone.now() + await database_sync_to_async( + params_query.save, thread_sensitive=False + )() component_params: ComponentParamData = pickle.loads(params_query.params) component_args = component_params.args component_kwargs = component_params.kwargs @@ -181,6 +172,15 @@ async def run_dispatcher(self): component_instance = component_constructor( *component_args, **component_kwargs ) + except models.ComponentSession.DoesNotExist: + await asyncio.to_thread( + _logger.warning, + f"Component session for '{dotted_path}:{uuid}' not found. The " + "session may have already expired beyond REACTPY_RECONNECT_MAX. " + "If you are using a custom host, you may have forgotten to provide " + "args/kwargs.", + ) + return except Exception: await asyncio.to_thread( _logger.exception, diff --git a/src/reactpy_django/websocket/paths.py b/src/reactpy_django/websocket/paths.py index afd410c3..039ee5ba 100644 --- a/src/reactpy_django/websocket/paths.py +++ b/src/reactpy_django/websocket/paths.py @@ -1,15 +1,17 @@ from django.urls import path -from reactpy_django.config import REACTPY_WEBSOCKET_URL +from reactpy_django.config import REACTPY_URL_PREFIX from .consumer import ReactpyAsyncWebsocketConsumer -REACTPY_WEBSOCKET_PATH = path( - f"{REACTPY_WEBSOCKET_URL}//", +REACTPY_WEBSOCKET_ROUTE = path( + f"{REACTPY_URL_PREFIX}///", ReactpyAsyncWebsocketConsumer.as_asgi(), ) - """A URL path for :class:`ReactpyAsyncWebsocketConsumer`. -Required in order for ReactPy to know the websocket path. +Required since the `reverse()` function does not exist for Django Channels, but we need +to know the websocket path. """ + +REACTPY_WEBSOCKET_PATH = REACTPY_WEBSOCKET_ROUTE diff --git a/tests/test_app/asgi.py b/tests/test_app/asgi.py index b49d12ef..e372e42f 100644 --- a/tests/test_app/asgi.py +++ b/tests/test_app/asgi.py @@ -11,7 +11,6 @@ from django.core.asgi import get_asgi_application - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings") # Fetch ASGI application before importing dependencies that require ORM models. @@ -20,15 +19,13 @@ from channels.auth import AuthMiddlewareStack # noqa: E402 from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 from channels.sessions import SessionMiddlewareStack # noqa: E402 - -from reactpy_django import REACTPY_WEBSOCKET_PATH # noqa: E402 - +from reactpy_django import REACTPY_WEBSOCKET_ROUTE # noqa: E402 application = ProtocolTypeRouter( { "http": http_asgi_app, "websocket": SessionMiddlewareStack( - AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_PATH])) + AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_ROUTE])) ), } ) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 433bd9e4..d018cd96 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -2,10 +2,13 @@ import inspect from pathlib import Path +import reactpy_django from channels.db import database_sync_to_async from django.http import HttpRequest from django.shortcuts import render from reactpy import component, hooks, html, web +from reactpy_django.components import view_to_component + from test_app.models import ( AsyncForiegnChild, AsyncRelationalChild, @@ -17,9 +20,6 @@ TodoItem, ) -import reactpy_django -from reactpy_django.components import view_to_component - from . import views from .types import TestObject @@ -588,3 +588,17 @@ def view_to_component_decorator_args(request): "view_to_component.html", {"test_name": inspect.currentframe().f_code.co_name}, # type: ignore ) + + +@component +def custom_host(number=0): + scope = reactpy_django.hooks.use_scope() + port = scope["server"][1] + + return html.div( + { + "class_name": f"{inspect.currentframe().f_code.co_name}-{number}", # type: ignore + "data-port": port, + }, + f"Server Port: {port}", + ) diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index 10cd8f6b..a99da927 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -40,7 +40,6 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "channels", # Websocket library "reactpy_django", # Django compatiblity layer for ReactPy "test_app", # This test application ] @@ -161,6 +160,10 @@ ] # Logging +LOG_LEVEL = "WARNING" +if DEBUG and ("test" not in sys.argv): + LOG_LEVEL = "DEBUG" + LOGGING = { "version": 1, "disable_existing_loggers": False, @@ -172,7 +175,7 @@ "loggers": { "reactpy_django": { "handlers": ["console"], - "level": "DEBUG" if DEBUG else "WARNING", + "level": LOG_LEVEL, }, }, } @@ -180,4 +183,4 @@ # ReactPy Django Settings REACTPY_AUTH_BACKEND = "django.contrib.auth.backends.ModelBackend" -REACTPY_BACKHAUL_THREAD = "test" not in sys.argv +REACTPY_BACKHAUL_THREAD = "test" not in sys.argv and "runserver" not in sys.argv diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index 10eaac27..303e99dd 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -89,6 +89,8 @@

ReactPy Test Page


{% component "test_app.components.hello_world" invalid_param="random_value" %}

+
{% component "test_app.components.hello_world" host="https://example.com/" %}
+
diff --git a/tests/test_app/templates/host_port.html b/tests/test_app/templates/host_port.html new file mode 100644 index 00000000..1eb2be2a --- /dev/null +++ b/tests/test_app/templates/host_port.html @@ -0,0 +1,21 @@ +{% load static %} {% load reactpy %} + + + + + + + + + ReactPy + + + +

ReactPy Test Page

+
+ Custom Host ({{new_host}}): + {% component "test_app.components.custom_host" host=new_host number=0 %} +
+ + + diff --git a/tests/test_app/templates/host_port_roundrobin.html b/tests/test_app/templates/host_port_roundrobin.html new file mode 100644 index 00000000..ad2dada0 --- /dev/null +++ b/tests/test_app/templates/host_port_roundrobin.html @@ -0,0 +1,23 @@ +{% load static %} {% load reactpy %} + + + + + + + + + ReactPy + + + +

ReactPy Test Page

+
+ {% for count in count %} + Round-Robin Host: + {% component "test_app.components.custom_host" number=count %} +
+ {% endfor %} + + + diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 0801e28e..58e6c053 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,5 +1,6 @@ import asyncio import os +import socket import sys from functools import partial @@ -10,11 +11,10 @@ from django.db import connections from django.test.utils import modify_settings from playwright.sync_api import TimeoutError, sync_playwright - from reactpy_django.models import ComponentSession - -CLICK_DELAY = 250 if os.getenv("GITHUB_ACTIONS") else 25 # Delay in miliseconds. +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") +CLICK_DELAY = 250 if GITHUB_ACTIONS else 25 # Delay in miliseconds. class ComponentTests(ChannelsLiveServerTestCase): @@ -41,11 +41,17 @@ def setUpClass(cls): cls._server_process.ready.wait() cls._port = cls._server_process.port.value + # Open the second server process + cls._server_process2 = cls.ProtocolServerProcess(cls.host, get_application) + cls._server_process2.start() + cls._server_process2.ready.wait() + cls._port2 = cls._server_process2.port.value + # Open a Playwright browser window if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) cls.playwright = sync_playwright().start() - headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", 0))) + headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", not GITHUB_ACTIONS))) cls.browser = cls.playwright.chromium.launch(headless=not headed) cls.page = cls.browser.new_page() @@ -54,6 +60,10 @@ def tearDownClass(cls): # Close the Playwright browser cls.playwright.stop() + # Close the second server process + cls._server_process2.terminate() + cls._server_process2.join() + # Repurposed from ChannelsLiveServerTestCase._post_teardown cls._server_process.terminate() cls._server_process.join() @@ -293,3 +303,69 @@ def test_component_session_missing(self): query_exists = query.exists() os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") self.assertFalse(query_exists) + + def test_custom_host(self): + """Make sure that the component is rendered by a separate server.""" + new_page = self.browser.new_page() + try: + new_page.goto(f"{self.live_server_url}/port/{self._port2}/") + elem = new_page.locator(".custom_host-0") + elem.wait_for() + self.assertIn( + f"Server Port: {self._port2}", + elem.text_content(), + ) + finally: + new_page.close() + + def test_custom_host_wrong_port(self): + """Make sure that other ports are not rendering components.""" + new_page = self.browser.new_page() + try: + tmp_sock = socket.socket() + tmp_sock.bind((self._server_process.host, 0)) + random_port = tmp_sock.getsockname()[1] + new_page.goto(f"{self.live_server_url}/port/{random_port}/") + with self.assertRaises(TimeoutError): + new_page.locator(".custom_host").wait_for(timeout=1000) + finally: + new_page.close() + + def test_host_roundrobin(self): + """Verify if round-robin host selection is working.""" + new_page = self.browser.new_page() + try: + new_page.goto( + f"{self.live_server_url}/roundrobin/{self._port}/{self._port2}/8" + ) + elem0 = new_page.locator(".custom_host-0") + elem1 = new_page.locator(".custom_host-1") + elem2 = new_page.locator(".custom_host-2") + elem3 = new_page.locator(".custom_host-3") + + elem0.wait_for() + elem1.wait_for() + elem2.wait_for() + elem3.wait_for() + + current_ports = { + elem0.get_attribute("data-port"), + elem1.get_attribute("data-port"), + elem2.get_attribute("data-port"), + elem3.get_attribute("data-port"), + } + correct_ports = { + str(self._port), + str(self._port2), + } + + # There should only be two ports in the set + self.assertEqual(current_ports, correct_ports) + self.assertEqual(len(current_ports), 2) + finally: + new_page.close() + + def test_invalid_host_error(self): + broken_component = self.page.locator("#invalid_host_error") + broken_component.wait_for() + self.assertIn("InvalidHostError:", broken_component.text_content()) diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index f3621b3e..ea185971 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -20,7 +20,7 @@ from django.contrib import admin from django.urls import include, path -from .views import base_template +from .views import base_template, host_port_roundrobin_template, host_port_template class AccessUser: @@ -31,6 +31,10 @@ class AccessUser: urlpatterns = [ path("", base_template), + path("port//", host_port_template), + path( + "roundrobin////", host_port_roundrobin_template + ), path("", include("test_app.performance.urls")), path("reactpy/", include("reactpy_django.http.urls")), path("admin/", admin.site.urls), diff --git a/tests/test_app/views.py b/tests/test_app/views.py index 0d013b1a..689d8f8c 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -1,6 +1,8 @@ import inspect +from itertools import cycle from channels.db import database_sync_to_async +from django.http import HttpRequest from django.shortcuts import render from django.views.generic import TemplateView, View @@ -11,6 +13,35 @@ def base_template(request): return render(request, "base.html", {"my_object": TestObject(1)}) +def host_port_template(request: HttpRequest, port: int): + host = request.get_host().replace(str(request.get_port()), str(port)) + return render(request, "host_port.html", {"new_host": host}) + + +def host_port_roundrobin_template( + request: HttpRequest, port1: int, port2: int, count: int = 1 +): + from reactpy_django import config + + # Override ReactPy config to use round-robin hosts + original = config.REACTPY_DEFAULT_HOSTS + config.REACTPY_DEFAULT_HOSTS = cycle( + [ + f"{request.get_host().split(':')[0]}:{port1}", + f"{request.get_host().split(':')[0]}:{port2}", + ] + ) + html = render( + request, + "host_port_roundrobin.html", + {"count": range(max(count, 1))}, + ) + + # Reset ReactPy config + config.REACTPY_DEFAULT_HOSTS = original + return html + + def view_to_component_sync_func(request): return render( request, From daf1232e63bb2c514fb600895a261cb380d4dfba Mon Sep 17 00:00:00 2001 From: Mark Bakhit <16909269+Archmonger@users.noreply.github.com> Date: Fri, 18 Aug 2023 03:06:26 -0700 Subject: [PATCH 02/68] v3.4.0 (#173) --- CHANGELOG.md | 9 +++++++-- src/reactpy_django/__init__.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c5b054..cd5eab3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,12 +34,16 @@ Using the following categories, list your changes in this order: ## [Unreleased] +- Nothing (yet)! + +## [3.4.0] - 2023-08-18 + ### Added - **Distributed Computing:** ReactPy components can now optionally be rendered by a completely separate server! - `REACTPY_DEFAULT_HOSTS` setting can round-robin a list of ReactPy rendering hosts. - `host` argument has been added to the `component` template tag to force components to render on a specific host. -- `reactpy_django.utils.register_component` function to manually register root components. +- `reactpy_django.utils.register_component` function can manually register root components. - Useful if you have dedicated ReactPy rendering application(s) that do not use HTML templates. ### Changed @@ -358,7 +362,8 @@ Using the following categories, list your changes in this order: - Support for IDOM within the Django -[unreleased]: https://github.com/reactive-python/reactpy-django/compare/3.3.2...HEAD +[unreleased]: https://github.com/reactive-python/reactpy-django/compare/3.4.0...HEAD +[3.4.0]: https://github.com/reactive-python/reactpy-django/compare/3.3.2...3.4.0 [3.3.2]: https://github.com/reactive-python/reactpy-django/compare/3.3.1...3.3.2 [3.3.1]: https://github.com/reactive-python/reactpy-django/compare/3.3.0...3.3.1 [3.3.0]: https://github.com/reactive-python/reactpy-django/compare/3.2.1...3.3.0 diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index cfbbae80..7458595f 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -8,7 +8,7 @@ REACTPY_WEBSOCKET_ROUTE, ) -__version__ = "3.3.2" +__version__ = "3.4.0" __all__ = [ "REACTPY_WEBSOCKET_PATH", "REACTPY_WEBSOCKET_ROUTE", From aae9c7a67a7586bca657f400716c56a366487097 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 20 Aug 2023 05:22:43 -0700 Subject: [PATCH 03/68] Bump ReactPy, refactor template tag, and pretty WS URLs. (#174) - Bumped the minimum ReactPy version to `1.0.2`. - Prettier websocket URLs for components that do not have sessions. - Template tag will now only validate `args`/`kwargs` if `settings.py:DEBUG` is enabled. --- CHANGELOG.md | 6 +- requirements/pkg-deps.txt | 2 +- src/reactpy_django/templatetags/reactpy.py | 79 ++++++++++++---------- src/reactpy_django/utils.py | 7 +- src/reactpy_django/websocket/consumer.py | 6 +- src/reactpy_django/websocket/paths.py | 10 ++- 6 files changed, 60 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd5eab3f..fe62c373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,11 @@ Using the following categories, list your changes in this order: ## [Unreleased] -- Nothing (yet)! +### Changed + +- Bumped the minimum ReactPy version to `1.0.2`. +- Prettier websocket URLs for components that do not have sessions. +- Template tag will now only validate `args`/`kwargs` if `settings.py:DEBUG` is enabled. ## [3.4.0] - 2023-08-18 diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index c5958398..ee7156c3 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,6 +1,6 @@ channels >=4.0.0 django >=4.1.0 -reactpy >=1.0.0, <1.1.0 +reactpy >=1.0.2, <1.1.0 aiofile >=3.0 dill >=0.3.5 orjson >=3.6.0 diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index c174d1b1..1ae88413 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -7,6 +7,7 @@ from django import template from django.http import HttpRequest from django.urls import NoReverseMatch, reverse +from reactpy.core.types import ComponentConstructor from reactpy_django import config, models from reactpy_django.exceptions import ( @@ -15,7 +16,7 @@ InvalidHostError, ) from reactpy_django.types import ComponentParamData -from reactpy_django.utils import check_component_args, func_has_args +from reactpy_django.utils import validate_component_args try: RESOLVED_WEB_MODULES_PATH = reverse("reactpy:web_modules", args=["/"]).strip("/") @@ -55,60 +56,52 @@ def component( """ - - # Determine the host request: HttpRequest | None = context.get("request") perceived_host = (request.get_host() if request else "").strip("/") host = ( host or (next(config.REACTPY_DEFAULT_HOSTS) if config.REACTPY_DEFAULT_HOSTS else "") ).strip("/") - - # Check if this this component needs to rendered by the current ASGI app - use_current_app = not host or host.startswith(perceived_host) - - # Create context variables + is_local = not host or host.startswith(perceived_host) uuid = uuid4().hex class_ = kwargs.pop("class", "") - kwargs.pop("key", "") # `key` is effectively useless for the root node - - # Fail if user has a method in their host - if host.find("://") != -1: - protocol = host.split("://")[0] - msg = ( - f"Invalid host provided to component. Contains a protocol '{protocol}://'." - ) - _logger.error(msg) - return failure_context(dotted_path, InvalidHostError(msg)) - - # Fetch the component if needed - if use_current_app: + kwargs.pop("key", "") # `key` is useless for the root node + component_has_args = args or kwargs + user_component: ComponentConstructor | None = None + + # Validate the host + if host and config.REACTPY_DEBUG_MODE: + try: + validate_host(host) + except InvalidHostError as e: + return failure_context(dotted_path, e) + + # Fetch the component + if is_local: user_component = config.REACTPY_REGISTERED_COMPONENTS.get(dotted_path) if not user_component: msg = f"Component '{dotted_path}' is not registered as a root component. " _logger.error(msg) return failure_context(dotted_path, ComponentDoesNotExistError(msg)) - # Store the component's args/kwargs in the database, if needed - # These will be fetched by the websocket consumer later - try: - if use_current_app: - check_component_args(user_component, *args, **kwargs) - if func_has_args(user_component): - save_component_params(args, kwargs, uuid) - # Can't guarantee args will match up if the component is rendered by a different app. - # So, we just store any provided args/kwargs in the database. - elif args or kwargs: - save_component_params(args, kwargs, uuid) - except Exception as e: - if isinstance(e, ComponentParamError): + # Validate the component + if is_local and config.REACTPY_DEBUG_MODE: + try: + validate_component_args(user_component, *args, **kwargs) + except ComponentParamError as e: _logger.error(str(e)) - else: + return failure_context(dotted_path, e) + + # Store args & kwargs in the database (fetched by our websocket later) + if component_has_args: + try: + save_component_params(args, kwargs, uuid) + except Exception as e: _logger.exception( "An unknown error has occurred while saving component params for '%s'.", dotted_path, ) - return failure_context(dotted_path, e) + return failure_context(dotted_path, e) # Return the template rendering context return { @@ -117,7 +110,9 @@ def component( "reactpy_host": host or perceived_host, "reactpy_url_prefix": config.REACTPY_URL_PREFIX, "reactpy_reconnect_max": config.REACTPY_RECONNECT_MAX, - "reactpy_component_path": f"{dotted_path}/{uuid}/", + "reactpy_component_path": f"{dotted_path}/{uuid}/" + if component_has_args + else f"{dotted_path}/", "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH, } @@ -136,3 +131,13 @@ def save_component_params(args, kwargs, uuid): model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) model.full_clean() model.save() + + +def validate_host(host: str): + if "://" in host: + protocol = host.split("://")[0] + msg = ( + f"Invalid host provided to component. Contains a protocol '{protocol}://'." + ) + _logger.error(msg) + raise InvalidHostError(msg) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 22844610..0776bc3b 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -297,12 +297,7 @@ def django_query_postprocessor( return data -def func_has_args(func) -> bool: - """Checks if a function has any args or kwargs.""" - return bool(inspect.signature(func).parameters) - - -def check_component_args(func, *args, **kwargs): +def validate_component_args(func, *args, **kwargs): """ Validate whether a set of args/kwargs would work on the given function. diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index aa0c2006..a2a76cab 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -22,7 +22,7 @@ from reactpy.core.serve import serve_layout from reactpy_django.types import ComponentParamData, ComponentWebsocket -from reactpy_django.utils import db_cleanup, func_has_args +from reactpy_django.utils import db_cleanup _logger = logging.getLogger(__name__) backhaul_loop = asyncio.new_event_loop() @@ -124,7 +124,7 @@ async def run_dispatcher(self): scope = self.scope dotted_path = scope["url_route"]["kwargs"]["dotted_path"] - uuid = scope["url_route"]["kwargs"]["uuid"] + uuid = scope["url_route"]["kwargs"].get("uuid") search = scope["query_string"].decode() self.recv_queue: asyncio.Queue = asyncio.Queue() connection = Connection( # For `use_connection` @@ -151,7 +151,7 @@ async def run_dispatcher(self): # Fetch the component's args/kwargs from the database, if needed try: - if func_has_args(component_constructor): + if uuid: # Always clean up expired entries first await database_sync_to_async(db_cleanup, thread_sensitive=False)() diff --git a/src/reactpy_django/websocket/paths.py b/src/reactpy_django/websocket/paths.py index 039ee5ba..fa185565 100644 --- a/src/reactpy_django/websocket/paths.py +++ b/src/reactpy_django/websocket/paths.py @@ -1,3 +1,4 @@ +from channels.routing import URLRouter # noqa: E402 from django.urls import path from reactpy_django.config import REACTPY_URL_PREFIX @@ -5,8 +6,13 @@ from .consumer import ReactpyAsyncWebsocketConsumer REACTPY_WEBSOCKET_ROUTE = path( - f"{REACTPY_URL_PREFIX}///", - ReactpyAsyncWebsocketConsumer.as_asgi(), + f"{REACTPY_URL_PREFIX}//", + URLRouter( + [ + path("/", ReactpyAsyncWebsocketConsumer.as_asgi()), + path("", ReactpyAsyncWebsocketConsumer.as_asgi()), + ] + ), ) """A URL path for :class:`ReactpyAsyncWebsocketConsumer`. From 085cebe264dc28cf08573803330cb228501a25a2 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 26 Aug 2023 13:54:04 -0700 Subject: [PATCH 04/68] Docs version control and new theme (#176) - Version controlled docs - Every time something is committed to `main`, it will update the `develop` docs. - Every time a version is released, it will push a new version number to the docs. - Use our new ReactPy docs theme, which is an even better imitation of ReactJS docs - Fix `GITHUB_ACTIONS` env type conversion --- .github/workflows/publish-develop-docs.yml | 18 + .github/workflows/publish-docs.yml | 17 - .github/workflows/publish-release-docs.yml | 19 ++ CHANGELOG.md | 4 + README.md | 9 +- docs/overrides/main.html | 7 + docs/src/changelog/index.md | 8 +- docs/src/contribute/code.md | 12 +- docs/src/contribute/docs.md | 8 +- docs/src/contribute/running-tests.md | 25 +- docs/src/features/components.md | 14 +- docs/src/features/decorators.md | 10 +- docs/src/features/hooks.md | 14 +- docs/src/features/settings.md | 24 +- docs/src/features/template-tag.md | 30 +- docs/src/features/utils.md | 8 +- docs/src/get-started/choose-django-app.md | 19 +- docs/src/get-started/create-component.md | 29 +- docs/src/get-started/installation.md | 34 +- docs/src/get-started/learn-more.md | 16 +- docs/src/get-started/register-view.md | 16 +- docs/src/get-started/run-webserver.md | 18 +- docs/src/get-started/use-template-tag.md | 14 +- docs/src/static/css/extra.css | 376 +++++++++++++++++++++ docs/src/static/js/extra.js | 19 ++ docs/src/stylesheets/extra.css | 246 -------------- mkdocs.yml | 16 +- noxfile.py | 6 +- requirements/build-docs.txt | 2 + src/reactpy_django/components.py | 4 +- src/reactpy_django/templatetags/reactpy.py | 1 - tests/test_app/tests/test_components.py | 9 +- 32 files changed, 678 insertions(+), 374 deletions(-) create mode 100644 .github/workflows/publish-develop-docs.yml delete mode 100644 .github/workflows/publish-docs.yml create mode 100644 .github/workflows/publish-release-docs.yml create mode 100644 docs/src/static/css/extra.css create mode 100644 docs/src/static/js/extra.js delete mode 100644 docs/src/stylesheets/extra.css diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml new file mode 100644 index 00000000..0269e82c --- /dev/null +++ b/.github/workflows/publish-develop-docs.yml @@ -0,0 +1,18 @@ +name: Publish Develop Docs +on: + push: + branches: + - main +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-python@v4 + with: + python-version: 3.x + - run: pip install -r requirements/build-docs.txt + - name: Publish Develop Docs + run: mike deploy --push develop diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml deleted file mode 100644 index d6339c6b..00000000 --- a/.github/workflows/publish-docs.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Publish Docs -on: - push: - branches: - - main -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - uses: actions/setup-python@v4 - with: - python-version: 3.x - - run: pip install -r requirements/build-docs.txt - - run: mkdocs gh-deploy --force diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml new file mode 100644 index 00000000..15ffb8c7 --- /dev/null +++ b/.github/workflows/publish-release-docs.yml @@ -0,0 +1,19 @@ +name: Publish Release Docs + +on: + release: + types: [published] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-python@v4 + with: + python-version: 3.x + - run: pip install -r requirements/build-docs.txt + - name: Publish ${{ github.event.release.name }} Docs + run: mike deploy --push --update-aliases ${{ github.event.release.name }} latest diff --git a/CHANGELOG.md b/CHANGELOG.md index fe62c373..013688c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ Using the following categories, list your changes in this order: ## [Unreleased] +### Added + +- [ReactPy-Django docs](https://reactive-python.github.io/reactpy-django/) are now version controlled via [mike](https://github.com/jimporter/mike)! + ### Changed - Bumped the minimum ReactPy version to `1.0.2`. diff --git a/README.md b/README.md index f2d0f592..281290e3 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ +[ReactPy-Django](https://github.com/reactive-python/reactpy-django) is used to add used to add [ReactPy](https://reactpy.dev/) support to an existing **Django project**. + [ReactPy](https://reactpy.dev/) is a library for building user interfaces in Python without Javascript. ReactPy interfaces are made from components that look and behave similar to those found in [ReactJS](https://reactjs.org/). Designed with simplicity in mind, ReactPy can be used by those without web development experience while also being powerful enough to grow with your ambitions. @@ -77,11 +79,12 @@ def hello_world(recipient: str): -In your **Django app**'s HTML template, you can now embed your ReactPy component using the `component` template tag. Within this tag, you will need to type in your dotted path to the component function as the first argument. - -Additionally, you can pass in `args` and `kwargs` into your component function. For example, after reading the code below, pay attention to how the function definition for `hello_world` (_in the previous example_) accepts a `recipient` argument. +In your **Django app**'s HTML template, you can now embed your ReactPy component using the `component` template tag. Within this tag, you will need to type in the dotted path to the component. + +Additionally, you can pass in `args` and `kwargs` into your component function. After reading the code below, pay attention to how the function definition for `hello_world` (_from the previous example_) accepts a `recipient` argument. + ```jinja diff --git a/docs/overrides/main.html b/docs/overrides/main.html index e70aa10c..0b173292 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -11,3 +11,10 @@ {% endif %} {% endblock %} + +{% block outdated %} +You're not viewing the latest version. + + Click here to go to latest. + +{% endblock %} diff --git a/docs/src/changelog/index.md b/docs/src/changelog/index.md index 9c2bef97..1ecf88e2 100644 --- a/docs/src/changelog/index.md +++ b/docs/src/changelog/index.md @@ -3,8 +3,12 @@ hide: - toc --- -!!! summary "Attribution" +

- {% include-markdown "../../../CHANGELOG.md" start="" end="" %} +{% include-markdown "../../../CHANGELOG.md" start="" end="" %} + +

+ +--- {% include-markdown "../../../CHANGELOG.md" start="" %} diff --git a/docs/src/contribute/code.md b/docs/src/contribute/code.md index e53f4ecc..cc5b8e58 100644 --- a/docs/src/contribute/code.md +++ b/docs/src/contribute/code.md @@ -1,12 +1,18 @@ ## Overview -!!! summary "Overview" +

You will need to set up a Python environment to develop ReactPy-Django. -??? tip "Looking to contribute features that are not Django specific?" +

- Everything within the `reactpy-django` repository must be specific to Django integration. Check out the [ReactPy Core documentation](https://reactpy.dev/docs/about/contributor-guide.html) to contribute general features such as: components, hooks, events, and more. +!!! note + + Looking to contribute features that are not Django specific? + + Everything within the `reactpy-django` repository must be specific to Django integration. Check out the [ReactPy Core documentation](https://reactpy.dev/docs/about/contributor-guide.html) to contribute general features such as components, hooks, and events. + +--- ## Modifying Code diff --git a/docs/src/contribute/docs.md b/docs/src/contribute/docs.md index 8312a26a..33b83fb7 100644 --- a/docs/src/contribute/docs.md +++ b/docs/src/contribute/docs.md @@ -1,8 +1,12 @@ ## Overview -!!! summary "Overview" +

- You will need to set up a Python environment to preview docs changes. +You will need to set up a Python environment to create, test, and preview docs changes. + +

+ +--- ## Modifying Docs diff --git a/docs/src/contribute/running-tests.md b/docs/src/contribute/running-tests.md index 79d3b114..fe01be26 100644 --- a/docs/src/contribute/running-tests.md +++ b/docs/src/contribute/running-tests.md @@ -1,8 +1,12 @@ ## Overview -!!! summary "Overview" +

- You will need to set up a Python environment to run out test suite. +You will need to set up a Python environment to run the ReactPy-Django test suite. + +

+ +--- ## Running Tests @@ -29,17 +33,26 @@ By running the command below you can run the full test suite: nox -s test ``` -Or, if you want to run the tests in the foreground: +Or, if you want to run the tests in the background: ```bash linenums="0" -nox -s test -- --headed +nox -s test -- --headless ``` -## Only Django Tests +## Django Tests -Alternatively, if you want to only run Django related tests, you can use the following command: +If you want to only run our Django tests in your current environment, you can use the following command: ```bash linenums="0" cd tests python manage.py test ``` + +## Django Test Webserver + +If you want to manually run the Django test application, you can use the following command: + +```bash linenums="0" +cd tests +python manage.py runserver +``` diff --git a/docs/src/features/components.md b/docs/src/features/components.md index 900b9fe2..f4dae25a 100644 --- a/docs/src/features/components.md +++ b/docs/src/features/components.md @@ -1,8 +1,12 @@ ## Overview -!!! summary "Overview" +

- Prefabricated components can be used within your `components.py` to help simplify development. +We supply some pre-designed that components can be used to help simplify development. + +

+ +--- ## View To Component @@ -29,7 +33,7 @@ Convert any Django view into a ReactPy component by using this decorator. Compat | Type | Description | | --- | --- | - | `_ViewComponentConstructor` | A function that takes `request, *args, key, **kwargs` and returns an ReactPy component. All parameters are directly provided to your view, besides `key` which is used by ReactPy. | + | `_ViewComponentConstructor` | A function that takes `request, *args, key, **kwargs` and returns a ReactPy component. All parameters are directly provided to your view, besides `key` which is used by ReactPy. | ??? Warning "Potential information exposure when using `compatibility = True`" @@ -180,7 +184,7 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering. | Type | Description | | --- | --- | - | `Component` | An ReactPy component. | + | `Component` | A ReactPy component. | ??? question "Should I put `django_css` at the top of my HTML?" @@ -235,7 +239,7 @@ Allows you to defer loading JavaScript until a component begins rendering. This | Type | Description | | --- | --- | - | `Component` | An ReactPy component. | + | `Component` | A ReactPy component. | ??? question "Should I put `django_js` at the bottom of my HTML?" diff --git a/docs/src/features/decorators.md b/docs/src/features/decorators.md index 45548799..ee79a4e8 100644 --- a/docs/src/features/decorators.md +++ b/docs/src/features/decorators.md @@ -1,8 +1,12 @@ ## Overview -!!! summary "Overview" +

- Decorator utilities can be used within your `components.py` to help simplify development. +Decorator functions can be used within your `components.py` to help simplify development. + +

+ +--- ## Auth Required @@ -31,7 +35,7 @@ This decorator is commonly used to selectively render a component only if a user | Type | Description | | --- | --- | - | `Component` | An ReactPy component. | + | `Component` | A ReactPy component. | | `VdomDict` | An `reactpy.html` snippet. | | `None` | No component render. | diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md index 5916776a..6c57fda3 100644 --- a/docs/src/features/hooks.md +++ b/docs/src/features/hooks.md @@ -1,14 +1,18 @@ ## Overview -!!! summary "Overview" +

- Prefabricated hooks can be used within your `components.py` to help simplify development. +Prefabricated hooks can be used within your `components.py` to help simplify development. -??? tip "Looking for standard React hooks?" +

- The `reactpy-django` package only contains django specific hooks. Standard hooks can be found within [`reactive-python/reactpy`](https://github.com/reactive-python/reactpy). Since `reactpy` is installed alongside `reactpy-django`, you can import them at any time. +!!! note - Check out the [ReactPy Core docs](https://reactpy.dev/docs/reference/hooks-api.html#basic-hooks) to see what hooks are available! + Looking for standard React hooks? + + This package only contains Django specific hooks. Standard hooks can be found within [`reactive-python/reactpy`](https://reactpy.dev/docs/reference/hooks-api.html#basic-hooks). + +--- ## Use Query diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md index 29ca81ad..8cf11815 100644 --- a/docs/src/features/settings.md +++ b/docs/src/features/settings.md @@ -1,8 +1,18 @@ ## Overview -!!! summary "Overview" +

- Your **Django project's** `settings.py` can modify the behavior of ReactPy. +Your **Django project's** `settings.py` can modify the behavior of ReactPy. + +

+ +!!! note + + The default configuration of ReactPy is suitable for the vast majority of use cases. + + You should only consider changing settings when the necessity arises. + +--- ## Primary Configuration @@ -13,18 +23,12 @@ These are ReactPy-Django's default settings values. You can modify these values | Setting | Default Value | Example Value(s) | Description | | --- | --- | --- | --- | | `REACTPY_CACHE` | `#!python "default"` | `#!python "my-reactpy-cache"` | Cache used to store ReactPy web modules. ReactPy benefits from a fast, well indexed cache.
We recommend installing [`redis`](https://redis.io/) or [`python-diskcache`](https://grantjenks.com/docs/diskcache/tutorial.html#djangocache). | -| `REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Database ReactPy uses to store session data. ReactPy requires a multiprocessing-safe and thread-safe database.
If configuring `REACTPY_DATABASE`, it is mandatory to also configure `DATABASE_ROUTERS` like such:
`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` | +| `REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Database used to store ReactPy session data. ReactPy requires a multiprocessing-safe and thread-safe database.
If configuring `REACTPY_DATABASE`, it is mandatory to use our database router like such:
`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` | | `REACTPY_RECONNECT_MAX` | `#!python 259200` | `#!python 96000`, `#!python 60`, `#!python 0` | Maximum seconds between reconnection attempts before giving up.
Use `#!python 0` to prevent reconnection. | | `REACTPY_URL_PREFIX` | `#!python "reactpy/"` | `#!python "rp/"`, `#!python "render/reactpy/"` | The prefix to be used for all ReactPy websocket and HTTP URLs. | | `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python "example_project.my_query_postprocessor"` | Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function. | | `REACTPY_AUTH_BACKEND` | `#!python "django.contrib.auth.backends.ModelBackend"` | `#!python "example_project.auth.MyModelBackend"` | Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:
1. You are using `AuthMiddlewareStack` and...
2. You are using Django's `AUTHENTICATION_BACKENDS` setting and...
3. Your Django user model does not define a `backend` attribute. | | `REACTPY_BACKHAUL_THREAD` | `#!python False` | `#!python True` | Whether to render ReactPy components in a dedicated thread. This allows the webserver to process web traffic while during ReactPy rendering.
Vastly improves throughput with web servers such as `hypercorn` and `uvicorn`. | -| `REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir" ]` | Default host(s) to use for ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing.
You can use the `host` argument in your [template tag](../features/template-tag.md#component) to override this default. | +| `REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir" ]` | The default host(s) that can render your ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing.
You can use the `host` argument in your [template tag](../features/template-tag.md#component) as a manual override. | - -??? question "Do I need to modify my settings?" - - The default configuration of ReactPy is suitable for the majority of use cases. - - You should only consider changing settings when the necessity arises. diff --git a/docs/src/features/template-tag.md b/docs/src/features/template-tag.md index f1424059..e04c5bb9 100644 --- a/docs/src/features/template-tag.md +++ b/docs/src/features/template-tag.md @@ -1,8 +1,12 @@ ## Overview -!!! summary "Overview" +

- Template tags can be used within your Django templates such as `my-template.html` to import ReactPy features. +Django template tags can be used within your HTML templates to provide ReactPy features. + +

+ +--- ## Component @@ -20,6 +24,8 @@ The `component` template tag can be used to insert any number of ReactPy compone | --- | --- | --- | --- | | `dotted_path` | `str` | The dotted path to the component to render. | N/A | | `*args` | `Any` | The positional arguments to provide to the component. | N/A | + | `class` | `str | None` | The HTML class to apply to the top-level component div. | `None` | + | `key` | `str | None` | Force the component's root node to use a [specific key value](https://reactpy.dev/docs/guides/creating-interfaces/rendering-data/index.html#organizing-items-with-keys). Using `key` within a template tag is effectively useless. | `None` | | `host` | `str | None` | The host to use for the ReactPy connections. If set to `None`, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir` | `None` | | `**kwargs` | `Any` | The keyword arguments to provide to the component. | N/A | @@ -27,7 +33,7 @@ The `component` template tag can be used to insert any number of ReactPy compone | Type | Description | | --- | --- | - | `Component` | An ReactPy component. | + | `Component` | A ReactPy component. | @@ -56,24 +62,6 @@ The `component` template tag can be used to insert any number of ReactPy compone ``` - - -??? 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's root node to use a [specific key value](https://reactpy.dev/docs/guides/creating-interfaces/rendering-data/index.html#organizing-items-with-keys). Using `key` within a template tag is effectively useless. - - === "my-template.html" - - ```jinja - ... - {% component "example.components.my_component" class="my-html-class" key=123 %} - ... - ``` - - ??? question "Can I render components on a different server (distributed computing)?" diff --git a/docs/src/features/utils.md b/docs/src/features/utils.md index dfadb9f9..9ba8e312 100644 --- a/docs/src/features/utils.md +++ b/docs/src/features/utils.md @@ -1,8 +1,12 @@ ## Overview -!!! summary "Overview" +

- Utility functions that you can use when needed. +Utility functions provide various miscellaneous functionality. These are typically not used, but are available for advanced use cases. + +

+ +--- ## Django Query Postprocessor diff --git a/docs/src/get-started/choose-django-app.md b/docs/src/get-started/choose-django-app.md index 61dcfdba..1594baa5 100644 --- a/docs/src/get-started/choose-django-app.md +++ b/docs/src/get-started/choose-django-app.md @@ -1,16 +1,25 @@ ## Overview -!!! summary "Overview" +

- Set up a **Django Project** with at least one app. +Set up a **Django Project** with at least one app. -## Choose a Django App +

-If you have reached this point, you should have already [installed ReactPy-Django](../get-started/installation.md) through the previous steps. +!!! note + + If you have reached this point, you should have already [installed ReactPy-Django](../get-started/installation.md) through the previous steps. + +--- + +## Deciding which Django App to use You will now need to pick at least one **Django app** to start using ReactPy-Django on. -For the examples within this section, we will assume you have 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. +For the following examples, we will assume the following: + +1. You have a **Django app** named `my_app`, which was created by Django's [`startapp` command](https://docs.djangoproject.com/en/dev/intro/tutorial01/#creating-the-polls-app). +2. You have placed `my_app` directly into your **Django project** folder (`./example_project/my_app`). This is common for small projects. ??? question "How do I organize my Django project for ReactPy?" diff --git a/docs/src/get-started/create-component.md b/docs/src/get-started/create-component.md index 032906e7..1f94c308 100644 --- a/docs/src/get-started/create-component.md +++ b/docs/src/get-started/create-component.md @@ -1,12 +1,20 @@ ## Overview -!!! summary "Overview" +

- Create a component function using our decorator. +You can let ReactPy know what functions are components by using the `#!python @component` decorator. -## Create a Component +

-{% include-markdown "../../../README.md" start="" end="" %} +--- + +## Declaring a function as a root component + +You will need a file to start creating ReactPy components. + +We recommend creating a `components.py` file within your chosen **Django app** to start out. For this example, the file path will look like this: `./example_project/my_app/components.py`. + +Within this file, you can define your component functions and then add ReactPy's `#!python @component` decorator. === "components.py" @@ -18,4 +26,15 @@ We recommend creating a `components.py` for small **Django apps**. If your app has a lot of components, you should consider breaking them apart into individual modules such as `components/navbar.py`. - 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`. + Ultimately, components are referenced by Python dotted path in `my-template.html` ([_see next step_](./use-template-tag.md)). So, at minimum your component path needs to be valid to Python's `importlib`. + +??? question "What does the decorator actually do?" + + While not all components need to be decorated, there are a few features this decorator adds to your components. + + 1. The ability to be used as a root component. + - The decorator is required for any component that you want to reference in your Django templates ([_see next step_](./use-template-tag.md)). + 2. The ability to use [hooks](../features/hooks.md). + - The decorator is required on any component where hooks are defined. + 3. Scoped failures. + - If a decorated component generates an exception, then only that one component will fail to render. diff --git a/docs/src/get-started/installation.md b/docs/src/get-started/installation.md index bd368ec1..be727947 100644 --- a/docs/src/get-started/installation.md +++ b/docs/src/get-started/installation.md @@ -1,12 +1,18 @@ ## Overview -!!! summary "Overview" +

- ReactPy-Django can be installed from PyPI to an existing **Django project** with minimal configuration. +[ReactPy-Django](https://github.com/reactive-python/reactpy-django) can be used to add used to add [ReactPy](https://github.com/reactive-python/reactpy) support to an existing **Django project**. Minimal configuration is required to get started. -## Step 0: Create a Django Project +

-These docs assumes you have already created [a **Django project**](https://docs.djangoproject.com/en/dev/intro/tutorial01/), which involves creating and installing at least one **Django app**. If not, check out this [9 minute YouTube tutorial](https://www.youtube.com/watch?v=ZsJRXS_vrw0) created by _IDG TECHtalk_. +!!! note + + These docs assumes you have already created [a **Django project**](https://docs.djangoproject.com/en/dev/intro/tutorial01/), which involves creating and installing at least one **Django app**. + + If do not have a **Django project**, check out this [9 minute YouTube tutorial](https://www.youtube.com/watch?v=ZsJRXS_vrw0) created by _IDG TECHtalk_. + +--- ## Step 1: Install from PyPI @@ -84,7 +90,7 @@ Register ReactPy's Websocket using `REACTPY_WEBSOCKET_ROUTE`. If you do not have an `asgi.py`, follow the [`channels` installation guide](https://channels.readthedocs.io/en/stable/installation.html). -## Step 5: Run Migrations +## Step 5: Run database migrations Run Django's database migrations to initialize ReactPy-Django's database table. @@ -99,3 +105,21 @@ Run Django's check command to verify if ReactPy was set up correctly. ```bash linenums="0" python manage.py check ``` + +## Step 7: Create your first component! + +The [following steps](./choose-django-app.md) will show you how to create your first ReactPy component. + +Prefer a quick summary? Read the **At a Glance** section below. + +!!! info "At a Glance" + + **`my_app/components.py`** + + {% include-markdown "../../../README.md" start="" end="" %} + + --- + + **`my_app/templates/my-template.html`** + + {% include-markdown "../../../README.md" start="" end="" %} diff --git a/docs/src/get-started/learn-more.md b/docs/src/get-started/learn-more.md index 8b58400d..3ee45968 100644 --- a/docs/src/get-started/learn-more.md +++ b/docs/src/get-started/learn-more.md @@ -1,11 +1,17 @@ # :confetti_ball: Congratulations :confetti_ball: -If you followed the previous steps, you have now created a "Hello World" component! +

-The docs you are reading only covers our Django integration. To learn more about features, such as interactive events and hooks, check out the [ReactPy Core Documentation](https://reactpy.dev/docs/guides/creating-interfaces/index.html)! +If you followed the previous steps, you have now created a "Hello World" component using ReactPy-Django! -Additionally, the vast majority of tutorials/guides you find for ReactJS can be applied to ReactPy. +

-=== "Learn More" +!!! info "Deep Dive" - [ReactPy-Django Advanced Usage](../features/components.md){ .md-button .md-button--primary} [ReactPy Core Documentation](https://reactpy.dev/docs/guides/creating-interfaces/index.html){ .md-button .md-button--primary } [Ask Questions on Discord](https://discord.gg/uNb5P4hA9X){ .md-button .md-button--primary } + The docs you are reading only covers our Django integration. To learn more, check out one of the following links: + + - [ReactPy-Django Feature Reference](../features/components.md) + - [ReactPy Core Documentation](https://reactpy.dev/docs/guides/creating-interfaces/index.html) + - [Ask Questions on Discord](https://discord.gg/uNb5P4hA9X) + + Additionally, the vast majority of tutorials/guides you find for ReactJS can be applied to ReactPy. diff --git a/docs/src/get-started/register-view.md b/docs/src/get-started/register-view.md index 7f21bd66..472d722b 100644 --- a/docs/src/get-started/register-view.md +++ b/docs/src/get-started/register-view.md @@ -1,16 +1,22 @@ ## Overview -!!! summary "Overview" +

- Select your template containing an ReactPy component, and render it using a Django view. +Render your template containing your ReactPy component using a Django view. -## Register a View +

-We will assume you have [created a Django View](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view) before, but here's a simple example below. +!!! Note + + We assume you have [created a Django View](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view) before, but we have included a simple example below. + +--- + +## Creating a Django view and URL path Within your **Django app**'s `views.py` file, you will need to create a function to render the HTML template containing your ReactPy components. -In this example, we will create a view that renders `my-template.html` (_from the previous step_). +In this example, we will create a view that renders `my-template.html` ([_from the previous step_](./use-template-tag.md)). === "views.py" diff --git a/docs/src/get-started/run-webserver.md b/docs/src/get-started/run-webserver.md index 5a1d27dd..cefeafc3 100644 --- a/docs/src/get-started/run-webserver.md +++ b/docs/src/get-started/run-webserver.md @@ -1,10 +1,14 @@ ## Overview -!!! summary "Overview" +

- Run a webserver to display your Django view. +Run a webserver to display your Django view. -## Run the Webserver +

+ +--- + +## Viewing your component using a webserver To test your new Django view, run the following command to start up a development webserver. @@ -12,10 +16,12 @@ To test your new Django view, run the following command to start up a developmen python manage.py runserver ``` -Now you can navigate to your **Django project** URL that contains an ReactPy component, such as `http://127.0.0.1:8000/example/` (_from the previous step_). +Now you can navigate to your **Django project** URL that contains a ReactPy component, such as `http://127.0.0.1:8000/example/` ([_from the previous step_](./register-view.md)). If you copy-pasted our example component, you will now see your component display "Hello World". -??? warning "Do not use `manage.py runserver` for production." +!!! warning "Pitfall" + + Do not use `manage.py runserver` for production. - The webserver contained within `manage.py runserver` is only intended for development and testing purposes. For production deployments make sure to read [Django's documentation](https://docs.djangoproject.com/en/dev/howto/deployment/). + This command is only intended for development purposes. For production deployments make sure to read [Django's documentation](https://docs.djangoproject.com/en/dev/howto/deployment/). diff --git a/docs/src/get-started/use-template-tag.md b/docs/src/get-started/use-template-tag.md index e108e407..8ef210fd 100644 --- a/docs/src/get-started/use-template-tag.md +++ b/docs/src/get-started/use-template-tag.md @@ -1,21 +1,25 @@ ## Overview -!!! summary "Overview" +

- Decide where the component will be displayed by using our template tag. +Decide where the component will be displayed by using our template tag. -## Use the Template Tag +

+ +--- + +## Embedding a component in a template {% include-markdown "../../../README.md" start="" end="" %} +Additionally, you can pass in `args` and `kwargs` into your component function. After reading the code below, pay attention to how the function definition for `hello_world` ([_from the previous step_](./create-component.md)) accepts a `recipient` argument. + === "my-template.html" {% include-markdown "../../../README.md" start="" end="" %} {% include-markdown "../features/template-tag.md" start="" end="" %} -{% include-markdown "../features/template-tag.md" start="" end="" %} - {% include-markdown "../features/template-tag.md" start="" end="" %} ??? question "Where is my templates folder?" diff --git a/docs/src/static/css/extra.css b/docs/src/static/css/extra.css new file mode 100644 index 00000000..d3967666 --- /dev/null +++ b/docs/src/static/css/extra.css @@ -0,0 +1,376 @@ +/* Variable overrides */ +:root { + --code-max-height: 17.25rem; +} + +[data-md-color-scheme="slate"] { + --md-code-hl-color: #ffffcf1c; + --md-hue: 225; + --md-default-bg-color: hsla(var(--md-hue), 15%, 16%, 1); + --md-default-bg-color--light: hsla(var(--md-hue), 15%, 16%, 0.54); + --md-default-bg-color--lighter: hsla(var(--md-hue), 15%, 16%, 0.26); + --md-default-bg-color--lightest: hsla(var(--md-hue), 15%, 16%, 0.07); + --md-code-bg-color: #16181d; + --md-primary-fg-color: #2b3540; + --md-default-fg-color--light: #fff; + --md-typeset-a-color: #00b0f0; + --md-code-hl-comment-color: hsla(var(--md-hue), 75%, 90%, 0.43); + --tabbed-labels-color: rgb(52 58 70); +} + +[data-md-color-scheme="default"] { + --tabbed-labels-color: #7d829e26; +} + +/* General admonition styling */ +/* TODO: Write this in a way that supports the light theme */ +[data-md-color-scheme="slate"] .md-typeset details, +[data-md-color-scheme="slate"] .md-typeset .admonition { + border-color: transparent !important; +} + +.md-typeset :is(.admonition, details) { + margin: 0.55em 0; +} + +.md-typeset .admonition { + font-size: 0.7rem; +} + +.md-typeset .admonition:focus-within, +.md-typeset details:focus-within { + box-shadow: var(--md-shadow-z1) !important; +} + +/* Colors for "summary" admonition */ +[data-md-color-scheme="slate"] .md-typeset .admonition.summary { + background: #353a45; + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +[data-md-color-scheme="slate"] .md-typeset .summary .admonition-title { + font-size: 1rem; + background: transparent; + padding-left: 0.6rem; + padding-bottom: 0; +} + +[data-md-color-scheme="slate"] .md-typeset .summary .admonition-title:before { + display: none; +} + +[data-md-color-scheme="slate"] .md-typeset .admonition.summary { + border-color: #ffffff17 !important; +} + +/* Colors for "note" admonition */ +[data-md-color-scheme="slate"] .md-typeset .admonition.note { + background: rgb(43 110 98/ 0.2); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +[data-md-color-scheme="slate"] .md-typeset .note .admonition-title { + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(68 172 153); +} + +[data-md-color-scheme="slate"] .md-typeset .note .admonition-title:before { + font-size: 1.1rem; + background-color: rgb(68 172 153); +} + +.md-typeset .note > .admonition-title:before, +.md-typeset .note > summary:before { + -webkit-mask-image: var(--md-admonition-icon--abstract); + mask-image: var(--md-admonition-icon--abstract); +} + +/* Colors for "warning" admonition */ +[data-md-color-scheme="slate"] .md-typeset .admonition.warning { + background: rgb(182 87 0 / 0.2); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +[data-md-color-scheme="slate"] .md-typeset .warning .admonition-title { + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(219 125 39); +} + +[data-md-color-scheme="slate"] .md-typeset .warning .admonition-title:before { + font-size: 1.1rem; + background-color: rgb(219 125 39); +} + +/* Colors for "info" admonition */ +[data-md-color-scheme="slate"] .md-typeset .admonition.info { + background: rgb(43 52 145 / 0.2); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +[data-md-color-scheme="slate"] .md-typeset .info .admonition-title { + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(136 145 236); +} + +[data-md-color-scheme="slate"] .md-typeset .info .admonition-title:before { + font-size: 1.1rem; + background-color: rgb(136 145 236); +} + +/* Colors for "example" admonition */ +[data-md-color-scheme="slate"] .md-typeset .admonition.example { + background: rgb(94 104 126); + border-radius: 0.4rem; +} + +[data-md-color-scheme="slate"] .md-typeset .example .admonition-title { + background: rgb(78 87 105); + color: rgb(246 247 249); +} + +[data-md-color-scheme="slate"] .md-typeset .example .admonition-title:before { + background-color: rgb(246 247 249); +} + +[data-md-color-scheme="slate"] .md-typeset .admonition.example code { + background: transparent; + color: #fff; +} + +/* Move the sidebars to the edges of the page */ +.md-main__inner.md-grid { + margin-left: 0; + margin-right: 0; + max-width: unset; + display: flex; + justify-content: center; +} + +.md-sidebar--primary { + margin-right: auto; +} + +.md-sidebar.md-sidebar--secondary { + margin-left: auto; +} + +.md-content { + max-width: 56rem; +} + +/* Maintain content positioning even if sidebars are disabled */ +@media screen and (min-width: 76.1875em) { + .md-sidebar { + display: block; + } + + .md-sidebar[hidden] { + visibility: hidden; + } +} + +/* Sidebar styling */ +@media screen and (min-width: 76.1875em) { + .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { + text-transform: uppercase; + } + + .md-nav__title[for="__toc"] { + text-transform: uppercase; + margin: 0.5rem; + } + + .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { + color: rgb(133 142 159); + margin: 0.5rem; + } + + .md-nav__item .md-nav__link { + position: relative; + } + + .md-nav__link:is(:focus, :hover):not(.md-nav__link--active) { + color: unset; + } + + .md-nav__item + .md-nav__link:is(:focus, :hover):not(.md-nav__link--active):before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.2; + z-index: -1; + background-color: grey; + } + + .md-nav__item .md-nav__link--active:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.15; + z-index: -1; + background-color: var(--md-typeset-a-color); + } + + .md-nav__link { + padding: 0.5rem 0.5rem 0.5rem 1rem; + margin: 0; + border-radius: 0 10px 10px 0; + font-weight: 600; + overflow: hidden; + } + + .md-sidebar__scrollwrap { + margin: 0; + } + + [dir="ltr"] + .md-nav--lifted + .md-nav[data-md-level="1"] + > .md-nav__list + > .md-nav__item { + padding: 0; + } + + .md-nav__item--nested .md-nav__item .md-nav__item { + padding: 0; + } + + .md-nav__item--nested .md-nav__item .md-nav__item .md-nav__link { + font-weight: 300; + } + + .md-nav__item--nested .md-nav__item .md-nav__item .md-nav__link { + font-weight: 400; + padding-left: 1.25rem; + } +} + +/* Table of Contents styling */ +@media screen and (min-width: 60em) { + [data-md-component="sidebar"] .md-nav__title[for="__toc"] { + text-transform: uppercase; + margin: 0.5rem; + margin-left: 0; + } + + [data-md-component="toc"] .md-nav__item .md-nav__link--active { + position: relative; + } + + [data-md-component="toc"] .md-nav__item .md-nav__link--active:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.15; + z-index: -1; + background-color: var(--md-typeset-a-color); + } + + [data-md-component="toc"] .md-nav__link { + padding: 0.5rem 0.5rem; + margin: 0; + border-radius: 10px 0 0 10px; + } + [dir="ltr"] .md-sidebar__inner { + padding: 0; + } + + .md-nav__item { + padding: 0; + } +} + +/* Font changes */ +.md-typeset { + font-weight: 300; +} + +.md-typeset h1 { + font-weight: 500; + margin: 0; + font-size: 2.5em; +} + +.md-typeset h2 { + font-weight: 500; +} + +.md-typeset h3 { + font-weight: 600; +} + +/* Intro section styling */ +p.intro { + font-size: 0.9rem; + font-weight: 500; +} + +/* Hide invisible jump selectors */ +h2#overview { + visibility: hidden; + height: 0; + margin: 0; + padding: 0; +} + +/* Code blocks */ +.md-typeset pre > code { + border-radius: 16px; +} + +.md-typeset .highlighttable .linenos { + max-height: var(--code-max-height); + overflow: hidden; +} + +.md-typeset .tabbed-block .highlighttable code { + border-radius: 0; +} + +.md-typeset .tabbed-block { + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + overflow: hidden; +} + +.js .md-typeset .tabbed-labels { + background: var(--tabbed-labels-color); + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} + +.md-typeset .tabbed-labels > label { + font-weight: 400; + font-size: 0.7rem; + padding-top: 0.55em; + padding-bottom: 0.35em; +} + +.md-typeset pre > code { + max-height: var(--code-max-height); +} + +/* Reduce height of outdated banner */ +.md-banner__inner { + margin: 0.45rem auto; +} diff --git a/docs/src/static/js/extra.js b/docs/src/static/js/extra.js new file mode 100644 index 00000000..50e2dda3 --- /dev/null +++ b/docs/src/static/js/extra.js @@ -0,0 +1,19 @@ +// Sync scrolling between the code node and the line number node +// Event needs to be a separate function, otherwise the event will be triggered multiple times +let code_with_lineno_scroll_event = function () { + let tr = this.parentNode.parentNode.parentNode.parentNode; + let lineno = tr.querySelector(".linenos"); + lineno.scrollTop = this.scrollTop; +}; + +const observer = new MutationObserver((mutations) => { + let lineno = document.querySelectorAll(".linenos~.code"); + lineno.forEach(function (element) { + let code = element.parentNode.querySelector("code"); + code.addEventListener("scroll", code_with_lineno_scroll_event); + }); +}); + +observer.observe(document.body, { + childList: true, +}); diff --git a/docs/src/stylesheets/extra.css b/docs/src/stylesheets/extra.css deleted file mode 100644 index 1599d525..00000000 --- a/docs/src/stylesheets/extra.css +++ /dev/null @@ -1,246 +0,0 @@ -/* Reduce the insane amounts of white space the default theme has */ -.md-typeset :is(.admonition, details) { - margin: 0.55em 0; -} - -.md-typeset .tabbed-labels > label { - padding-top: 0; - padding-bottom: 0.35em; -} - -/* Font size for admonitions */ -.md-typeset .admonition.summary, -.md-typeset details.summary { - font-size: 0.7rem; -} - -/* Colors for admonitions */ -[data-md-color-scheme="slate"] - .md-typeset - details:not(.warning, .failure, .danger, .bug) - > .admonition-title, -[data-md-color-scheme="slate"] - .md-typeset - details:not(.warning, .failure, .danger, .bug) - > summary { - background: var(--md-primary-fg-color) !important; -} - -[data-md-color-scheme="slate"] .md-typeset .admonition, -[data-md-color-scheme="slate"] .md-typeset details { - border-color: transparent !important; -} - -[data-md-color-scheme="slate"] .md-typeset details > .admonition-title:after, -[data-md-color-scheme="slate"] .md-typeset details > summary:after { - color: var(--md-admonition-fg-color) !important; -} - -/* Colors for summary admonition */ -[data-md-color-scheme="slate"] .md-typeset .admonition.summary, -[data-md-color-scheme="slate"] .md-typeset details.summary { - background: #353a45; - padding: 0.8rem 1.4rem; - border-radius: 0.8rem; -} - -[data-md-color-scheme="slate"] .md-typeset details.summary > .admonition-title, -[data-md-color-scheme="slate"] .md-typeset details.summary > summary { - background: #353a45 !important; -} - -[data-md-color-scheme="slate"] .md-typeset .summary .admonition-title, -[data-md-color-scheme="slate"] .md-typeset .summary summary { - font-size: 1rem; - background: transparent; - padding-left: 0.6rem; - padding-bottom: 0; -} - -[data-md-color-scheme="slate"] .md-typeset .summary .admonition-title:before { - display: none; -} - -[data-md-color-scheme="slate"] .md-typeset .admonition, -[data-md-color-scheme="slate"] .md-typeset details { - border-color: #ffffff17 !important; -} - -/* Move the sidebars to the edges of the page */ -.md-main__inner.md-grid { - margin-left: 0; - margin-right: 0; - max-width: unset; - display: flex; - justify-content: center; -} - -.md-sidebar--primary { - margin-right: auto; -} - -.md-sidebar.md-sidebar--secondary { - margin-left: auto; -} - -.md-content { - max-width: 56rem; -} - -/* Maintain content positioning even if sidebars are disabled */ -@media screen and (min-width: 76.1875em) { - .md-sidebar { - display: block; - } - - .md-sidebar[hidden] { - visibility: hidden; - } -} - -/* Sidebar styling */ -@media screen and (min-width: 76.1875em) { - .md-nav__title[for="__toc"] { - text-transform: uppercase; - margin: 0.5rem; - } - - .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { - color: rgb(133 142 159); - margin: 0.5rem; - } - - .md-nav__item .md-nav__link { - position: relative; - } - - .md-nav__link:is(:focus, :hover):not(.md-nav__link--active) { - color: unset; - } - - .md-nav__item - .md-nav__link:is(:focus, :hover):not(.md-nav__link--active):before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.2; - z-index: -1; - background-color: grey; - } - - .md-nav__item .md-nav__link--active:before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.15; - z-index: -1; - background-color: var(--md-typeset-a-color); - } - - .md-nav__link { - padding: 0.5rem 0.5rem 0.5rem 1rem; - margin: 0; - border-radius: 0 10px 10px 0; - font-weight: 500; - } - - .md-sidebar__scrollwrap { - margin: 0; - } - - [dir="ltr"] - .md-nav--lifted - .md-nav[data-md-level="1"] - > .md-nav__list - > .md-nav__item { - padding: 0; - } - - .md-nav__item--nested .md-nav__item .md-nav__item { - padding: 0; - } - - .md-nav__item--nested .md-nav__item .md-nav__item .md-nav__link { - font-weight: 400; - padding-left: 1.5rem; - } -} - -/* Table of Contents styling */ -@media screen and (min-width: 60em) { - [data-md-component="sidebar"] .md-nav__title[for="__toc"] { - text-transform: uppercase; - margin: 0.5rem; - margin-left: 0; - } - - [data-md-component="toc"] .md-nav__item .md-nav__link--active { - position: relative; - } - - [data-md-component="toc"] .md-nav__item .md-nav__link--active:before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.15; - z-index: -1; - background-color: var(--md-typeset-a-color); - } - - [data-md-component="toc"] .md-nav__link { - padding: 0.5rem 0.5rem; - margin: 0; - border-radius: 10px 0 0 10px; - } - [dir="ltr"] .md-sidebar__inner { - padding: 0; - } - - .md-nav__item { - padding: 0; - } -} - -/* Page background color */ -[data-md-color-scheme="slate"] { - --md-hue: 225; - --md-default-bg-color: hsla(var(--md-hue), 15%, 16%, 1); - --md-default-bg-color--light: hsla(var(--md-hue), 15%, 16%, 0.54); - --md-default-bg-color--lighter: hsla(var(--md-hue), 15%, 16%, 0.26); - --md-default-bg-color--lightest: hsla(var(--md-hue), 15%, 16%, 0.07); - --md-code-bg-color: #16181d; - --md-primary-fg-color: #2b3540; - --md-default-fg-color--light: #fff; - --md-typeset-a-color: #00b0f0; - --md-code-hl-comment-color: hsla(var(--md-hue), 75%, 90%, 0.43); -} - -/* Font changes */ -.md-typeset h1 { - font-weight: 500; -} - -.md-typeset h1:not([id]) { - display: none; -} - -.md-typeset h2 { - font-weight: 400; -} - -/* Hide invisible jump selectors */ -h2#overview { - visibility: hidden; - height: 0; - margin: 0; - padding: 0; -} diff --git a/mkdocs.yml b/mkdocs.yml index fa7b5f90..e5b08921 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -49,6 +49,8 @@ theme: - content.code.copy icon: repo: fontawesome/brands/github + logo: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg + favicon: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg markdown_extensions: - toc: @@ -65,11 +67,14 @@ markdown_extensions: - pymdownx.inlinehilite - admonition - attr_list + - md_in_html + - pymdownx.keys plugins: - search - - git-authors - include-markdown + - section-index + - git-authors - minify: minify_html: true minify_js: true @@ -84,9 +89,14 @@ plugins: extra: generator: false + version: + provider: mike + +extra_javascript: + - static/js/extra.js extra_css: - - stylesheets/extra.css + - static/css/extra.css watch: - docs @@ -94,7 +104,7 @@ watch: - README.md - CHANGELOG.md -site_name: ReactPy-Django Docs +site_name: ReactPy-Django site_author: Archmonger site_description: React for Django developers. copyright: Copyright © 2023 Reactive Python diff --git a/noxfile.py b/noxfile.py index 43fb2a40..accaf6a1 100644 --- a/noxfile.py +++ b/noxfile.py @@ -39,9 +39,9 @@ def test_suite(session: Session) -> None: session.env["REACTPY_DEBUG_MODE"] = "1" posargs = session.posargs[:] - if "--headed" in posargs: - posargs.remove("--headed") - session.env["PLAYWRIGHT_HEADED"] = "1" + if "--headless" in posargs: + posargs.remove("--headless") + session.env["PLAYWRIGHT_HEADLESS"] = "1" if "--no-debug-mode" not in posargs: posargs.append("--debug-mode") diff --git a/requirements/build-docs.txt b/requirements/build-docs.txt index 9ae8fcf1..0e7af6eb 100644 --- a/requirements/build-docs.txt +++ b/requirements/build-docs.txt @@ -6,3 +6,5 @@ linkcheckmd mkdocs-spellcheck[all] mkdocs-git-authors-plugin mkdocs-minify-plugin +mkdocs-section-index +mike diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 9d6e6a98..31df3e2e 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -120,7 +120,7 @@ def view_to_component( transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, ) -> _ViewComponentConstructor | Callable[[Callable], _ViewComponentConstructor]: - """Converts a Django view to an ReactPy component. + """Converts a Django view to a ReactPy component. Keyword Args: view: The view function or class to convert. @@ -134,7 +134,7 @@ def view_to_component( Returns: A function that takes `request: HttpRequest | None, *args: Any, key: Key | None, **kwargs: Any` - and returns an ReactPy component. + and returns a ReactPy component. """ def decorator(view: Callable | View): diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 1ae88413..b174fa9a 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -65,7 +65,6 @@ def component( is_local = not host or host.startswith(perceived_host) uuid = uuid4().hex class_ = kwargs.pop("class", "") - kwargs.pop("key", "") # `key` is useless for the root node component_has_args = args or kwargs user_component: ComponentConstructor | None = None diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 58e6c053..e78d8963 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -2,6 +2,7 @@ import os import socket import sys +from distutils.util import strtobool from functools import partial from channels.testing import ChannelsLiveServerTestCase @@ -13,8 +14,8 @@ from playwright.sync_api import TimeoutError, sync_playwright from reactpy_django.models import ComponentSession -GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") -CLICK_DELAY = 250 if GITHUB_ACTIONS else 25 # Delay in miliseconds. +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") +CLICK_DELAY = 250 if strtobool(GITHUB_ACTIONS) else 25 # Delay in miliseconds. class ComponentTests(ChannelsLiveServerTestCase): @@ -51,8 +52,8 @@ def setUpClass(cls): if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) cls.playwright = sync_playwright().start() - headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", not GITHUB_ACTIONS))) - cls.browser = cls.playwright.chromium.launch(headless=not headed) + headless = strtobool(os.environ.get("PLAYWRIGHT_HEADLESS", GITHUB_ACTIONS)) + cls.browser = cls.playwright.chromium.launch(headless=bool(headless)) cls.page = cls.browser.new_page() @classmethod From e5f27da14628c0cb50b7e15354c8bdd47e05f683 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 26 Aug 2023 14:03:53 -0700 Subject: [PATCH 05/68] Add git user to mike deployment (#177) Mike requires a git user otherwise it will fail to deploy. --- .github/workflows/publish-develop-docs.yml | 5 ++++- .github/workflows/publish-release-docs.yml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml index 0269e82c..3fdb2bab 100644 --- a/.github/workflows/publish-develop-docs.yml +++ b/.github/workflows/publish-develop-docs.yml @@ -15,4 +15,7 @@ jobs: python-version: 3.x - run: pip install -r requirements/build-docs.txt - name: Publish Develop Docs - run: mike deploy --push develop + run: | + git config user.name github-actions + git config user.email github-actions@github.com + mike deploy --push develop diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml index 15ffb8c7..a0c8861d 100644 --- a/.github/workflows/publish-release-docs.yml +++ b/.github/workflows/publish-release-docs.yml @@ -16,4 +16,7 @@ jobs: python-version: 3.x - run: pip install -r requirements/build-docs.txt - name: Publish ${{ github.event.release.name }} Docs - run: mike deploy --push --update-aliases ${{ github.event.release.name }} latest + run: | + git config user.name github-actions + git config user.email github-actions@github.com + mike deploy --push --update-aliases ${{ github.event.release.name }} latest From cd0ebf5646d563ba83edf2804357785100812770 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 26 Aug 2023 14:19:02 -0700 Subject: [PATCH 06/68] Run `mike set-default` on release deployment (#178) Looks like mike deployment won't stick without the set-default call --- .github/workflows/publish-release-docs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml index a0c8861d..b58cb0ed 100644 --- a/.github/workflows/publish-release-docs.yml +++ b/.github/workflows/publish-release-docs.yml @@ -20,3 +20,4 @@ jobs: git config user.name github-actions git config user.email github-actions@github.com mike deploy --push --update-aliases ${{ github.event.release.name }} latest + mike set-default --push latest From 4a973b68c16afbcff289f9af56a11e38c922336c Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 26 Aug 2023 21:01:59 -0700 Subject: [PATCH 07/68] Create new `ReactPyDjangoClient` (#175) - Move from JavaScript to TypeScript - Bumped the minimum `@reactpy/client` version to `0.3.1` - Create a Django-specific client - Minor refactoring to various bits of code - Bumped minimum Django version to `4.2`. - More customization for reconnection behavior through new settings! - `REACTPY_RECONNECT_INTERVAL` - `REACTPY_RECONNECT_MAX_INTERVAL` - `REACTPY_RECONNECT_MAX_RETRIES` - `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` --- .github/workflows/publish-py.yml | 56 ++--- .github/workflows/publish-release-docs.yml | 1 - .github/workflows/test-src.yml | 2 +- CHANGELOG.md | 13 ++ docs/src/contribute/code.md | 2 +- docs/src/contribute/docs.md | 2 +- docs/src/features/settings.md | 6 +- docs/src/get-started/run-webserver.md | 2 +- pyproject.toml | 1 - requirements/pkg-deps.txt | 2 +- src/js/package-lock.json | 189 ++++++++++------- src/js/package.json | 12 +- src/js/rollup.config.mjs | 4 +- src/js/src/client.ts | 31 +++ src/js/src/index.js | 59 ------ src/js/src/index.ts | 59 ++++++ src/js/src/types.ts | 17 ++ src/js/src/utils.ts | 77 +++++++ src/js/tsconfig.json | 7 + src/reactpy_django/checks.py | 191 +++++++++++++++++- src/reactpy_django/config.py | 24 ++- ...05_alter_componentsession_last_accessed.py | 17 ++ src/reactpy_django/models.py | 2 +- .../templates/reactpy/component.html | 9 +- src/reactpy_django/templatetags/reactpy.py | 11 +- src/reactpy_django/types.py | 4 +- src/reactpy_django/utils.py | 26 +-- src/reactpy_django/websocket/consumer.py | 59 ++++-- tests/test_app/tests/test_database.py | 14 +- 29 files changed, 672 insertions(+), 227 deletions(-) create mode 100644 src/js/src/client.ts delete mode 100644 src/js/src/index.js create mode 100644 src/js/src/index.ts create mode 100644 src/js/src/types.ts create mode 100644 src/js/src/utils.ts create mode 100644 src/js/tsconfig.json create mode 100644 src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml index 4439cb5e..09be8866 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-py.yml @@ -4,33 +4,33 @@ name: Publish Python on: - release: - types: [published] + release: + types: [published] jobs: - release-package: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: "14.x" - - name: Set up Python - uses: actions/setup-python@v1 - with: - python-version: "3.x" - - name: Install NPM - run: | - npm install -g npm@7.22.0 - npm --version - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements/build-pkg.txt - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python -m build --sdist --wheel --outdir dist . - twine upload dist/* + release-package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: "20.x" + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: "3.x" + - name: Install NPM + run: | + npm install -g npm@latest + npm --version + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements/build-pkg.txt + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python -m build --sdist --wheel --outdir dist . + twine upload dist/* diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml index b58cb0ed..a0c8861d 100644 --- a/.github/workflows/publish-release-docs.yml +++ b/.github/workflows/publish-release-docs.yml @@ -20,4 +20,3 @@ jobs: git config user.name github-actions git config user.email github-actions@github.com mike deploy --push --update-aliases ${{ github.event.release.name }} latest - mike set-default --push latest diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index f945ea9d..4a03c49c 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "14.x" + node-version: "20.x" - name: Use Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 013688c7..da060bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,11 @@ Using the following categories, list your changes in this order: ### Added +- More customization for reconnection behavior through new settings! + - `REACTPY_RECONNECT_INTERVAL` + - `REACTPY_RECONNECT_MAX_INTERVAL` + - `REACTPY_RECONNECT_MAX_RETRIES` + - `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` - [ReactPy-Django docs](https://reactive-python.github.io/reactpy-django/) are now version controlled via [mike](https://github.com/jimporter/mike)! ### Changed @@ -43,6 +48,14 @@ Using the following categories, list your changes in this order: - Bumped the minimum ReactPy version to `1.0.2`. - Prettier websocket URLs for components that do not have sessions. - Template tag will now only validate `args`/`kwargs` if `settings.py:DEBUG` is enabled. +- Bumped the minimum `@reactpy/client` version to `0.3.1` +- Use TypeScript instead of JavaScript for this repository. +- Bumped minimum Django version to `4.2`. + - Note: ReactPy-Django will continue bumping minimum Django requirements to versions that increase async support. This "latest-only" trend will continue until Django has all async features that ReactPy benefits from. After this point, ReactPy-Django will begin supporting all maintained Django versions. + +### Removed + +- `settings.py:REACTPY_RECONNECT_MAX` is removed. See the docs for the new `REACTPY_RECONNECT_*` settings. ## [3.4.0] - 2023-08-18 diff --git a/docs/src/contribute/code.md b/docs/src/contribute/code.md index cc5b8e58..a5b52955 100644 --- a/docs/src/contribute/code.md +++ b/docs/src/contribute/code.md @@ -45,7 +45,7 @@ cd tests python manage.py runserver ``` -Navigate to `http://127.0.0.1:8000` to see if the tests are rendering correctly. +Navigate to [`http://127.0.0.1:8000`](http://127.0.0.1:8000) to see if the tests are rendering correctly. ## GitHub Pull Request diff --git a/docs/src/contribute/docs.md b/docs/src/contribute/docs.md index 33b83fb7..913bc0a6 100644 --- a/docs/src/contribute/docs.md +++ b/docs/src/contribute/docs.md @@ -37,7 +37,7 @@ Finally, to verify that everything is working properly, you can manually run the mkdocs serve ``` -Navigate to `http://127.0.0.1:8000` to view a preview of the documentation. +Navigate to [`http://127.0.0.1:8000`](http://127.0.0.1:8000) to view a preview of the documentation. ## GitHub Pull Request diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md index 8cf11815..00662976 100644 --- a/docs/src/features/settings.md +++ b/docs/src/features/settings.md @@ -24,11 +24,15 @@ These are ReactPy-Django's default settings values. You can modify these values | --- | --- | --- | --- | | `REACTPY_CACHE` | `#!python "default"` | `#!python "my-reactpy-cache"` | Cache used to store ReactPy web modules. ReactPy benefits from a fast, well indexed cache.
We recommend installing [`redis`](https://redis.io/) or [`python-diskcache`](https://grantjenks.com/docs/diskcache/tutorial.html#djangocache). | | `REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Database used to store ReactPy session data. ReactPy requires a multiprocessing-safe and thread-safe database.
If configuring `REACTPY_DATABASE`, it is mandatory to use our database router like such:
`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` | -| `REACTPY_RECONNECT_MAX` | `#!python 259200` | `#!python 96000`, `#!python 60`, `#!python 0` | Maximum seconds between reconnection attempts before giving up.
Use `#!python 0` to prevent reconnection. | +| `REACTPY_SESSION_MAX_AGE` | `#!python 259200` | `#!python 0`, `#!python 60`, `#!python 96000` | Maximum seconds to store ReactPy session data, such as `args` and `kwargs` passed into your component template tag.
Use `#!python 0` to not store any session data. | | `REACTPY_URL_PREFIX` | `#!python "reactpy/"` | `#!python "rp/"`, `#!python "render/reactpy/"` | The prefix to be used for all ReactPy websocket and HTTP URLs. | | `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python "example_project.my_query_postprocessor"` | Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function. | | `REACTPY_AUTH_BACKEND` | `#!python "django.contrib.auth.backends.ModelBackend"` | `#!python "example_project.auth.MyModelBackend"` | Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:
1. You are using `AuthMiddlewareStack` and...
2. You are using Django's `AUTHENTICATION_BACKENDS` setting and...
3. Your Django user model does not define a `backend` attribute. | | `REACTPY_BACKHAUL_THREAD` | `#!python False` | `#!python True` | Whether to render ReactPy components in a dedicated thread. This allows the webserver to process web traffic while during ReactPy rendering.
Vastly improves throughput with web servers such as `hypercorn` and `uvicorn`. | | `REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir" ]` | The default host(s) that can render your ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing.
You can use the `host` argument in your [template tag](../features/template-tag.md#component) as a manual override. | +| `REACTPY_RECONNECT_INTERVAL` | `#!python 750` | `#!python 100`, `#!python 2500`, `#!python 6000` | Milliseconds between client reconnection attempts. This value will gradually increase if `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` is greater than `#!python 1`. | +| `REACTPY_RECONNECT_MAX_INTERVAL` | `#!python 60000` | `#!python 10000`, `#!python 25000`, `#!python 900000` | Maximum milliseconds between client reconnection attempts. This allows setting an upper bound on how high `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` can increase the time between reconnection attempts. | +| `REACTPY_RECONNECT_MAX_RETRIES` | `#!python 150` | `#!python 0`, `#!python 5`, `#!python 300` | Maximum number of reconnection attempts before the client gives up. | +| `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` | `#!python 1.25` | `#!python 1`, `#!python 1.5`, `#!python 3` | Multiplier for the time between client reconnection attempts. On each reconnection attempt, the `REACTPY_RECONNECT_INTERVAL` will be multiplied by this to increase the time between attempts. You can keep time between each reconnection the same by setting this to `#!python 1`. | diff --git a/docs/src/get-started/run-webserver.md b/docs/src/get-started/run-webserver.md index cefeafc3..cb4f87f1 100644 --- a/docs/src/get-started/run-webserver.md +++ b/docs/src/get-started/run-webserver.md @@ -16,7 +16,7 @@ To test your new Django view, run the following command to start up a developmen python manage.py runserver ``` -Now you can navigate to your **Django project** URL that contains a ReactPy component, such as `http://127.0.0.1:8000/example/` ([_from the previous step_](./register-view.md)). +Now you can navigate to your **Django project** URL that contains a ReactPy component, such as [`http://127.0.0.1:8000/example/`](http://127.0.0.1:8000/example/) ([_from the previous step_](./register-view.md)). If you copy-pasted our example component, you will now see your component display "Hello World". diff --git a/pyproject.toml b/pyproject.toml index 0f9e87a3..250a64a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ warn_unused_configs = true warn_redundant_casts = true warn_unused_ignores = true check_untyped_defs = true -incremental = true [tool.ruff.isort] known-first-party = ["src", "tests"] diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index ee7156c3..8eecf7bc 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,5 +1,5 @@ channels >=4.0.0 -django >=4.1.0 +django >=4.2.0 reactpy >=1.0.2, <1.1.0 aiofile >=3.0 dill >=0.3.5 diff --git a/src/js/package-lock.json b/src/js/package-lock.json index 024084a6..84321d16 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -5,14 +5,16 @@ "packages": { "": { "dependencies": { - "@reactpy/client": "^0.1.0" + "@reactpy/client": "^0.3.1", + "@rollup/plugin-typescript": "^11.1.2", + "tslib": "^2.6.2" }, "devDependencies": { "@rollup/plugin-commonjs": "^24.0.1", "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-replace": "^5.0.2", - "prettier": "^2.8.3", - "rollup": "^3.12.0" + "prettier": "^3.0.2", + "rollup": "^3.28.1" } }, "node_modules/@jridgewell/sourcemap-codec": { @@ -22,16 +24,16 @@ "dev": true }, "node_modules/@reactpy/client": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.1.0.tgz", - "integrity": "sha512-GVsP23Re29JAbLNOBJytcem8paNhLj+2SZ8n9GlnlHPWuV6chAofT0aGveepCj1I9DdeVfRjDL6hfTreJEaDdg==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.3.1.tgz", + "integrity": "sha512-mvFwAvmRMgo7lTjkhkEJzBep6HX/wfm5BaNbtEMOUzto7G/h+z1AmqlOMXLH37DSI0iwfmCuNwy07EJM0JWZ0g==", "dependencies": { - "htm": "^3.0.3", + "event-to-object": "^0.1.2", "json-pointer": "^0.6.2" }, "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" + "react": ">=16 <18", + "react-dom": ">=16 <18" } }, "node_modules/@rollup/plugin-commonjs": { @@ -105,11 +107,35 @@ } } }, + "node_modules/@rollup/plugin-typescript": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.2.tgz", + "integrity": "sha512-0ghSOCMcA7fl1JM+0gYRf+Q/HWyg+zg7/gDSc+fRLmlJWcW5K1I+CLRzaRhXf4Y3DRyPnnDo4M2ktw+a6JcDEg==", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, "node_modules/@rollup/pluginutils": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", - "dev": true, "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", @@ -130,8 +156,7 @@ "node_modules/@types/estree": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", - "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==", - "dev": true + "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==" }, "node_modules/@types/resolve": { "version": "1.20.2", @@ -184,8 +209,15 @@ "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/event-to-object": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/event-to-object/-/event-to-object-0.1.2.tgz", + "integrity": "sha512-+fUmp1XOCZiYomwe5Zxp4IlchuZZfdVdjFUk5MbgRT4M+V2TEWKc0jJwKLCX/nxlJ6xM5VUb/ylzERh7YDCRrg==", + "dependencies": { + "json-pointer": "^0.6.2" + } }, "node_modules/foreach": { "version": "2.0.6", @@ -215,8 +247,7 @@ "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "node_modules/glob": { "version": "8.1.0", @@ -241,7 +272,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "dependencies": { "function-bind": "^1.1.1" }, @@ -249,11 +279,6 @@ "node": ">= 0.4.0" } }, - "node_modules/htm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", - "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -289,7 +314,6 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dev": true, "dependencies": { "has": "^1.0.3" }, @@ -383,14 +407,12 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -399,15 +421,15 @@ } }, "node_modules/prettier": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.3.tgz", - "integrity": "sha512-tJ/oJ4amDihPoufT5sM0Z1SKEuKay8LfVAMlbbhnnkvt6BUserZylqo2PN+p9KeljLr0OHa2rXHU1T8reeoTrw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz", + "integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==", "dev": true, "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" @@ -444,7 +466,6 @@ "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, "dependencies": { "is-core-module": "^2.9.0", "path-parse": "^1.0.7", @@ -458,10 +479,10 @@ } }, "node_modules/rollup": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.12.0.tgz", - "integrity": "sha512-4MZ8kA2HNYahIjz63rzrMMRvDqQDeS9LoriJvMuV0V6zIGysP36e9t4yObUfwdT9h/szXoHQideICftcdZklWg==", - "dev": true, + "version": "3.28.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.28.1.tgz", + "integrity": "sha512-R9OMQmIHJm9znrU3m3cpE8uhN0fGdXiawME7aZIpQqvpS/85+Vt1Hq1/yVIcYfOmaQiHjvXkQAoJukvLpau6Yw==", + "devOptional": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -487,7 +508,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -495,6 +515,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -510,11 +548,11 @@ "dev": true }, "@reactpy/client": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.1.0.tgz", - "integrity": "sha512-GVsP23Re29JAbLNOBJytcem8paNhLj+2SZ8n9GlnlHPWuV6chAofT0aGveepCj1I9DdeVfRjDL6hfTreJEaDdg==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.3.1.tgz", + "integrity": "sha512-mvFwAvmRMgo7lTjkhkEJzBep6HX/wfm5BaNbtEMOUzto7G/h+z1AmqlOMXLH37DSI0iwfmCuNwy07EJM0JWZ0g==", "requires": { - "htm": "^3.0.3", + "event-to-object": "^0.1.2", "json-pointer": "^0.6.2" } }, @@ -556,11 +594,19 @@ "magic-string": "^0.27.0" } }, + "@rollup/plugin-typescript": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.2.tgz", + "integrity": "sha512-0ghSOCMcA7fl1JM+0gYRf+Q/HWyg+zg7/gDSc+fRLmlJWcW5K1I+CLRzaRhXf4Y3DRyPnnDo4M2ktw+a6JcDEg==", + "requires": { + "@rollup/pluginutils": "^5.0.1", + "resolve": "^1.22.1" + } + }, "@rollup/pluginutils": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", - "dev": true, "requires": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", @@ -570,8 +616,7 @@ "@types/estree": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", - "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==", - "dev": true + "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==" }, "@types/resolve": { "version": "1.20.2", @@ -615,8 +660,15 @@ "estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "event-to-object": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/event-to-object/-/event-to-object-0.1.2.tgz", + "integrity": "sha512-+fUmp1XOCZiYomwe5Zxp4IlchuZZfdVdjFUk5MbgRT4M+V2TEWKc0jJwKLCX/nxlJ6xM5VUb/ylzERh7YDCRrg==", + "requires": { + "json-pointer": "^0.6.2" + } }, "foreach": { "version": "2.0.6", @@ -639,8 +691,7 @@ "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "glob": { "version": "8.1.0", @@ -659,16 +710,10 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "requires": { "function-bind": "^1.1.1" } }, - "htm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", - "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" - }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -698,7 +743,6 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dev": true, "requires": { "has": "^1.0.3" } @@ -777,19 +821,17 @@ "path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" }, "prettier": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.3.tgz", - "integrity": "sha512-tJ/oJ4amDihPoufT5sM0Z1SKEuKay8LfVAMlbbhnnkvt6BUserZylqo2PN+p9KeljLr0OHa2rXHU1T8reeoTrw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz", + "integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==", "dev": true }, "react": { @@ -817,7 +859,6 @@ "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, "requires": { "is-core-module": "^2.9.0", "path-parse": "^1.0.7", @@ -825,10 +866,10 @@ } }, "rollup": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.12.0.tgz", - "integrity": "sha512-4MZ8kA2HNYahIjz63rzrMMRvDqQDeS9LoriJvMuV0V6zIGysP36e9t4yObUfwdT9h/szXoHQideICftcdZklWg==", - "dev": true, + "version": "3.28.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.28.1.tgz", + "integrity": "sha512-R9OMQmIHJm9znrU3m3cpE8uhN0fGdXiawME7aZIpQqvpS/85+Vt1Hq1/yVIcYfOmaQiHjvXkQAoJukvLpau6Yw==", + "devOptional": true, "requires": { "fsevents": "~2.3.2" } @@ -846,8 +887,18 @@ "supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" + }, + "tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "peer": true }, "wrappy": { "version": "1.0.2", diff --git a/src/js/package.json b/src/js/package.json index 92890671..40596a0d 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -1,6 +1,6 @@ { "description": "reactpy-django client", - "main": "src/index.js", + "main": "src/index.ts", "type": "module", "files": [ "src/**/*.js" @@ -10,13 +10,15 @@ "format": "prettier --ignore-path .gitignore --write ." }, "devDependencies": { - "prettier": "^2.8.3", - "rollup": "^3.12.0", "@rollup/plugin-commonjs": "^24.0.1", "@rollup/plugin-node-resolve": "^15.0.1", - "@rollup/plugin-replace": "^5.0.2" + "@rollup/plugin-replace": "^5.0.2", + "prettier": "^3.0.2", + "rollup": "^3.28.1" }, "dependencies": { - "@reactpy/client": "^0.1.0" + "@reactpy/client": "^0.3.1", + "@rollup/plugin-typescript": "^11.1.2", + "tslib": "^2.6.2" } } diff --git a/src/js/rollup.config.mjs b/src/js/rollup.config.mjs index 50fbc637..79f93839 100644 --- a/src/js/rollup.config.mjs +++ b/src/js/rollup.config.mjs @@ -1,9 +1,10 @@ import resolve from "@rollup/plugin-node-resolve"; import commonjs from "@rollup/plugin-commonjs"; import replace from "@rollup/plugin-replace"; +import typescript from "@rollup/plugin-typescript"; export default { - input: "src/index.js", + input: "src/index.ts", output: { file: "../reactpy_django/static/reactpy_django/client.js", format: "esm", @@ -14,6 +15,7 @@ export default { replace({ "process.env.NODE_ENV": JSON.stringify("production"), }), + typescript(), ], onwarn: function (warning) { console.warn(warning.message); diff --git a/src/js/src/client.ts b/src/js/src/client.ts new file mode 100644 index 00000000..6f79df77 --- /dev/null +++ b/src/js/src/client.ts @@ -0,0 +1,31 @@ +import { BaseReactPyClient, ReactPyClient, ReactPyModule } from "@reactpy/client"; +import { createReconnectingWebSocket } from "./utils"; +import { ReactPyDjangoClientProps, ReactPyUrls } from "./types"; + +export class ReactPyDjangoClient + extends BaseReactPyClient + implements ReactPyClient +{ + urls: ReactPyUrls; + socket: { current?: WebSocket }; + + constructor(props: ReactPyDjangoClientProps) { + super(); + this.urls = props.urls; + this.socket = createReconnectingWebSocket({ + readyPromise: this.ready, + url: this.urls.componentUrl, + onMessage: async ({ data }) => + this.handleIncoming(JSON.parse(data)), + ...props.reconnectOptions, + }); + } + + sendMessage(message: any): void { + this.socket.current?.send(JSON.stringify(message)); + } + + loadModule(moduleName: string): Promise { + return import(`${this.urls.jsModules}/${moduleName}`); + } +} diff --git a/src/js/src/index.js b/src/js/src/index.js deleted file mode 100644 index 2ee74e07..00000000 --- a/src/js/src/index.js +++ /dev/null @@ -1,59 +0,0 @@ -import { mountLayoutWithWebSocket } from "@reactpy/client"; - -// Set up a websocket at the base endpoint -let HTTP_PROTOCOL = window.location.protocol; -let WS_PROTOCOL = ""; -if (HTTP_PROTOCOL == "https:") { - WS_PROTOCOL = "wss:"; -} else { - WS_PROTOCOL = "ws:"; -} - -export function mountViewToElement( - mountElement, - reactpyHost, - reactpyUrlPrefix, - reactpyReconnectMax, - reactpyComponentPath, - reactpyResolvedWebModulesPath -) { - // Determine the Websocket route - let wsOrigin; - if (reactpyHost) { - wsOrigin = `${WS_PROTOCOL}//${reactpyHost}`; - } else { - wsOrigin = `${WS_PROTOCOL}//${window.location.host}`; - } - const websocketUrl = `${wsOrigin}/${reactpyUrlPrefix}/${reactpyComponentPath}`; - - // Determine the HTTP route - let httpOrigin; - let webModulesPath; - if (reactpyHost) { - httpOrigin = `${HTTP_PROTOCOL}//${reactpyHost}`; - webModulesPath = `${reactpyUrlPrefix}/web_module`; - } else { - httpOrigin = `${HTTP_PROTOCOL}//${window.location.host}`; - if (reactpyResolvedWebModulesPath) { - webModulesPath = reactpyResolvedWebModulesPath; - } else { - webModulesPath = `${reactpyUrlPrefix}/web_module`; - } - } - const webModuleUrl = `${httpOrigin}/${webModulesPath}`; - - // Function that loads the JavaScript web module, if needed - const loadImportSource = (source, sourceType) => { - return import( - sourceType == "NAME" ? `${webModuleUrl}/${source}` : source - ); - }; - - // Start rendering the component - mountLayoutWithWebSocket( - mountElement, - websocketUrl, - loadImportSource, - reactpyReconnectMax - ); -} diff --git a/src/js/src/index.ts b/src/js/src/index.ts new file mode 100644 index 00000000..53d67c6f --- /dev/null +++ b/src/js/src/index.ts @@ -0,0 +1,59 @@ +import { mount } from "@reactpy/client"; +import { ReactPyDjangoClient } from "./client"; + +export function mountComponent( + mountElement: HTMLElement, + host: string, + urlPrefix: string, + componentPath: string, + resolvedJsModulesPath: string, + reconnectStartInterval: number, + reconnectMaxInterval: number, + reconnectMaxRetries: number, + reconnectBackoffMultiplier: number +) { + // Protocols + let httpProtocol = window.location.protocol; + let wsProtocol = `ws${httpProtocol === "https:" ? "s" : ""}:`; + + // WebSocket route (for Python components) + let wsOrigin: string; + if (host) { + wsOrigin = `${wsProtocol}//${host}`; + } else { + wsOrigin = `${wsProtocol}//${window.location.host}`; + } + + // HTTP route (for JavaScript modules) + let httpOrigin: string; + let jsModulesPath: string; + if (host) { + httpOrigin = `${httpProtocol}//${host}`; + jsModulesPath = `${urlPrefix}/web_module`; + } else { + httpOrigin = `${httpProtocol}//${window.location.host}`; + if (resolvedJsModulesPath) { + jsModulesPath = resolvedJsModulesPath; + } else { + jsModulesPath = `${urlPrefix}/web_module`; + } + } + + // Configure a new ReactPy client + const client = new ReactPyDjangoClient({ + urls: { + componentUrl: `${wsOrigin}/${urlPrefix}/${componentPath}`, + query: document.location.search, + jsModules: `${httpOrigin}/${jsModulesPath}`, + }, + reconnectOptions: { + startInterval: reconnectStartInterval, + maxInterval: reconnectMaxInterval, + backoffMultiplier: reconnectBackoffMultiplier, + maxRetries: reconnectMaxRetries, + }, + }); + + // Start rendering the component + mount(mountElement, client); +} diff --git a/src/js/src/types.ts b/src/js/src/types.ts new file mode 100644 index 00000000..54a0b604 --- /dev/null +++ b/src/js/src/types.ts @@ -0,0 +1,17 @@ +export type ReconnectOptions = { + startInterval: number; + maxInterval: number; + maxRetries: number; + backoffMultiplier: number; +} + +export type ReactPyUrls = { + componentUrl: string; + query: string; + jsModules: string; +} + +export type ReactPyDjangoClientProps = { + urls: ReactPyUrls; + reconnectOptions: ReconnectOptions; +} diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts new file mode 100644 index 00000000..a3f653ce --- /dev/null +++ b/src/js/src/utils.ts @@ -0,0 +1,77 @@ +export function createReconnectingWebSocket(props: { + url: string; + readyPromise: Promise; + onOpen?: () => void; + onMessage: (message: MessageEvent) => void; + onClose?: () => void; + startInterval: number; + maxInterval: number; + maxRetries: number; + backoffMultiplier: number; +}) { + const { startInterval, maxInterval, maxRetries, backoffMultiplier } = props; + let retries = 0; + let interval = startInterval; + let everConnected = false; + const closed = false; + const socket: { current?: WebSocket } = {}; + + const connect = () => { + if (closed) { + return; + } + socket.current = new WebSocket(props.url); + socket.current.onopen = () => { + everConnected = true; + console.info("ReactPy connected!"); + interval = startInterval; + retries = 0; + if (props.onOpen) { + props.onOpen(); + } + }; + socket.current.onmessage = props.onMessage; + socket.current.onclose = () => { + if (!everConnected) { + console.info("ReactPy failed to connect!"); + return; + } + console.info("ReactPy disconnected!"); + if (props.onClose) { + props.onClose(); + } + if (retries >= maxRetries) { + console.info("ReactPy connection max retries exhausted!"); + return; + } + console.info( + `ReactPy reconnecting in ${(interval / 1000).toPrecision( + 4 + )} seconds...` + ); + setTimeout(connect, interval); + interval = nextInterval(interval, backoffMultiplier, maxInterval); + retries++; + }; + }; + + props.readyPromise + .then(() => console.info("Starting ReactPy client...")) + .then(connect); + + return socket; +} + +export function nextInterval( + currentInterval: number, + backoffMultiplier: number, + maxInterval: number +): number { + return Math.min( + currentInterval * + // increase interval by backoff multiplier + backoffMultiplier, + // don't exceed max interval + maxInterval + ); +} diff --git a/src/js/tsconfig.json b/src/js/tsconfig.json new file mode 100644 index 00000000..7da4aa77 --- /dev/null +++ b/src/js/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "esnext", + "moduleResolution": "node", + }, +} diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 7ab9546e..754b81be 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -1,4 +1,5 @@ import contextlib +import math import sys from django.contrib.staticfiles.finders import find @@ -104,7 +105,7 @@ def reactpy_warnings(app_configs, **kwargs): # DELETED W007: Check if REACTPY_WEBSOCKET_URL doesn't end with a slash # DELETED W008: Check if REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character - # Removed Settings + # Removed REACTPY_WEBSOCKET_URL setting if getattr(settings, "REACTPY_WEBSOCKET_URL", None): warnings.append( Warning( @@ -159,6 +160,87 @@ def reactpy_warnings(app_configs, **kwargs): ) ) + # Removed REACTPY_RECONNECT_MAX setting + if getattr(settings, "REACTPY_RECONNECT_MAX", None): + warnings.append( + Warning( + "REACTPY_RECONNECT_MAX has been removed.", + hint="See the docs for the new REACTPY_RECONNECT_* settings.", + id="reactpy_django.W013", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_INTERVAL, int) + and config.REACTPY_RECONNECT_INTERVAL > 30000 + ): + warnings.append( + Warning( + "REACTPY_RECONNECT_INTERVAL is set to >30 seconds. Are you sure this is intentional? " + "This may cause unexpected delays between reconnection.", + hint="Check your value for REACTPY_RECONNECT_INTERVAL or suppress this warning.", + id="reactpy_django.W014", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) + and config.REACTPY_RECONNECT_MAX_RETRIES > 5000 + ): + warnings.append( + Warning( + "REACTPY_RECONNECT_MAX_RETRIES is set to a very large value. Are you sure this is intentional? " + "This may leave your clients attempting reconnections for a long time.", + hint="Check your value for REACTPY_RECONNECT_MAX_RETRIES or suppress this warning.", + id="reactpy_django.W015", + ) + ) + + # Check if the value is too large (greater than 50) + if ( + isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)) + and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER > 100 + ): + warnings.append( + Warning( + "REACTPY_RECONNECT_BACKOFF_MULTIPLIER is set to a very large value. Are you sure this is intentional?", + hint="Check your value for REACTPY_RECONNECT_BACKOFF_MULTIPLIER or suppress this warning.", + id="reactpy_django.W016", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) + and isinstance(config.REACTPY_RECONNECT_INTERVAL, int) + and isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) + and isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)) + and config.REACTPY_RECONNECT_INTERVAL > 0 + and config.REACTPY_RECONNECT_MAX_INTERVAL > 0 + and config.REACTPY_RECONNECT_MAX_RETRIES > 0 + and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER > 1 + and ( + config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER + ** config.REACTPY_RECONNECT_MAX_RETRIES + ) + * config.REACTPY_RECONNECT_INTERVAL + < config.REACTPY_RECONNECT_MAX_INTERVAL + ): + max_value = math.floor( + ( + config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER + ** config.REACTPY_RECONNECT_MAX_RETRIES + ) + * config.REACTPY_RECONNECT_INTERVAL + ) + warnings.append( + Warning( + "Your current ReactPy configuration can never reach REACTPY_RECONNECT_MAX_INTERVAL. At most you will reach " + f"{max_value} miliseconds, which is less than {config.REACTPY_RECONNECT_MAX_INTERVAL} (REACTPY_RECONNECT_MAX_INTERVAL).", + hint="Check your ReactPy REACTPY_RECONNECT_* settings.", + id="reactpy_django.W017", + ) + ) + return warnings @@ -166,6 +248,8 @@ def reactpy_warnings(app_configs, **kwargs): def reactpy_errors(app_configs, **kwargs): from django.conf import settings + from reactpy_django import config + errors = [] # Make sure ASGI is enabled @@ -204,12 +288,12 @@ def reactpy_errors(app_configs, **kwargs): id="reactpy_django.E003", ) ) - if not isinstance(getattr(settings, "REACTPY_RECONNECT_MAX", 0), int): + if not isinstance(getattr(settings, "REACTPY_SESSION_MAX_AGE", 0), int): errors.append( Error( - "Invalid type for REACTPY_RECONNECT_MAX.", - hint="REACTPY_RECONNECT_MAX should be an integer.", - obj=settings.REACTPY_RECONNECT_MAX, + "Invalid type for REACTPY_SESSION_MAX_AGE.", + hint="REACTPY_SESSION_MAX_AGE should be an integer.", + obj=settings.REACTPY_SESSION_MAX_AGE, id="reactpy_django.E004", ) ) @@ -278,4 +362,101 @@ def reactpy_errors(app_configs, **kwargs): ) break + if not isinstance(config.REACTPY_RECONNECT_INTERVAL, int): + errors.append( + Error( + "Invalid type for REACTPY_RECONNECT_INTERVAL.", + hint="REACTPY_RECONNECT_INTERVAL should be an integer.", + id="reactpy_django.E012", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_INTERVAL, int) + and config.REACTPY_RECONNECT_INTERVAL < 0 + ): + errors.append( + Error( + "Invalid value for REACTPY_RECONNECT_INTERVAL.", + hint="REACTPY_RECONNECT_INTERVAL should be a positive integer.", + id="reactpy_django.E013", + ) + ) + + if not isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int): + errors.append( + Error( + "Invalid type for REACTPY_RECONNECT_MAX_INTERVAL.", + hint="REACTPY_RECONNECT_MAX_INTERVAL should be an integer.", + id="reactpy_django.E014", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) + and config.REACTPY_RECONNECT_MAX_INTERVAL < 0 + ): + errors.append( + Error( + "Invalid value for REACTPY_RECONNECT_MAX_INTERVAL.", + hint="REACTPY_RECONNECT_MAX_INTERVAL should be a positive integer.", + id="reactpy_django.E015", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) + and isinstance(config.REACTPY_RECONNECT_INTERVAL, int) + and config.REACTPY_RECONNECT_MAX_INTERVAL < config.REACTPY_RECONNECT_INTERVAL + ): + errors.append( + Error( + "REACTPY_RECONNECT_MAX_INTERVAL is less than REACTPY_RECONNECT_INTERVAL.", + hint="REACTPY_RECONNECT_MAX_INTERVAL should be greater than or equal to REACTPY_RECONNECT_INTERVAL.", + id="reactpy_django.E016", + ) + ) + + if not isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int): + errors.append( + Error( + "Invalid type for REACTPY_RECONNECT_MAX_RETRIES.", + hint="REACTPY_RECONNECT_MAX_RETRIES should be an integer.", + id="reactpy_django.E017", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) + and config.REACTPY_RECONNECT_MAX_RETRIES < 0 + ): + errors.append( + Error( + "Invalid value for REACTPY_RECONNECT_MAX_RETRIES.", + hint="REACTPY_RECONNECT_MAX_RETRIES should be a positive integer.", + id="reactpy_django.E018", + ) + ) + + if not isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)): + errors.append( + Error( + "Invalid type for REACTPY_RECONNECT_BACKOFF_MULTIPLIER.", + hint="REACTPY_RECONNECT_BACKOFF_MULTIPLIER should be an integer or float.", + id="reactpy_django.E019", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)) + and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER < 1 + ): + errors.append( + Error( + "Invalid value for REACTPY_RECONNECT_BACKOFF_MULTIPLIER.", + hint="REACTPY_RECONNECT_BACKOFF_MULTIPLIER should be greater than or equal to 1.", + id="reactpy_django.E020", + ) + ) + return errors diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 24aff5f6..d811f7dc 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -35,9 +35,9 @@ "REACTPY_URL_PREFIX", REACTPY_WEBSOCKET_URL, ).strip("/") -REACTPY_RECONNECT_MAX: int = getattr( +REACTPY_SESSION_MAX_AGE: int = getattr( settings, - "REACTPY_RECONNECT_MAX", + "REACTPY_SESSION_MAX_AGE", 259200, # Default to 3 days ) REACTPY_CACHE: str = getattr( @@ -82,3 +82,23 @@ if _default_hosts else None ) +REACTPY_RECONNECT_INTERVAL: int = getattr( + settings, + "REACTPY_RECONNECT_INTERVAL", + 750, # Default to 0.75 seconds +) +REACTPY_RECONNECT_MAX_INTERVAL: int = getattr( + settings, + "REACTPY_RECONNECT_MAX_INTERVAL", + 60000, # Default to 60 seconds +) +REACTPY_RECONNECT_MAX_RETRIES: int = getattr( + settings, + "REACTPY_RECONNECT_MAX_RETRIES", + 150, +) +REACTPY_RECONNECT_BACKOFF_MULTIPLIER: float | int = getattr( + settings, + "REACTPY_RECONNECT_BACKOFF_MULTIPLIER", + 1.25, # Default to 25% backoff per connection attempt +) diff --git a/src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py b/src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py new file mode 100644 index 00000000..488f660d --- /dev/null +++ b/src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.3 on 2023-08-23 19:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("reactpy_django", "0004_config"), + ] + + operations = [ + migrations.AlterField( + model_name="componentsession", + name="last_accessed", + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py index 65152126..1fa69d2c 100644 --- a/src/reactpy_django/models.py +++ b/src/reactpy_django/models.py @@ -8,7 +8,7 @@ class ComponentSession(models.Model): uuid = models.UUIDField(primary_key=True, editable=False, unique=True) # type: ignore params = models.BinaryField(editable=False) # type: ignore - last_accessed = models.DateTimeField(auto_now_add=True) # type: ignore + last_accessed = models.DateTimeField(auto_now=True) # type: ignore class Config(models.Model): diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html index 4010b80f..75a65dbe 100644 --- a/src/reactpy_django/templates/reactpy/component.html +++ b/src/reactpy_django/templates/reactpy/component.html @@ -6,15 +6,18 @@ {% else %}
{% endif %} diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index b174fa9a..82640ced 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -15,7 +15,7 @@ ComponentParamError, InvalidHostError, ) -from reactpy_django.types import ComponentParamData +from reactpy_django.types import ComponentParams from reactpy_django.utils import validate_component_args try: @@ -83,7 +83,7 @@ def component( _logger.error(msg) return failure_context(dotted_path, ComponentDoesNotExistError(msg)) - # Validate the component + # Validate the component args & kwargs if is_local and config.REACTPY_DEBUG_MODE: try: validate_component_args(user_component, *args, **kwargs) @@ -108,11 +108,14 @@ def component( "reactpy_uuid": uuid, "reactpy_host": host or perceived_host, "reactpy_url_prefix": config.REACTPY_URL_PREFIX, - "reactpy_reconnect_max": config.REACTPY_RECONNECT_MAX, "reactpy_component_path": f"{dotted_path}/{uuid}/" if component_has_args else f"{dotted_path}/", "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH, + "reactpy_reconnect_interval": config.REACTPY_RECONNECT_INTERVAL, + "reactpy_reconnect_max_interval": config.REACTPY_RECONNECT_MAX_INTERVAL, + "reactpy_reconnect_backoff_multiplier": config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, + "reactpy_reconnect_max_retries": config.REACTPY_RECONNECT_MAX_RETRIES, } @@ -126,7 +129,7 @@ def failure_context(dotted_path: str, error: Exception): def save_component_params(args, kwargs, uuid): - params = ComponentParamData(args, kwargs) + params = ComponentParams(args, kwargs) model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) model.full_clean() model.save() diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 02fdec6e..ac6205e0 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -33,7 +33,7 @@ "SyncPostprocessor", "QueryOptions", "MutationOptions", - "ComponentParamData", + "ComponentParams", ] _Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]]) @@ -127,7 +127,7 @@ class MutationOptions: @dataclass -class ComponentParamData: +class ComponentParams: """Container used for serializing component parameters. This dataclass is pickled & stored in the database, then unpickled when needed.""" diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 0776bc3b..755b3a05 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -324,29 +324,31 @@ def create_cache_key(*args): return f"reactpy_django:{':'.join(str(arg) for arg in args)}" -def db_cleanup(immediate: bool = False): +def delete_expired_sessions(immediate: bool = False): """Deletes expired component sessions from the database. - This function may be expanded in the future to include additional cleanup tasks.""" - from .config import REACTPY_DEBUG_MODE, REACTPY_RECONNECT_MAX + As a performance optimization, this is only run once every REACTPY_SESSION_MAX_AGE seconds. + """ + from .config import REACTPY_DEBUG_MODE, REACTPY_SESSION_MAX_AGE from .models import ComponentSession, Config config = Config.load() start_time = timezone.now() cleaned_at = config.cleaned_at - clean_needed_by = cleaned_at + timedelta(seconds=REACTPY_RECONNECT_MAX) + clean_needed_by = cleaned_at + timedelta(seconds=REACTPY_SESSION_MAX_AGE) # Delete expired component parameters if immediate or timezone.now() >= clean_needed_by: - expiration_date = timezone.now() - timedelta(seconds=REACTPY_RECONNECT_MAX) + expiration_date = timezone.now() - timedelta(seconds=REACTPY_SESSION_MAX_AGE) ComponentSession.objects.filter(last_accessed__lte=expiration_date).delete() config.cleaned_at = timezone.now() config.save() # Check if cleaning took abnormally long - clean_duration = timezone.now() - start_time - if REACTPY_DEBUG_MODE and clean_duration.total_seconds() > 1: - _logger.warning( - "ReactPy has taken %s seconds to clean up expired component sessions. " - "This may indicate a performance issue with your system, cache, or database.", - clean_duration.total_seconds(), - ) + if REACTPY_DEBUG_MODE: + clean_duration = timezone.now() - start_time + if clean_duration.total_seconds() > 1: + _logger.warning( + "ReactPy has taken %s seconds to clean up expired component sessions. " + "This may indicate a performance issue with your system, cache, or database.", + clean_duration.total_seconds(), + ) diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index a2a76cab..c6a47c27 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -21,8 +21,8 @@ from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy_django.types import ComponentParamData, ComponentWebsocket -from reactpy_django.utils import db_cleanup +from reactpy_django.types import ComponentParams, ComponentWebsocket +from reactpy_django.utils import delete_expired_sessions _logger = logging.getLogger(__name__) backhaul_loop = asyncio.new_event_loop() @@ -42,6 +42,7 @@ class ReactpyAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer): async def connect(self) -> None: """The browser has connected.""" + from reactpy_django import models from reactpy_django.config import REACTPY_AUTH_BACKEND, REACTPY_BACKHAUL_THREAD await super().connect() @@ -80,6 +81,7 @@ async def connect(self) -> None: # Start the component dispatcher self.dispatcher: Future | asyncio.Task self.threaded = REACTPY_BACKHAUL_THREAD + self.component_session: models.ComponentSession | None = None if self.threaded: if not backhaul_thread.is_alive(): await asyncio.to_thread( @@ -95,6 +97,28 @@ async def connect(self) -> None: async def disconnect(self, code: int) -> None: """The browser has disconnected.""" self.dispatcher.cancel() + + if self.component_session: + # Clean up expired component sessions + try: + await database_sync_to_async( + delete_expired_sessions, thread_sensitive=False + )() + except Exception: + await asyncio.to_thread( + _logger.exception, + "ReactPy has failed to delete expired component sessions!", + ) + + # Update the last_accessed timestamp + try: + await self.component_session.asave() + except Exception: + await asyncio.to_thread( + _logger.exception, + "ReactPy has failed to save component session!", + ) + await super().disconnect(code) async def receive_json(self, content: Any, **_) -> None: @@ -118,8 +142,8 @@ async def run_dispatcher(self): """Runs the main loop that performs component rendering tasks.""" from reactpy_django import models from reactpy_django.config import ( - REACTPY_RECONNECT_MAX, REACTPY_REGISTERED_COMPONENTS, + REACTPY_SESSION_MAX_AGE, ) scope = self.scope @@ -136,8 +160,8 @@ async def run_dispatcher(self): carrier=ComponentWebsocket(self.close, self.disconnect, dotted_path), ) now = timezone.now() - component_args: Sequence[Any] = () - component_kwargs: MutableMapping[str, Any] = {} + component_session_args: Sequence[Any] = () + component_session_kwargs: MutableMapping[str, Any] = {} # Verify the component has already been registered try: @@ -152,31 +176,24 @@ async def run_dispatcher(self): # Fetch the component's args/kwargs from the database, if needed try: if uuid: - # Always clean up expired entries first - await database_sync_to_async(db_cleanup, thread_sensitive=False)() - - # Get the queries from a DB - params_query = await models.ComponentSession.objects.aget( + # Get the component session from the DB + self.component_session = await models.ComponentSession.objects.aget( uuid=uuid, - last_accessed__gt=now - timedelta(seconds=REACTPY_RECONNECT_MAX), + last_accessed__gt=now - timedelta(seconds=REACTPY_SESSION_MAX_AGE), ) - params_query.last_accessed = timezone.now() - await database_sync_to_async( - params_query.save, thread_sensitive=False - )() - component_params: ComponentParamData = pickle.loads(params_query.params) - component_args = component_params.args - component_kwargs = component_params.kwargs + params: ComponentParams = pickle.loads(self.component_session.params) + component_session_args = params.args + component_session_kwargs = params.kwargs # Generate the initial component instance component_instance = component_constructor( - *component_args, **component_kwargs + *component_session_args, **component_session_kwargs ) except models.ComponentSession.DoesNotExist: await asyncio.to_thread( _logger.warning, f"Component session for '{dotted_path}:{uuid}' not found. The " - "session may have already expired beyond REACTPY_RECONNECT_MAX. " + "session may have already expired beyond REACTPY_SESSION_MAX_AGE. " "If you are using a custom host, you may have forgotten to provide " "args/kwargs.", ) @@ -185,7 +202,7 @@ async def run_dispatcher(self): await asyncio.to_thread( _logger.exception, f"Failed to construct component {component_constructor} " - f"with parameters {component_kwargs}", + f"with args='{component_session_args}' kwargs='{component_session_kwargs}'!", ) return diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py index 3bd23527..0c9a1b84 100644 --- a/tests/test_app/tests/test_database.py +++ b/tests/test_app/tests/test_database.py @@ -6,7 +6,7 @@ from django.test import TransactionTestCase from reactpy_django import utils from reactpy_django.models import ComponentSession -from reactpy_django.types import ComponentParamData +from reactpy_django.types import ComponentParams class RoutedDatabaseTests(TransactionTestCase): @@ -15,7 +15,7 @@ class RoutedDatabaseTests(TransactionTestCase): @classmethod def setUpClass(cls): super().setUpClass() - utils.db_cleanup(immediate=True) + utils.delete_expired_sessions(immediate=True) def test_component_params(self): # Make sure the ComponentParams table is empty @@ -31,15 +31,15 @@ def test_component_params(self): # Force `params_1` to expire from reactpy_django import config - config.REACTPY_RECONNECT_MAX = 1 - sleep(config.REACTPY_RECONNECT_MAX + 0.1) + config.REACTPY_SESSION_MAX_AGE = 1 + sleep(config.REACTPY_SESSION_MAX_AGE + 0.1) # Create a new, non-expired component params params_2 = self._save_params_to_db(2) self.assertEqual(ComponentSession.objects.count(), 2) # Delete the first component params based on expiration time - utils.db_cleanup() # Don't use `immediate` to test cache timestamping logic + utils.delete_expired_sessions() # Don't use `immediate` to test timestamping logic # Make sure `params_1` has expired self.assertEqual(ComponentSession.objects.count(), 1) @@ -47,9 +47,9 @@ def test_component_params(self): pickle.loads(ComponentSession.objects.first().params), params_2 # type: ignore ) - def _save_params_to_db(self, value: Any) -> ComponentParamData: + def _save_params_to_db(self, value: Any) -> ComponentParams: db = list(self.databases)[0] - param_data = ComponentParamData((value,), {"test_value": value}) + param_data = ComponentParams((value,), {"test_value": value}) model = ComponentSession(uuid4().hex, params=pickle.dumps(param_data)) model.clean_fields() model.clean() From ceeead668a5e93b471d0c0dd3eb561a4ec5aba13 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 26 Aug 2023 21:23:50 -0700 Subject: [PATCH 08/68] v3.5.0 (#179) --- .mailmap | 2 ++ CHANGELOG.md | 9 +++++++-- mkdocs.yml | 1 + src/reactpy_django/__init__.py | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 .mailmap diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000..1e68fb67 --- /dev/null +++ b/.mailmap @@ -0,0 +1,2 @@ +# .mailmap +Mark Bakhit <16909269+archmonger@users.noreply.github.com> diff --git a/CHANGELOG.md b/CHANGELOG.md index da060bbd..f41b7389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ Using the following categories, list your changes in this order: ## [Unreleased] +- Nothing (yet)! + +## [3.5.0] - 2023-08-26 + ### Added - More customization for reconnection behavior through new settings! @@ -49,8 +53,8 @@ Using the following categories, list your changes in this order: - Prettier websocket URLs for components that do not have sessions. - Template tag will now only validate `args`/`kwargs` if `settings.py:DEBUG` is enabled. - Bumped the minimum `@reactpy/client` version to `0.3.1` +- Bumped the minimum Django version to `4.2`. - Use TypeScript instead of JavaScript for this repository. -- Bumped minimum Django version to `4.2`. - Note: ReactPy-Django will continue bumping minimum Django requirements to versions that increase async support. This "latest-only" trend will continue until Django has all async features that ReactPy benefits from. After this point, ReactPy-Django will begin supporting all maintained Django versions. ### Removed @@ -383,7 +387,8 @@ Using the following categories, list your changes in this order: - Support for IDOM within the Django -[unreleased]: https://github.com/reactive-python/reactpy-django/compare/3.4.0...HEAD +[unreleased]: https://github.com/reactive-python/reactpy-django/compare/3.5.0...HEAD +[3.5.0]: https://github.com/reactive-python/reactpy-django/compare/3.4.0...3.5.0 [3.4.0]: https://github.com/reactive-python/reactpy-django/compare/3.3.2...3.4.0 [3.3.2]: https://github.com/reactive-python/reactpy-django/compare/3.3.1...3.3.2 [3.3.1]: https://github.com/reactive-python/reactpy-django/compare/3.3.0...3.3.1 diff --git a/mkdocs.yml b/mkdocs.yml index e5b08921..ae5129bc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -103,6 +103,7 @@ watch: - mkdocs.yml - README.md - CHANGELOG.md + - .mailmap site_name: ReactPy-Django site_author: Archmonger diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index 7458595f..d5719f97 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -8,7 +8,7 @@ REACTPY_WEBSOCKET_ROUTE, ) -__version__ = "3.4.0" +__version__ = "3.5.0" __all__ = [ "REACTPY_WEBSOCKET_PATH", "REACTPY_WEBSOCKET_ROUTE", From 7a3657ad3710814ac466b728b41ca662f9e01856 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 29 Aug 2023 21:21:33 -0700 Subject: [PATCH 09/68] Warn if `INSTALLED_APPS['reactpy_django']` position looks suspicious (#181) --- CHANGELOG.md | 4 +++- README.md | 2 +- src/reactpy_django/checks.py | 24 +++++++++++++++++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f41b7389..80b9d021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,9 @@ Using the following categories, list your changes in this order: ## [Unreleased] -- Nothing (yet)! +### Added + +- Warning W018 (`Suspicious position of 'reactpy_django' in INSTALLED_APPS`) has been added. ## [3.5.0] - 2023-08-26 diff --git a/README.md b/README.md index 281290e3..8e05e247 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Follow the links below to find out more about this project. - [Documentation](https://reactive-python.github.io/reactpy-django) - [GitHub Discussions](https://github.com/reactive-python/reactpy-django/discussions) - [Discord](https://discord.gg/uNb5P4hA9X) -- [Contributor Guide](https://reactive-python.github.io/reactpy-django/contribute/code/) +- [Contributor Guide](https://reactive-python.github.io/reactpy-django/latest/contribute/code/) - [Code of Conduct](https://github.com/reactive-python/reactpy-django/blob/main/CODE_OF_CONDUCT.md) diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 754b81be..e7d320d6 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -17,6 +17,7 @@ def reactpy_warnings(app_configs, **kwargs): from reactpy_django.config import REACTPY_FAILED_COMPONENTS warnings = [] + INSTALLED_APPS: list[str] = getattr(settings, "INSTALLED_APPS", []) # REACTPY_DATABASE is not an in-memory database. if ( @@ -53,10 +54,7 @@ def reactpy_warnings(app_configs, **kwargs): # Warn if REACTPY_BACKHAUL_THREAD is set to True with Daphne if ( sys.argv[0].endswith("daphne") - or ( - "runserver" in sys.argv - and "daphne" in getattr(settings, "INSTALLED_APPS", []) - ) + or ("runserver" in sys.argv and "daphne" in INSTALLED_APPS) ) and getattr(settings, "REACTPY_BACKHAUL_THREAD", False): warnings.append( Warning( @@ -72,7 +70,7 @@ def reactpy_warnings(app_configs, **kwargs): warnings.append( Warning( "ReactPy client.js could not be found within Django static files!", - hint="Check your Django static file configuration.", + hint="Check all static files related Django settings and INSTALLED_APPS.", id="reactpy_django.W004", ) ) @@ -241,6 +239,22 @@ def reactpy_warnings(app_configs, **kwargs): ) ) + position_to_beat = 0 + for app in INSTALLED_APPS: + if app.startswith("django.contrib."): + position_to_beat = INSTALLED_APPS.index(app) + if ( + "reactpy_django" in INSTALLED_APPS + and INSTALLED_APPS.index("reactpy_django") < position_to_beat + ): + warnings.append( + Warning( + "The position of 'reactpy_django' in INSTALLED_APPS is suspicious.", + hint="Move 'reactpy_django' below all 'django.contrib.*' apps, or suppress this warning.", + id="reactpy_django.W018", + ) + ) + return warnings From 82c80c38dc25461da2b7aef1de89453fa686491a Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 29 Aug 2023 23:16:44 -0700 Subject: [PATCH 10/68] Allow settings.py postprocessor to be `None` (#182) --- CHANGELOG.md | 4 ++++ docs/src/features/settings.md | 2 +- src/reactpy_django/checks.py | 4 ++-- src/reactpy_django/config.py | 19 ++++++++++++------- src/reactpy_django/types.py | 4 ++-- tests/test_app/components.py | 15 +++++++++++++++ tests/test_app/templates/base.html | 3 +++ tests/test_app/tests/test_components.py | 5 +++++ 8 files changed, 44 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b9d021..40f29365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ Using the following categories, list your changes in this order: - Warning W018 (`Suspicious position of 'reactpy_django' in INSTALLED_APPS`) has been added. +### Changed + +- The default postprocessor can now disabled by setting `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` to `None`. + ## [3.5.0] - 2023-08-26 ### Added diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md index 00662976..3917d766 100644 --- a/docs/src/features/settings.md +++ b/docs/src/features/settings.md @@ -26,7 +26,7 @@ These are ReactPy-Django's default settings values. You can modify these values | `REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Database used to store ReactPy session data. ReactPy requires a multiprocessing-safe and thread-safe database.
If configuring `REACTPY_DATABASE`, it is mandatory to use our database router like such:
`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` | | `REACTPY_SESSION_MAX_AGE` | `#!python 259200` | `#!python 0`, `#!python 60`, `#!python 96000` | Maximum seconds to store ReactPy session data, such as `args` and `kwargs` passed into your component template tag.
Use `#!python 0` to not store any session data. | | `REACTPY_URL_PREFIX` | `#!python "reactpy/"` | `#!python "rp/"`, `#!python "render/reactpy/"` | The prefix to be used for all ReactPy websocket and HTTP URLs. | -| `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python "example_project.my_query_postprocessor"` | Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function. | +| `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python None`, `#!python "example_project.my_query_postprocessor"` | Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function. Postprocessor functions can be async or sync, and the parameters must contain the arg `#!python data`. Set `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` to `#!python None` to globally disable the default postprocessor. | | `REACTPY_AUTH_BACKEND` | `#!python "django.contrib.auth.backends.ModelBackend"` | `#!python "example_project.auth.MyModelBackend"` | Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:
1. You are using `AuthMiddlewareStack` and...
2. You are using Django's `AUTHENTICATION_BACKENDS` setting and...
3. Your Django user model does not define a `backend` attribute. | | `REACTPY_BACKHAUL_THREAD` | `#!python False` | `#!python True` | Whether to render ReactPy components in a dedicated thread. This allows the webserver to process web traffic while during ReactPy rendering.
Vastly improves throughput with web servers such as `hypercorn` and `uvicorn`. | | `REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir" ]` | The default host(s) that can render your ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing.
You can use the `host` argument in your [template tag](../features/template-tag.md#component) as a manual override. | diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index e7d320d6..adc437d0 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -330,12 +330,12 @@ def reactpy_errors(app_configs, **kwargs): ) ) if not isinstance( - getattr(settings, "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", ""), str + getattr(settings, "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", ""), (str, type(None)) ): errors.append( Error( "Invalid type for REACTPY_DEFAULT_QUERY_POSTPROCESSOR.", - hint="REACTPY_DEFAULT_QUERY_POSTPROCESSOR should be a string.", + hint="REACTPY_DEFAULT_QUERY_POSTPROCESSOR should be a string or None.", obj=settings.REACTPY_DEFAULT_QUERY_POSTPROCESSOR, id="reactpy_django.E007", ) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index d811f7dc..dc350e2a 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -53,15 +53,20 @@ _default_query_postprocessor = getattr( settings, "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", - None, + "UNSET", ) -REACTPY_DEFAULT_QUERY_POSTPROCESSOR: AsyncPostprocessor | SyncPostprocessor | None = ( - import_dotted_path( - _default_query_postprocessor - if isinstance(_default_query_postprocessor, str) - else "reactpy_django.utils.django_query_postprocessor", +REACTPY_DEFAULT_QUERY_POSTPROCESSOR: AsyncPostprocessor | SyncPostprocessor | None +if _default_query_postprocessor is None: + REACTPY_DEFAULT_QUERY_POSTPROCESSOR = None +else: + REACTPY_DEFAULT_QUERY_POSTPROCESSOR = import_dotted_path( + "reactpy_django.utils.django_query_postprocessor" + if ( + _default_query_postprocessor == "UNSET" + or not isinstance(_default_query_postprocessor, str) + ) + else _default_query_postprocessor ) -) REACTPY_AUTH_BACKEND: str | None = getattr( settings, "REACTPY_AUTH_BACKEND", diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index ac6205e0..233fe432 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -105,9 +105,9 @@ class QueryOptions: are optional `postprocessor_kwargs` (see below). This postprocessor function must return the modified `data`. - If `None`, the default postprocessor is used. + If unset, REACTPY_DEFAULT_QUERY_POSTPROCESSOR is used. - This default Django query postprocessor prevents Django's lazy query execution, and + ReactPy's default django_query_postprocessor prevents Django's lazy query execution, and additionally can be configured via `postprocessor_kwargs` to recursively fetch `many_to_many` and `many_to_one` fields.""" diff --git a/tests/test_app/components.py b/tests/test_app/components.py index d018cd96..ec53c031 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -8,6 +8,7 @@ from django.shortcuts import render from reactpy import component, hooks, html, web from reactpy_django.components import view_to_component +from reactpy_django.types import QueryOptions from test_app.models import ( AsyncForiegnChild, @@ -602,3 +603,17 @@ def custom_host(number=0): }, f"Server Port: {port}", ) + + +@component +def broken_postprocessor_query(): + relational_parent = reactpy_django.hooks.use_query( + QueryOptions(postprocessor=None), get_relational_parent_query + ) + + if not relational_parent.data: + return + + mtm = relational_parent.data.many_to_many.all() + + return html.div(f"This should have failed! Something went wrong: {mtm}") diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index 303e99dd..03dd3ba3 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -91,6 +91,9 @@

ReactPy Test Page


{% component "test_app.components.hello_world" host="https://example.com/" %}

+
+ {% component "test_app.components.broken_postprocessor_query" %}
+
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index e78d8963..d05ab46c 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -370,3 +370,8 @@ def test_invalid_host_error(self): broken_component = self.page.locator("#invalid_host_error") broken_component.wait_for() self.assertIn("InvalidHostError:", broken_component.text_content()) + + def test_broken_postprocessor_query(self): + broken_component = self.page.locator("#broken_postprocessor_query pre") + broken_component.wait_for() + self.assertIn("SynchronousOnlyOperation:", broken_component.text_content()) From 6773c85efe1ac647d2bdb45edfb8f2de8374948d Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Thu, 7 Sep 2023 02:40:50 -0700 Subject: [PATCH 11/68] Even better docs styling (+ new homepage) (#180) --- CHANGELOG.md | 10 +- README.md | 2 - docs/includes/orm.md | 6 +- .../add-interactivity-demo.html | 165 ++++++++ .../home-code-examples/add-interactivity.py | 30 ++ .../home-code-examples/code-block.html | 7 + .../create-user-interfaces-demo.html | 24 ++ .../create-user-interfaces.py | 22 + .../write-components-with-python-demo.html | 65 +++ .../write-components-with-python.py | 15 + docs/overrides/home.html | 132 ++++++ docs/overrides/main.html | 2 +- ...nels.py => configure-channels-asgi-app.py} | 4 - .../configure-channels-installed-app.py | 4 + .../index.md => about/changelog.md} | 0 docs/src/{contribute => about}/code.md | 48 ++- docs/src/{contribute => about}/docs.md | 2 +- docs/src/assets/css/admonition.css | 166 ++++++++ docs/src/assets/css/button.css | 41 ++ docs/src/assets/css/code.css | 111 ++++++ docs/src/assets/css/footer.css | 29 ++ docs/src/assets/css/home.css | 335 ++++++++++++++++ docs/src/assets/css/main.css | 84 ++++ docs/src/assets/css/navbar.css | 163 ++++++++ docs/src/assets/css/sidebar.css | 100 +++++ docs/src/assets/css/table-of-contents.css | 39 ++ docs/src/assets/img/add-interactivity.png | Bin 0 -> 20118 bytes .../src/assets/img/create-user-interfaces.png | Bin 0 -> 12381 bytes .../img/write-components-with-python.png | Bin 0 -> 15412 bytes .../{static/js/extra.js => assets/js/main.js} | 0 docs/src/contribute/running-tests.md | 58 --- docs/src/dictionary.txt | 5 +- docs/src/features/hooks.md | 328 --------------- docs/src/features/settings.md | 38 -- docs/src/features/utils.md | 65 --- docs/src/get-started/choose-django-app.md | 26 -- docs/src/get-started/create-component.md | 40 -- docs/src/get-started/installation.md | 125 ------ docs/src/get-started/learn-more.md | 17 - docs/src/get-started/register-view.md | 41 -- docs/src/get-started/run-webserver.md | 27 -- docs/src/get-started/use-template-tag.md | 27 -- docs/src/index.md | 13 +- .../learn/add-reactpy-to-a-django-project.md | 128 ++++++ docs/src/learn/your-first-component.md | 131 ++++++ .../src/{features => reference}/components.md | 88 ++-- .../src/{features => reference}/decorators.md | 28 +- docs/src/reference/hooks.md | 328 +++++++++++++++ docs/src/reference/settings.md | 38 ++ .../{features => reference}/template-tag.md | 24 +- docs/src/reference/utils.md | 65 +++ docs/src/static/css/extra.css | 376 ------------------ mkdocs.yml | 53 +-- src/reactpy_django/checks.py | 2 +- tests/test_app/settings.py | 2 +- 55 files changed, 2381 insertions(+), 1298 deletions(-) create mode 100644 docs/overrides/home-code-examples/add-interactivity-demo.html create mode 100644 docs/overrides/home-code-examples/add-interactivity.py create mode 100644 docs/overrides/home-code-examples/code-block.html create mode 100644 docs/overrides/home-code-examples/create-user-interfaces-demo.html create mode 100644 docs/overrides/home-code-examples/create-user-interfaces.py create mode 100644 docs/overrides/home-code-examples/write-components-with-python-demo.html create mode 100644 docs/overrides/home-code-examples/write-components-with-python.py create mode 100644 docs/overrides/home.html rename docs/python/{configure-channels.py => configure-channels-asgi-app.py} (55%) create mode 100644 docs/python/configure-channels-installed-app.py rename docs/src/{changelog/index.md => about/changelog.md} (100%) rename docs/src/{contribute => about}/code.md (56%) rename docs/src/{contribute => about}/docs.md (96%) create mode 100644 docs/src/assets/css/admonition.css create mode 100644 docs/src/assets/css/button.css create mode 100644 docs/src/assets/css/code.css create mode 100644 docs/src/assets/css/footer.css create mode 100644 docs/src/assets/css/home.css create mode 100644 docs/src/assets/css/main.css create mode 100644 docs/src/assets/css/navbar.css create mode 100644 docs/src/assets/css/sidebar.css create mode 100644 docs/src/assets/css/table-of-contents.css create mode 100644 docs/src/assets/img/add-interactivity.png create mode 100644 docs/src/assets/img/create-user-interfaces.png create mode 100644 docs/src/assets/img/write-components-with-python.png rename docs/src/{static/js/extra.js => assets/js/main.js} (100%) delete mode 100644 docs/src/contribute/running-tests.md delete mode 100644 docs/src/features/hooks.md delete mode 100644 docs/src/features/settings.md delete mode 100644 docs/src/features/utils.md delete mode 100644 docs/src/get-started/choose-django-app.md delete mode 100644 docs/src/get-started/create-component.md delete mode 100644 docs/src/get-started/installation.md delete mode 100644 docs/src/get-started/learn-more.md delete mode 100644 docs/src/get-started/register-view.md delete mode 100644 docs/src/get-started/run-webserver.md delete mode 100644 docs/src/get-started/use-template-tag.md create mode 100644 docs/src/learn/add-reactpy-to-a-django-project.md create mode 100644 docs/src/learn/your-first-component.md rename docs/src/{features => reference}/components.md (51%) rename docs/src/{features => reference}/decorators.md (51%) create mode 100644 docs/src/reference/hooks.md create mode 100644 docs/src/reference/settings.md rename docs/src/{features => reference}/template-tag.md (69%) create mode 100644 docs/src/reference/utils.md delete mode 100644 docs/src/static/css/extra.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 40f29365..577f67f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,7 +56,7 @@ Using the following categories, list your changes in this order: ### Changed - Bumped the minimum ReactPy version to `1.0.2`. -- Prettier websocket URLs for components that do not have sessions. +- Prettier WebSocket URLs for components that do not have sessions. - Template tag will now only validate `args`/`kwargs` if `settings.py:DEBUG` is enabled. - Bumped the minimum `@reactpy/client` version to `0.3.1` - Bumped the minimum Django version to `4.2`. @@ -79,7 +79,7 @@ Using the following categories, list your changes in this order: ### Changed -- ReactPy will now provide a warning if your HTTP URLs are not on the same prefix as your websockets. +- ReactPy will now provide a warning if your HTTP URLs are not on the same prefix as your WebSockets. - Cleaner logging output for auto-detected ReactPy root components. ### Deprecated @@ -91,14 +91,14 @@ Using the following categories, list your changes in this order: - Warning W007 (`REACTPY_WEBSOCKET_URL doesn't end with a slash`) has been removed. ReactPy now automatically handles slashes. - Warning W008 (`REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character`) has been removed. ReactPy now automatically handles this scenario. -- Error E009 (`channels is not in settings.py:INSTALLED_APPS`) has been removed. Newer versions of `channels` do not require installation via `INSTALLED_APPS` to receive an ASGI webserver. +- Error E009 (`channels is not in settings.py:INSTALLED_APPS`) has been removed. Newer versions of `channels` do not require installation via `INSTALLED_APPS` to receive an ASGI web server. ## [3.3.2] - 2023-08-13 ### Added -- ReactPy Websocket will now decode messages via `orjson` resulting in an ~6% overall performance improvement. -- Built-in `asyncio` event loops are now patched via `nest_asyncio`, resulting in an ~10% overall performance improvement. This has no performance impact if you are running your webserver with `uvloop`. +- ReactPy WebSocket will now decode messages via `orjson` resulting in an ~6% overall performance improvement. +- Built-in `asyncio` event loops are now patched via `nest_asyncio`, resulting in an ~10% overall performance improvement. This has no performance impact if you are running your web server with `uvloop`. ### Fixed diff --git a/README.md b/README.md index 8e05e247..8aebcd8b 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,6 @@ def hello_world(recipient: str): In your **Django app**'s HTML template, you can now embed your ReactPy component using the `component` template tag. Within this tag, you will need to type in the dotted path to the component. - - Additionally, you can pass in `args` and `kwargs` into your component function. After reading the code below, pay attention to how the function definition for `hello_world` (_from the previous example_) accepts a `recipient` argument. diff --git a/docs/includes/orm.md b/docs/includes/orm.md index 22151a74..fafb6226 100644 --- a/docs/includes/orm.md +++ b/docs/includes/orm.md @@ -1,13 +1,13 @@ -Due to Django's ORM design, database queries must be deferred using hooks. Otherwise, you will see a `SynchronousOnlyOperation` exception. +Due to Django's ORM design, database queries must be deferred using hooks. Otherwise, you will see a `#!python SynchronousOnlyOperation` exception. -These `SynchronousOnlyOperation` exceptions may be resolved in a future version of Django containing an asynchronous ORM. However, it is best practice to always perform ORM calls in the background via hooks. +These `#!python SynchronousOnlyOperation` exceptions may be resolved in a future version of Django containing an asynchronous ORM. However, it is best practice to always perform ORM calls in the background via hooks. -By default, automatic recursive fetching of `ManyToMany` or `ForeignKey` fields is enabled within the default `QueryOptions.postprocessor`. This is needed to prevent `SynchronousOnlyOperation` exceptions when accessing these fields within your ReactPy components. +By default, automatic recursive fetching of `#!python ManyToMany` or `#!python ForeignKey` fields is enabled within the default `#!python QueryOptions.postprocessor`. This is needed to prevent `#!python SynchronousOnlyOperation` exceptions when accessing these fields within your ReactPy components. diff --git a/docs/overrides/home-code-examples/add-interactivity-demo.html b/docs/overrides/home-code-examples/add-interactivity-demo.html new file mode 100644 index 00000000..d9e99579 --- /dev/null +++ b/docs/overrides/home-code-examples/add-interactivity-demo.html @@ -0,0 +1,165 @@ +
+
+ +
+
+ + + + example.com/videos.html +
+
+ +
+
+

Searchable Videos

+

Type a search query below.

+ +
+ +

5 Videos

+ +
+
+ + + +
+
+

ReactPy: The Documentary

+

From web library to taco delivery service

+
+ +
+ +
+
+ + + +
+
+

Code using Worst Practices

+

Harriet Potter (2013)

+
+ +
+ +
+
+ + + +
+
+

Introducing ReactPy Foriegn

+

Tim Cooker (2015)

+
+ +
+ +
+
+ + + +
+
+

Introducing ReactPy Cooks

+

Soap Boat and Dinosaur Dan (2018)

+
+ +
+ +
+
+ + + +
+
+

Introducing Quantum Components

+

Isaac Asimov and Lauren-kun (2020)

+
+ +
+
+ + +
+
diff --git a/docs/overrides/home-code-examples/add-interactivity.py b/docs/overrides/home-code-examples/add-interactivity.py new file mode 100644 index 00000000..90976446 --- /dev/null +++ b/docs/overrides/home-code-examples/add-interactivity.py @@ -0,0 +1,30 @@ +from reactpy import component, html, use_state + + +def filter_videos(videos, search_text): + return None + + +def search_input(dictionary, value): + return None + + +def video_list(videos, empty_heading): + return None + + +@component +def searchable_video_list(videos): + search_text, set_search_text = use_state("") + found_videos = filter_videos(videos, search_text) + + return html._( + search_input( + {"on_change": lambda new_text: set_search_text(new_text)}, + value=search_text, + ), + video_list( + videos=found_videos, + empty_heading=f"No matches for “{search_text}”", + ), + ) diff --git a/docs/overrides/home-code-examples/code-block.html b/docs/overrides/home-code-examples/code-block.html new file mode 100644 index 00000000..c1f14e5d --- /dev/null +++ b/docs/overrides/home-code-examples/code-block.html @@ -0,0 +1,7 @@ +
+ +
+
+ +
+
diff --git a/docs/overrides/home-code-examples/create-user-interfaces-demo.html b/docs/overrides/home-code-examples/create-user-interfaces-demo.html new file mode 100644 index 00000000..9a684d3c --- /dev/null +++ b/docs/overrides/home-code-examples/create-user-interfaces-demo.html @@ -0,0 +1,24 @@ +
+
+
+
+ + + +
+
+

My video

+

Video description

+
+ +
+
+
diff --git a/docs/overrides/home-code-examples/create-user-interfaces.py b/docs/overrides/home-code-examples/create-user-interfaces.py new file mode 100644 index 00000000..37776ab1 --- /dev/null +++ b/docs/overrides/home-code-examples/create-user-interfaces.py @@ -0,0 +1,22 @@ +from reactpy import component, html + + +def thumbnail(video): + return None + + +def like_button(video): + return None + + +@component +def video(video): + return html.div( + thumbnail(video), + html.a( + {"href": video.url}, + html.h3(video.title), + html.p(video.description), + ), + like_button(video), + ) diff --git a/docs/overrides/home-code-examples/write-components-with-python-demo.html b/docs/overrides/home-code-examples/write-components-with-python-demo.html new file mode 100644 index 00000000..203287c6 --- /dev/null +++ b/docs/overrides/home-code-examples/write-components-with-python-demo.html @@ -0,0 +1,65 @@ +
+
+

3 Videos

+
+
+ + + +
+
+

First video

+

Video description

+
+ +
+
+
+ + + +
+
+

Second video

+

Video description

+
+ +
+
+
+ + + +
+
+

Third video

+

Video description

+
+ +
+
+
diff --git a/docs/overrides/home-code-examples/write-components-with-python.py b/docs/overrides/home-code-examples/write-components-with-python.py new file mode 100644 index 00000000..6af43baa --- /dev/null +++ b/docs/overrides/home-code-examples/write-components-with-python.py @@ -0,0 +1,15 @@ +from reactpy import component, html + + +@component +def video_list(videos, empty_heading): + count = len(videos) + heading = empty_heading + if count > 0: + noun = "Videos" if count > 1 else "Video" + heading = f"{count} {noun}" + + return html.section( + html.h2(heading), + [video(video) for video in videos], + ) diff --git a/docs/overrides/home.html b/docs/overrides/home.html new file mode 100644 index 00000000..3664c44f --- /dev/null +++ b/docs/overrides/home.html @@ -0,0 +1,132 @@ + +{% extends "main.html" %} + + +{% block content %}{% endblock %} + + +{% block tabs %} + +
+
+ +

{{ config.site_name }}

+

{{ config.site_description }}

+ +
+ +
+

Create user interfaces from components

+

+ ReactPy lets you build user interfaces out of individual pieces called components. Create your own ReactPy + components like thumbnail, like_button, and video. Then combine + them into entire screens, pages, and apps. +

+
+ {% with image="create-user-interfaces.png", class="pop-left" %} + {% include "home-code-examples/code-block.html" %} + {% endwith %} + {% include "home-code-examples/create-user-interfaces-demo.html" %} +
+

+ Whether you work on your own or with thousands of other developers, using React feels the same. It is + designed to let you seamlessly combine components written by independent people, teams, and + organizations. +

+
+ +
+

Write components with pure Python code

+

+ ReactPy components are Python functions. Want to show some content conditionally? Use an + if statement. Displaying a list? Try using + list comprehension. + Learning ReactPy is learning programming. +

+
+ {% with image="write-components-with-python.png", class="pop-left" %} + {% include "home-code-examples/code-block.html" %} + {% endwith %} + {% include "home-code-examples/write-components-with-python-demo.html" %} + +
+
+ +
+

Add interactivity wherever you need it

+

+ ReactPy components receive data and return what should appear on the screen. You can pass them new data in + response to an interaction, like when the user types into an input. ReactPy will then update the screen to + match the new data. +

+
+ {% with image="add-interactivity.png" %} + {% include "home-code-examples/code-block.html" %} + {% endwith %} + {% include "home-code-examples/add-interactivity-demo.html" %} +
+

+ You don't have to build your whole page in ReactPy. Add React to your existing HTML page, and render + interactive ReactPy components anywhere on it. +

+
+ +
+

Go full-stack with the Django framework

+

+ ReactPy is a library. It lets you put components together, but it doesn't prescribe how to do routing and + data fetching. To build an entire app with ReactPy, we recommend a backend framework like + Django. +

+ + Get Started + +
+
+{% endblock %} diff --git a/docs/overrides/main.html b/docs/overrides/main.html index 0b173292..bc3074e6 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -13,7 +13,7 @@ {% endblock %} {% block outdated %} -You're not viewing the latest version. +You're not viewing the latest official release. Click here to go to latest. diff --git a/docs/python/configure-channels.py b/docs/python/configure-channels-asgi-app.py similarity index 55% rename from docs/python/configure-channels.py rename to docs/python/configure-channels-asgi-app.py index 337a922f..a2834e03 100644 --- a/docs/python/configure-channels.py +++ b/docs/python/configure-channels-asgi-app.py @@ -1,5 +1 @@ -INSTALLED_APPS = [ - "daphne", - ..., -] ASGI_APPLICATION = "example_project.asgi.application" diff --git a/docs/python/configure-channels-installed-app.py b/docs/python/configure-channels-installed-app.py new file mode 100644 index 00000000..fc9e4017 --- /dev/null +++ b/docs/python/configure-channels-installed-app.py @@ -0,0 +1,4 @@ +INSTALLED_APPS = [ + "daphne", + ..., +] diff --git a/docs/src/changelog/index.md b/docs/src/about/changelog.md similarity index 100% rename from docs/src/changelog/index.md rename to docs/src/about/changelog.md diff --git a/docs/src/contribute/code.md b/docs/src/about/code.md similarity index 56% rename from docs/src/contribute/code.md rename to docs/src/about/code.md index a5b52955..b163d01c 100644 --- a/docs/src/contribute/code.md +++ b/docs/src/about/code.md @@ -14,7 +14,7 @@ --- -## Modifying Code +## Creating an environment If you plan to make code changes to this repository, you will need to install the following dependencies first: @@ -38,7 +38,13 @@ Then, by running the command below you can: pip install -e . -r requirements.txt ``` -Finally, to verify that everything is working properly, you can manually run the test webserver. +!!! warning "Pitfall" + + Some of our development dependencies require a C++ compiler, which is not installed by default on Windows. + + If you receive errors related to this during installation, follow the instructions in your console errors. + +Finally, to verify that everything is working properly, you can manually run the test web server. ```bash linenums="0" cd tests @@ -47,6 +53,42 @@ python manage.py runserver Navigate to [`http://127.0.0.1:8000`](http://127.0.0.1:8000) to see if the tests are rendering correctly. -## GitHub Pull Request +## Creating a pull request {% include-markdown "../../includes/pr.md" %} + +## Running the full test suite + +!!! note + + This repository uses [Nox](https://nox.thea.codes/en/stable/) to run tests. For a full test of available scripts run `nox -l`. + +By running the command below you can run the full test suite: + +```bash linenums="0" +nox -s test +``` + +Or, if you want to run the tests in the background: + +```bash linenums="0" +nox -s test -- --headless +``` + +## Running Django tests + +If you want to only run our Django tests in your current environment, you can use the following command: + +```bash linenums="0" +cd tests +python manage.py test +``` + +## Running Django test web server + +If you want to manually run the Django test application, you can use the following command: + +```bash linenums="0" +cd tests +python manage.py runserver +``` diff --git a/docs/src/contribute/docs.md b/docs/src/about/docs.md similarity index 96% rename from docs/src/contribute/docs.md rename to docs/src/about/docs.md index 913bc0a6..6c2f413b 100644 --- a/docs/src/contribute/docs.md +++ b/docs/src/about/docs.md @@ -31,7 +31,7 @@ Then, by running the command below you can: pip install -e . -r requirements.txt --upgrade ``` -Finally, to verify that everything is working properly, you can manually run the docs preview webserver. +Finally, to verify that everything is working properly, you can manually run the docs preview web server. ```bash linenums="0" mkdocs serve diff --git a/docs/src/assets/css/admonition.css b/docs/src/assets/css/admonition.css new file mode 100644 index 00000000..f71fa55a --- /dev/null +++ b/docs/src/assets/css/admonition.css @@ -0,0 +1,166 @@ +[data-md-color-scheme="slate"] { + --admonition-border-color: transparent; + --admonition-expanded-border-color: rgba(255, 255, 255, 0.1); + --note-bg-color: rgb(43 110 98/ 0.2); + --terminal-bg-color: #0c0c0c; + --terminal-title-bg-color: #000; + --deep-dive-bg-color: rgb(43 52 145 / 0.2); + --you-will-learn-bg-color: #353a45; + --pitfall-bg-color: rgb(182 87 0 / 0.2); +} +[data-md-color-scheme="default"] { + --admonition-border-color: rgba(0, 0, 0, 0.08); + --admonition-expanded-border-color: var(--admonition-border-color); + --note-bg-color: rgb(244 251 249); + --terminal-bg-color: rgb(64 71 86); + --terminal-title-bg-color: rgb(35 39 47); + --deep-dive-bg-color: rgb(243 244 253); + --you-will-learn-bg-color: rgb(246, 247, 249); + --pitfall-bg-color: rgb(254, 245, 231); +} + +.md-typeset details, +.md-typeset .admonition { + border-color: var(--admonition-border-color) !important; + box-shadow: none; +} + +.md-typeset :is(.admonition, details) { + margin: 0.55em 0; +} + +.md-typeset .admonition { + font-size: 0.7rem; +} + +.md-typeset .admonition:focus-within, +.md-typeset details:focus-within { + box-shadow: none !important; +} + +.md-typeset details[open] { + border-color: var(--admonition-expanded-border-color) !important; +} + +/* +Admonition: "summary" +React Name: "You will learn" +*/ +.md-typeset .admonition.summary { + background: var(--you-will-learn-bg-color); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +.md-typeset .summary .admonition-title { + font-size: 1rem; + background: transparent; + padding-left: 0.6rem; + padding-bottom: 0; +} + +.md-typeset .summary .admonition-title:before { + display: none; +} + +.md-typeset .admonition.summary { + border-color: #ffffff17 !important; +} + +/* +Admonition: "note" +React Name: "Note" +*/ +.md-typeset .admonition.note { + background: var(--note-bg-color); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +.md-typeset .note .admonition-title { + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(68 172 153); +} + +.md-typeset .note .admonition-title:before { + font-size: 1.1rem; + background: rgb(68 172 153); +} + +.md-typeset .note > .admonition-title:before, +.md-typeset .note > summary:before { + -webkit-mask-image: var(--md-admonition-icon--abstract); + mask-image: var(--md-admonition-icon--abstract); +} + +/* +Admonition: "warning" +React Name: "Pitfall" +*/ +.md-typeset .admonition.warning { + background: var(--pitfall-bg-color); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +.md-typeset .warning .admonition-title { + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(219 125 39); +} + +.md-typeset .warning .admonition-title:before { + font-size: 1.1rem; + background: rgb(219 125 39); +} + +/* +Admonition: "info" +React Name: "Deep Dive" +*/ +.md-typeset .admonition.info { + background: var(--deep-dive-bg-color); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +.md-typeset .info .admonition-title { + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(136 145 236); +} + +.md-typeset .info .admonition-title:before { + font-size: 1.1rem; + background: rgb(136 145 236); +} + +/* +Admonition: "example" +React Name: "Terminal" +*/ +.md-typeset .admonition.example { + background: var(--terminal-bg-color); + border-radius: 0.4rem; + overflow: hidden; + border: none; +} + +.md-typeset .example .admonition-title { + background: var(--terminal-title-bg-color); + color: rgb(246 247 249); +} + +.md-typeset .example .admonition-title:before { + background: rgb(246 247 249); +} + +.md-typeset .admonition.example code { + background: transparent; + color: #fff; + box-shadow: none; +} diff --git a/docs/src/assets/css/button.css b/docs/src/assets/css/button.css new file mode 100644 index 00000000..8f71391a --- /dev/null +++ b/docs/src/assets/css/button.css @@ -0,0 +1,41 @@ +[data-md-color-scheme="slate"] { + --md-button-font-color: #fff; + --md-button-border-color: #404756; +} + +[data-md-color-scheme="default"] { + --md-button-font-color: #000; + --md-button-border-color: #8d8d8d; +} + +.md-typeset .md-button { + border-width: 1px; + border-color: var(--md-button-border-color); + border-radius: 9999px; + color: var(--md-button-font-color); + transition: color 125ms, background 125ms, border-color 125ms, + transform 125ms; +} + +.md-typeset .md-button:focus, +.md-typeset .md-button:hover { + border-color: var(--md-button-border-color); + color: var(--md-button-font-color); + background: rgba(78, 87, 105, 0.05); +} + +.md-typeset .md-button.md-button--primary { + color: #fff; + border-color: transparent; + background: var(--reactpy-color-dark); +} + +.md-typeset .md-button.md-button--primary:focus, +.md-typeset .md-button.md-button--primary:hover { + border-color: transparent; + background: var(--reactpy-color-darker); +} + +.md-typeset .md-button:focus { + transform: scale(0.98); +} diff --git a/docs/src/assets/css/code.css b/docs/src/assets/css/code.css new file mode 100644 index 00000000..d1556dc0 --- /dev/null +++ b/docs/src/assets/css/code.css @@ -0,0 +1,111 @@ +:root { + --code-max-height: 17.25rem; + --md-code-backdrop: rgba(0, 0, 0, 0) 0px 0px 0px 0px, + rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.03) 0px 0.8px 2px 0px, + rgba(0, 0, 0, 0.047) 0px 2.7px 6.7px 0px, + rgba(0, 0, 0, 0.08) 0px 12px 30px 0px; +} +[data-md-color-scheme="slate"] { + --md-code-hl-color: #ffffcf1c; + --md-code-bg-color: #16181d; + --md-code-hl-comment-color: hsla(var(--md-hue), 75%, 90%, 0.43); + --code-tab-color: rgb(52 58 70); + --md-code-hl-name-color: #aadafc; + --md-code-hl-string-color: hsl(21 49% 63% / 1); + --md-code-hl-keyword-color: hsl(289.67deg 35% 60%); + --md-code-hl-constant-color: hsl(213.91deg 68% 61%); + --md-code-hl-number-color: #bfd9ab; + --func-and-decorator-color: #dcdcae; + --module-import-color: #60c4ac; +} +[data-md-color-scheme="default"] { + --md-code-hl-color: #ffffcf1c; + --md-code-bg-color: rgba(208, 211, 220, 0.4); + --md-code-fg-color: rgb(64, 71, 86); + --code-tab-color: #fff; + --func-and-decorator-color: var(--md-code-hl-function-color); + --module-import-color: #e153e5; +} +[data-md-color-scheme="default"] .md-typeset .highlight > pre > code, +[data-md-color-scheme="default"] .md-typeset .highlight > table.highlighttable { + --md-code-bg-color: #fff; +} + +/* All code blocks */ +.md-typeset pre > code { + max-height: var(--code-max-height); +} + +/* Code blocks with no line number */ +.md-typeset .highlight > pre > code { + border-radius: 16px; + max-height: var(--code-max-height); + box-shadow: var(--md-code-backdrop); +} + +/* Code blocks with line numbers */ +.md-typeset .highlighttable .linenos { + max-height: var(--code-max-height); + overflow: hidden; +} +.md-typeset .highlighttable { + box-shadow: var(--md-code-backdrop); + border-radius: 8px; + overflow: hidden; +} + +/* Tabbed code blocks */ +.md-typeset .tabbed-set { + box-shadow: var(--md-code-backdrop); + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--md-default-fg-color--lightest); +} +.md-typeset .tabbed-set .tabbed-block { + overflow: hidden; +} +.js .md-typeset .tabbed-set .tabbed-labels { + background: var(--code-tab-color); + margin: 0; + padding-left: 0.8rem; +} +.md-typeset .tabbed-set .tabbed-labels > label { + font-weight: 400; + font-size: 0.7rem; + padding-top: 0.55em; + padding-bottom: 0.35em; +} +.md-typeset .tabbed-set .highlighttable { + border-radius: 0; +} + +/* Code hightlighting colors */ + +/* Module imports */ +.highlight .nc, +.highlight .ne, +.highlight .nn, +.highlight .nv { + color: var(--module-import-color); +} + +/* Function def name and decorator */ +.highlight .nd, +.highlight .nf { + color: var(--func-and-decorator-color); +} + +/* None type */ +.highlight .kc { + color: var(--md-code-hl-constant-color); +} + +/* Keywords such as def and return */ +.highlight .k { + color: var(--md-code-hl-constant-color); +} + +/* HTML tags */ +.highlight .nt { + color: var(--md-code-hl-constant-color); +} diff --git a/docs/src/assets/css/footer.css b/docs/src/assets/css/footer.css new file mode 100644 index 00000000..9dcaca2d --- /dev/null +++ b/docs/src/assets/css/footer.css @@ -0,0 +1,29 @@ +[data-md-color-scheme="slate"] { + --md-footer-bg-color: var(--md-default-bg-color); + --md-footer-bg-color--dark: var(--md-default-bg-color); + --md-footer-border-color: var(--md-header-border-color); +} + +[data-md-color-scheme="default"] { + --md-footer-fg-color: var(--md-typeset-color); + --md-footer-fg-color--light: var(--md-typeset-color); + --md-footer-bg-color: var(--md-default-bg-color); + --md-footer-bg-color--dark: var(--md-default-bg-color); + --md-footer-border-color: var(--md-header-border-color); +} + +.md-footer { + border-top: 1px solid var(--md-footer-border-color); +} + +.md-copyright { + width: 100%; +} + +.md-copyright__highlight { + width: 100%; +} + +.legal-footer-right { + float: right; +} diff --git a/docs/src/assets/css/home.css b/docs/src/assets/css/home.css new file mode 100644 index 00000000..c72e7093 --- /dev/null +++ b/docs/src/assets/css/home.css @@ -0,0 +1,335 @@ +img.home-logo { + height: 120px; +} + +.home .row { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + padding: 6rem 0.8rem; +} + +.home .row:not(.first, .stripe) { + background: var(--row-bg-color); +} + +.home .row.stripe { + background: var(--row-stripe-bg-color); + border: 0 solid var(--stripe-border-color); + border-top-width: 1px; + border-bottom-width: 1px; +} + +.home .row.first { + text-align: center; +} + +.home .row h1 { + max-width: 28rem; + line-height: 1.15; + font-weight: 500; + margin-bottom: 0.55rem; + margin-top: -1rem; +} + +.home .row.first h1 { + margin-top: 0.55rem; + margin-bottom: -0.75rem; +} + +.home .row > p { + max-width: 35rem; + line-height: 1.5; + font-weight: 400; +} + +.home .row.first > p { + font-size: 32px; + font-weight: 500; +} + +/* Code blocks */ +.home .row .tabbed-set { + background: var(--home-tabbed-set-bg-color); + margin: 0; +} + +.home .row .tabbed-content { + padding: 20px 18px; + overflow-x: auto; +} + +.home .row .tabbed-content img { + user-select: none; + -moz-user-select: none; + -webkit-user-drag: none; + -webkit-user-select: none; + -ms-user-select: none; + max-width: 580px; +} + +.home .row .tabbed-content { + -webkit-filter: var(--code-block-filter); + filter: var(--code-block-filter); +} + +/* Code examples */ +.home .example-container { + background: radial-gradient( + circle at 0% 100%, + rgb(41 84 147 / 11%) 0%, + rgb(22 89 189 / 4%) 70%, + rgb(48 99 175 / 0%) 80% + ), + radial-gradient( + circle at 100% 100%, + rgb(24 87 45 / 55%) 0%, + rgb(29 61 12 / 4%) 70%, + rgb(94 116 93 / 0%) 80% + ), + radial-gradient( + circle at 100% 0%, + rgba(54, 66, 84, 0.55) 0%, + rgb(102 111 125 / 4%) 70%, + rgba(54, 66, 84, 0) 80% + ), + radial-gradient( + circle at 0% 0%, + rgba(91, 114, 135, 0.55) 0%, + rgb(45 111 171 / 4%) 70%, + rgb(5 82 153 / 0%) 80% + ), + rgb(0, 0, 0) center center/cover no-repeat fixed; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: center; + border-radius: 16px; + margin: 30px 0; + max-width: 100%; + grid-column-gap: 20px; + padding-left: 20px; + padding-right: 20px; +} + +.home .demo .white-bg { + background: #fff; + border-radius: 16px; + display: flex; + flex-direction: column; + max-width: 590px; + min-width: -webkit-min-content; + min-width: -moz-min-content; + min-width: min-content; + row-gap: 1rem; + padding: 1rem; +} + +.home .demo .vid-row { + display: flex; + flex-direction: row; + -moz-column-gap: 12px; + column-gap: 12px; +} + +.home .demo { + color: #000; +} + +.home .demo .vid-thumbnail { + background: radial-gradient( + circle at 0% 100%, + rgb(41 84 147 / 55%) 0%, + rgb(22 89 189 / 4%) 70%, + rgb(48 99 175 / 0%) 80% + ), + radial-gradient( + circle at 100% 100%, + rgb(24 63 87 / 55%) 0%, + rgb(29 61 12 / 4%) 70%, + rgb(94 116 93 / 0%) 80% + ), + radial-gradient( + circle at 100% 0%, + rgba(54, 66, 84, 0.55) 0%, + rgb(102 111 125 / 4%) 70%, + rgba(54, 66, 84, 0) 80% + ), + radial-gradient( + circle at 0% 0%, + rgba(91, 114, 135, 0.55) 0%, + rgb(45 111 171 / 4%) 70%, + rgb(5 82 153 / 0%) 80% + ), + rgb(0, 0, 0) center center/cover no-repeat fixed; + width: 9rem; + aspect-ratio: 16 / 9; + border-radius: 8px; + display: flex; + justify-content: center; + align-items: center; +} + +.home .demo .vid-text { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + width: 100%; +} + +.home .demo h2 { + font-size: 18px; + line-height: 1.375; + margin: 0; + text-align: left; + font-weight: 700; +} + +.home .demo h3 { + font-size: 16px; + line-height: 1.25; + margin: 0; +} + +.home .demo p { + font-size: 14px; + line-height: 1.375; + margin: 0; +} + +.home .demo .browser-nav-url { + background: rgba(153, 161, 179, 0.2); + border-radius: 9999px; + font-size: 14px; + color: grey; + display: flex; + align-items: center; + justify-content: center; + -moz-column-gap: 5px; + column-gap: 5px; +} + +.home .demo .browser-navbar { + margin: -1rem; + margin-bottom: 0; + padding: 0.75rem 1rem; + border-bottom: 1px solid darkgrey; +} + +.home .demo .browser-viewport { + background: #fff; + border-radius: 16px; + display: flex; + flex-direction: column; + row-gap: 1rem; + height: 400px; + overflow-y: scroll; + margin: -1rem; + padding: 1rem; +} + +.home .demo .browser-viewport .search-header > h1 { + color: #000; + text-align: left; + font-size: 24px; + margin: 0; +} + +.home .demo .browser-viewport .search-header > p { + text-align: left; + font-size: 16px; + margin: 10px 0; +} + +.home .demo .search-bar input { + width: 100%; + background: rgba(153, 161, 179, 0.2); + border-radius: 9999px; + padding-left: 40px; + padding-right: 40px; + height: 40px; + color: #000; +} + +.home .demo .search-bar svg { + height: 40px; + position: absolute; + transform: translateX(75%); +} + +.home .demo .search-bar { + position: relative; +} + +/* Desktop Styling */ +@media screen and (min-width: 60em) { + .home .row { + text-align: center; + } + .home .row > p { + font-size: 21px; + } + .home .row > h1 { + font-size: 52px; + } + .home .row .pop-left { + margin-left: -20px; + margin-right: 0; + margin-top: -20px; + margin-bottom: -20px; + } + .home .row .pop-right { + margin-left: 0px; + margin-right: 0px; + margin-top: -20px; + margin-bottom: -20px; + } +} + +/* Mobile Styling */ +@media screen and (max-width: 60em) { + .home .row { + padding: 4rem 0.8rem; + } + .home .row > h1, + .home .row > p { + padding-left: 1rem; + padding-right: 1rem; + } + .home .row.first { + padding-top: 2rem; + } + .home-btns { + width: 100%; + display: grid; + grid-gap: 0.5rem; + gap: 0.5rem; + } + .home .example-container { + display: flex; + flex-direction: column; + row-gap: 20px; + width: 100%; + justify-content: center; + border-radius: 0; + padding: 1rem 0; + } + .home .row { + padding-left: 0; + padding-right: 0; + } + .home .tabbed-set { + width: 100%; + border-radius: 0; + } + .home .demo { + width: 100%; + display: flex; + justify-content: center; + } + .home .demo > .white-bg { + width: 80%; + max-width: 80%; + } +} diff --git a/docs/src/assets/css/main.css b/docs/src/assets/css/main.css new file mode 100644 index 00000000..500ae4be --- /dev/null +++ b/docs/src/assets/css/main.css @@ -0,0 +1,84 @@ +/* Variable overrides */ +:root { + --reactpy-color: #58b962; + --reactpy-color-dark: #42914a; + --reactpy-color-darker: #34743b; + --reactpy-color-opacity-10: rgb(88 185 98 / 10%); +} + +[data-md-color-accent="red"] { + --md-primary-fg-color--light: var(--reactpy-color); + --md-primary-fg-color--dark: var(--reactpy-color-dark); +} + +[data-md-color-scheme="slate"] { + --md-default-bg-color: rgb(35 39 47); + --md-default-bg-color--light: hsla(var(--md-hue), 15%, 16%, 0.54); + --md-default-bg-color--lighter: hsla(var(--md-hue), 15%, 16%, 0.26); + --md-default-bg-color--lightest: hsla(var(--md-hue), 15%, 16%, 0.07); + --md-primary-fg-color: var(--md-default-bg-color); + --md-default-fg-color--light: #fff; + --md-typeset-a-color: var(--reactpy-color); + --md-accent-fg-color: var(--reactpy-color-dark); +} + +[data-md-color-scheme="default"] { + --md-primary-fg-color: var(--md-default-bg-color); + --md-default-fg-color--light: #000; + --md-default-fg-color--lighter: #0000007e; + --md-default-fg-color--lightest: #00000029; + --md-typeset-color: rgb(35, 39, 47); + --md-typeset-a-color: var(--reactpy-color); + --md-accent-fg-color: var(--reactpy-color-dark); +} + +/* Font changes */ +.md-typeset { + font-weight: 300; +} + +.md-typeset h1 { + font-weight: 500; + margin: 0; + font-size: 2.5em; +} + +.md-typeset h2 { + font-weight: 500; +} + +.md-typeset h3 { + font-weight: 600; +} + +/* Intro section styling */ +p.intro { + font-size: 0.9rem; + font-weight: 500; +} + +/* Hide "Overview" jump selector */ +h2#overview { + visibility: hidden; + height: 0; + margin: 0; + padding: 0; +} + +/* Reduce size of the outdated banner */ +.md-banner__inner { + margin: 0.45rem auto; +} + +/* Desktop Styles */ +@media screen and (min-width: 60em) { + /* Remove max width on desktop */ + .md-grid { + max-width: none; + } +} + +/* Max size of page content */ +.md-content { + max-width: 56rem; +} diff --git a/docs/src/assets/css/navbar.css b/docs/src/assets/css/navbar.css new file mode 100644 index 00000000..32347ccb --- /dev/null +++ b/docs/src/assets/css/navbar.css @@ -0,0 +1,163 @@ +[data-md-color-scheme="slate"] { + --md-header-border-color: rgb(255 255 255 / 5%); +} + +[data-md-color-scheme="default"] { + --md-header-border-color: rgb(0 0 0 / 7%); +} + +.md-header { + border: 0 solid transparent; + border-bottom-width: 1px; +} + +.md-header--shadow { + box-shadow: none; + border-color: var(--md-header-border-color); + transition: border-color 0.35s cubic-bezier(0.1, 0.7, 0.1, 1); +} + +/* Version selector */ +.md-header__topic .md-ellipsis, +.md-header__title [data-md-component="header-topic"] { + display: none; +} + +[dir="ltr"] .md-version__current { + margin: 0; +} + +.md-version__list { + margin: 0.2rem -0.8rem; +} + +/* Mobile Styling */ +@media screen and (max-width: 60em) { + label.md-header__button.md-icon[for="__drawer"] { + order: 1; + } + .md-header__button.md-logo { + display: initial; + order: 2; + margin-right: auto; + } + .md-header__title { + order: 3; + } + .md-header__button[for="__search"] { + order: 4; + } + .md-header__option[data-md-component="palette"] { + order: 5; + } + .md-header__source { + display: initial; + order: 6; + } + .md-header__source .md-source__repository { + display: none; + } +} + +/* Desktop Styling */ +@media screen and (min-width: 60em) { + /* Nav container */ + nav.md-header__inner { + display: contents; + } + header.md-header { + display: flex; + align-items: center; + } + + /* Logo */ + .md-header__button.md-logo { + order: 1; + padding-right: 0.4rem; + padding-top: 0; + padding-bottom: 0; + } + .md-header__button.md-logo img { + height: 2rem; + } + + /* Version selector */ + [dir="ltr"] .md-header__title { + order: 2; + margin: 0; + margin-right: 0.8rem; + margin-left: 0.2rem; + } + .md-header__topic { + position: relative; + } + + /* Search */ + .md-search { + order: 3; + width: 100%; + margin-right: 0.6rem; + } + .md-search__inner { + width: 100%; + float: unset !important; + } + .md-search__form { + border-radius: 9999px; + } + [data-md-toggle="search"]:checked ~ .md-header .md-header__option { + max-width: unset; + opacity: unset; + transition: unset; + } + + /* Tabs */ + .md-tabs { + order: 4; + min-width: -webkit-fit-content; + min-width: -moz-fit-content; + min-width: fit-content; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + z-index: -1; + overflow: visible; + border: none !important; + } + li.md-tabs__item.md-tabs__item--active { + background: var(--reactpy-color-opacity-10); + border-radius: 9999px; + color: var(--md-typeset-a-color); + } + .md-tabs__link { + margin: 0; + } + .md-tabs__item { + height: 1.8rem; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + } + + /* Dark/Light Selector */ + .md-header__option[data-md-component="palette"] { + order: 5; + } + + /* GitHub info */ + .md-header__source { + order: 6; + margin-left: 0 !important; + } +} + +/* Ultrawide Desktop Styles */ +@media screen and (min-width: 1919px) { + .md-search { + order: 2; + width: 100%; + max-width: 34.4rem; + margin: 0 auto; + } +} diff --git a/docs/src/assets/css/sidebar.css b/docs/src/assets/css/sidebar.css new file mode 100644 index 00000000..aeadf3b5 --- /dev/null +++ b/docs/src/assets/css/sidebar.css @@ -0,0 +1,100 @@ +:root { + --sizebar-font-size: 0.62rem; +} + +/* Desktop Styling */ +@media screen and (min-width: 76.1875em) { + /* Move the sidebar and TOC to the edge of the page */ + .md-main__inner.md-grid { + margin-left: 0; + margin-right: 0; + max-width: unset; + display: grid; + grid-template-columns: auto 1fr auto; + } + + .md-content { + justify-self: center; + width: 100%; + } + /* Made the sidebar buttons look React-like */ + .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { + text-transform: uppercase; + } + + .md-nav__title[for="__toc"] { + text-transform: uppercase; + margin: 0.5rem; + } + + .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { + color: rgb(133 142 159); + margin: 0.5rem; + } + + .md-nav__item .md-nav__link { + position: relative; + } + + .md-nav__link:is(:focus, :hover):not(.md-nav__link--active) { + color: unset; + } + + .md-nav__item + .md-nav__link:is(:focus, :hover):not(.md-nav__link--active):before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.2; + z-index: -1; + background: grey; + } + + .md-nav__item .md-nav__link--active:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + background: var(--reactpy-color-opacity-10); + } + + .md-nav__link { + padding: 0.5rem 0.5rem 0.5rem 1rem; + margin: 0; + border-radius: 0 10px 10px 0; + font-weight: 500; + overflow: hidden; + font-size: var(--sizebar-font-size); + } + + .md-sidebar__scrollwrap { + margin: 0; + } + + [dir="ltr"] + .md-nav--lifted + .md-nav[data-md-level="1"] + > .md-nav__list + > .md-nav__item { + padding: 0; + } + + .md-nav__item--nested .md-nav__item .md-nav__item { + padding: 0; + } + + .md-nav__item--nested .md-nav__item .md-nav__item .md-nav__link { + font-weight: 300; + } + + .md-nav__item--nested .md-nav__item .md-nav__item .md-nav__link { + font-weight: 400; + padding-left: 1.25rem; + } +} diff --git a/docs/src/assets/css/table-of-contents.css b/docs/src/assets/css/table-of-contents.css new file mode 100644 index 00000000..aa9a61ae --- /dev/null +++ b/docs/src/assets/css/table-of-contents.css @@ -0,0 +1,39 @@ +/* Table of Contents styling */ +@media screen and (min-width: 60em) { + [data-md-component="sidebar"] .md-nav__title[for="__toc"] { + text-transform: uppercase; + margin: 0.5rem; + margin-left: 0; + font-size: var(--sizebar-font-size); + } + + [data-md-component="toc"] .md-nav__item .md-nav__link--active { + position: relative; + } + + [data-md-component="toc"] .md-nav__item .md-nav__link--active:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.15; + z-index: -1; + background: var(--md-typeset-a-color); + } + + [data-md-component="toc"] .md-nav__link { + padding: 0.5rem 0.5rem; + margin: 0; + border-radius: 10px 0 0 10px; + font-weight: 400; + } + [dir="ltr"] .md-sidebar__inner { + padding: 0; + } + + .md-nav__item { + padding: 0; + } +} diff --git a/docs/src/assets/img/add-interactivity.png b/docs/src/assets/img/add-interactivity.png new file mode 100644 index 0000000000000000000000000000000000000000..e5e24d2925db020621ed3a157b3b34bd516353c1 GIT binary patch literal 20118 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 literal 0 HcmV?d00001 diff --git a/docs/src/assets/img/create-user-interfaces.png b/docs/src/assets/img/create-user-interfaces.png new file mode 100644 index 0000000000000000000000000000000000000000..13abd064d9ba90d6640f3769bba0eade6e366868 GIT binary patch literal 12381 zcmbVz1yG#L((WQ55JHe7XuhyO(BRG@!5tQNTb9LPao3c!kO2S>2rbmKoU|0=K~T62GsFaL3}be)xu-_H_QuT) z0=0rU0gYj17Pf+vdyTD>KnoK=N=+^WRs}mTn7M_thXYL2Ls1RtVFl$gp?o6*6mSC} z8Q8#_AV4=8YgD&mrVjfH#?q%?PO zvIDWOxVpMByK*qY9n4tR`1p`K*jd=wnUEArj_$Tj5H}`UN2)(T$Kv`hc zFdLYylOvLr?H^h@bGQ@S(H#DNVf|0{zYTyit%AZoWBgZHY;67+!qG{>1*yhg2Klel zj%x09FcuYkgo(qHlZOdnY-++}$_C}(H06V`nwmiWeqS68b-rH$f4^_?|M@=H!2-DjA=dwE zKKC2*e(ivyEgX@v>i*Z(QH9z6)v~q#{$4I12=sm%2vS1t=O1Q5`PbJL|7#2U$=1~z zhGhExSQmeiIl@hyTpOF4f)t+Re#8E$2g`q|_`C04HuvA;NRM&f{Kr)x zAO3N6Fk7UDb3nQ>nkB450DxgfMqEVAEp;c&TS-;(_HJS3WM)Alhz_^tIe8|QH((~t zTzOVvTdy1pbDqU>X;HH;tP-D;NF)pvo-7|=fCxW?6^jyWfvaxwg@0`%d~zF7%Q93g z3N9n8i5K7ZFB=f!6xmj6ol9CEKclI~a;G#V*l)_`7!o|2b$pVDyY47-<~6@=R%teZ z;V-<8^|XY1^Mv-%s~D8b#`J03)pC4%%$03hlt{NicP*xaRx+T^Vq2OkT3_y5MpAma5;77OVf~5Q@09cW9XMgS-j>dF-cP| z8G2^wt~+SNaZJr3v)#eoi4j;r&Yz#$ow_f(6fPnu-CWVaPv<}}J&GZGN=9H#;9B?ghZn1Sd|8%c-M~X-QHsA`%Zz;DM0_V$tu( zA+Dyv_;vwl!#7{ep}9QOr*EBbTnv98O?c?P7CUt@ikd+v;T4GRH=G8HO+V`l@hs|@sj{&3O`H%|z^~KMPf@$+-B;1jrXT79Z1qx!h z^7<5&vBWs@xPHR>PoDw;AK8VcbK?N;m>xIG{E(3doZs0*f9hTfoL+*CQ)Z+5(8)Fn zLTRSjth_5D&6J$Q)DFP!7679Vv+6K!^5XlMGhm>B0P-n9=Iu0%g&qYB(D%CZD2OUc zf22TH=|gJAm)n6reKL+^$wZc!$PZs|rIs0gJ;p_U)h~<5sO8J=PIFlEn&<~+qbN0+=R^)SAL~Eh;ayvwZciQJzScNxxVrSf)h|_iK{uz-TURa% zjDV-3Dyf%CeD!eTO)q=-VMY>&Ct!*SV3qn2TKmbJIm5R{92lYLBYB91iJox~d&Pi` z3W)`tN&?TH7{k%u?)^*C&Iq1$H&th+{qf-l&Cv8%U3Q_pgz`$*-Ry?`+4m^p>V|BO zn3$QI_c=Gmo0X7iIl)3{a&^|rLx#MgMuh`|$i>n(LHf7jBh4PMtn-PBEn!d7wQKk= z5?HopZ#(Fon8jYW+uT$xCGTmbAFTt0E?Hu>w~xmUXI}S3eW881w_{_xk$bh^eH+@H z@8g>??ajpEz6Dk#GR{11z1>l^F-<$P-{HNohfTGuUN5Gp=V$kPM}tHdeXh;b);%;v zpY6Iqkqe4sldv-T9Ap^h>wfw+?>1MdO=pPyd(`sRL`5>32fH83G|CCkwoPHTyRt3A zs4OExAJX4GRmK3cjekM+(mvg%lJjpW(hKwOaNnvJIA;)iydj(Ct+iBxeDX2@_`%) z_o_oO{b1HYarY{^=(6#5jH@psd^s?Bt}|*=Z__cs=wOsK$C;drN#B#Z zC({dFyC<7<*F3h0{D_l@(UJsi)Q9{oG#OLc^N>#O;b*?CTjax@Mp3CXmeiy6l#_i9 z->98_vOajo`%Tr3x_|$NBRJ=-AljN#nbT5!x!zj*6^9T8k@_QET7zW3P|M1xVm`ZK zZt?nBS@O!d69t9@mHAW#Yx1!ngL?;Az-;i3PS?isxdhO zQO{B$Y;F%z%uaU65i%L_v#dLp=N$1;yP`p z5YUr;v;WXg(>!0pE?~wv9Yhz)zn?d;!tr?vZ_m zZJcbiRlt@#UN`=lS884JYmIk+#qI-av;m)YH)~3{TtZljKZ<@vai2oRN|@ZoTUVqQ z^+n6yC<9Rp(IMj+)m^CogV-^G+U+tHu9De6S>~F)aPZ;rGq(eoM&9-Df)Sx`a7gQk ziYZ%>amslXd$`Q=Bg@2`*m&ODP2(vNNK0;H$`2&5k8})CMpd*UDnIU^-u%^#7eR*C z7m|?+IQ)jb1h@t%+Fp{{-4Zg5S8PgslVw5Tes`M7k$wjDIW|0N5#KPq+spH~bn-D@ zDCk6Bu1wGS`4$Y^P9?BTSpwRyYP z1o_NC&|AUFC?JF0|T)CK0--tyo}&zn>fiDy}kUrZJy9pVT3@h?_9&ld`cdGTRw5;!;-@F zw6WO~>#Al>k8ZFuwi$#E*gxIFZN>rcjO@-BnAzsm{Q)3+wd?6=>A2Ph1|n&&S`cSv z2|lgHgnsSCzNMvg=#0{{XEae>Y+P1IzID#&o}@Nje@HU8ex?@wD=f_ChD_KBv3c&4OC zPefSckBoO{;r%ijr3FaGM+b&Y#BNLxCP;;!nx7|q;VS)u#-OH7nvT_b##m8^n3+rj z)5xpZr_r0Haa9iBe3_ES?u}BhD8W|ta*3wllw%UZto7Me16IK-XhbnF!^@KlLV>;G z(d49m#wxvNJDyy$BP_uJNZqM_M=zRy65T$q@CS;Y|Lcs%jai46NmdDA&J0(%>So8* z{5elMyDO)B9o}jAc4r?hN+WGpcW!>;B6o*%R^;tz$`8m@OyjjT)fg()a1PQnsPR71 ztt{7aW$&0X;gwXb+Egv-lxO8SaO`R*PtdjHjg17NGu}od2sG&oDT<)iR{501FzFMZ zD5pwK($vQD$0Pe0d_2jO3R?LNCQfw!@X@@Czzyic4w+l_>O$E-pK6 zaXCiON@pc0v6*_67^BQ<|6+G9b;34ovWu-~Rh}WUm}OikRd-0!Qkuz3#a&reA}60o zDl8mU^X0Yr-Pql!cGC5d)T>ro#{J5vN;4@531?^L3(r^RELgxP%(wig4!&5BSLhMQ zO(xhm-;IJoK_;?~{w2f8Yk#F5c60j@tzFQ>hYHWD2U^QR`9m0}1w112U*r#M#>Z?4 zK-@RG3!0Vr!zNBcHqQ(2tyF)7{ZeS6^ti!t4${%j(tyYDd%UtArp0NU-;h~3P``A@ zw<(SJ(5<<-qo{L|7`bZUIo|B?aWUdE9DaQHRI%j?3eIoz9cM|-+r$su`=Pm)er=3+ zS1Gi**jGHRK@0aafe^iYet0`XRJgE^|5D^6ay48+f2glI9chVlj%%1|A=?Y$RlfSi|JK2zkzq57;1esC?olD8d{Ic@uyPE9k7V<{^cD zbWLqEr2z4B8e34l#7b{~m1nh=o{(urqcLSYSQGnIQc$MT(_z)FV(u!Crb@8R7~bG4 zuo{)uR-gX%q?50aO49~hSZUg&18!K!P$DwSOR(21RUjHzK9`E0G-#HmzT>`0!G1~l zrkx8qoni8YH=(34y+02LV3xhJB#JdocMA$<35GHg4Z!u*<_@CA!kf2p-O?mBbo3KK zcwN0VWLcyQUG#+IT;?Jo%fc4S5<9IX5qkAh$xLy+#N*vKE3t}I{Y^U1F9-s#Z((j{ zuZ;ej7kI2b9ko(X<{s%_%+M?sKMTLyLc*YzOrL5^IZeuJq;bB{9U-6f&7_q~`cQz` zbqa*}Q({P{^z9`M;VMtp(G0t{&)h2ySa3lOZJnw7Op4rb0Rl~Ff(S&8DVjZ}W(&bl z?|Jdr{(3NSQ8?tu2m3X@kTQR+?LVWRSGrm!QLt?2^znB4vH>uiRKRJ38#)t|{uXT` zCstaMg*<;>g2f#xS;tmU^)~>LZOn=Gtl4Qaf7UZq63jANkGm8r^+q)r5Ml2-mf(a- zjQ^`4YzM0kw`rUkE1e#FcParip-POBy^rV58?pr3wu-49YKEE^wOC;S@bVvFb8UKWB_8+DJvsDu> zeD+c21jloW+B?G1Ht2c%>TLk)PbHt#YD=l)O<)m>?JRWBPaos4tX7Rvg=vOHCQGKM z9psXzj(RS*I1C{2?mY`n#9|L6fSBlrEM@{PN3lq$piTdAMdiNVsd&3-H7Tp*7FQN~ ztt@}Gy5$K2fYnmY$FoyiK7-FeM}y*Zp%M{5a);XcRl#nLBYW3_=f6;G1Cb$1c>BQj z=j>LgN1}3PYo(60wDVK*#^e5If)84ZOCr7v)Y0W->rm4t{2IvfYq5xtV!Y<5(f$?-#@2+ZQAd`QexI^31qT7jj0nN(KH}tA zs8&bslva8T;ZUEu>#U~tZ0=pdhX%uz_S+j><8N6n-Yw-G&vo3A{78gtU&XAv4{#s4 zEbO{nkc;D8kC{+qoyDOgW$Nz*V|DYAOzO(TGq$#r!tNhNxm7_BEK@9RB&tpo_w zakmx%WES(4X1jXZ$_@(a_FmTkRcAK$OiNSAL2egL8atz*{6CAHBTVZa$A|*)tckB~ zO-8S~AVWA7Q#b{UqU+5R=%XrE?m8|Vo6L_5gwp;zsx{iFeTUP@uO(cLh8FAMvUV&Y zFEfeqBlWx)vbBOOkz2PvCyuCn#15H)5e6byPj_tg4=uX10lZ^nQs(ciu1K1$=kNi< zb%#l=o09;K8YZ3rlJEDBI57S($3qoNbkvN4MerqQF#{nWoWY-i9v!v4ACa-(70Lek z4N7W%oOyIGPg;_#r=S@@I$n2qvor2vw`ze(=sh&fn~sC1nxD5|05);e%+;e!e$&xEYnxJfSPR`FSL1jMgU>v*Xo^rG>+F9zFBT zmto0r=xtbv6c_LuIe-k(?C%fwTQ5R{KZpX+X*%a6Q}}hy4AN?nw=-VxzHP!K_SR{+ z_(S;}6>s3+eZWpUb4vL9`RX189wwEM^$eLmYt6sJ@W9ixbhG*Gh#RQcb_(_E2@4VV zz@5S^Cu6pcBcty@ir!|)$LockV6wEAM9Qsv@vXaBw;;!>x7Q`kJt}4>m9mGE6G|P! z5gsPX#mB4iGdk0o`aw7CAfZ=9g&JL7!hK(j1g>ft-yuk@LZ;cCnUIT#ojm=>G4iIo z^>pM;XbdTNT<;rd*$&QUZbx&@S?9W@+WKfRN*$!+pNx)g8aI5|I zE9m&~PD zL&_yBwq^aYE;)3$CNuCQH7#|9#1 zHMJ6w5Z0iX)N+K%XM1@MXTtSvE!?YmYu8?n=Rq{mgML>3!7`l*1O> zgLJwDT3-!0I^@Wu&Wjs2&6gHtHOUl^*mpC`ja2YA}GN5?p=*{b&c)}o;zj9Pt7qO ze~2y7U&pGDQ|yM(Hp?yig7zUY@4=n4N~DB5NzV!V7?Ckb{(E$70Z-}#CW8I6iOy^> zhPY2PbnotZbBjjMgpEaZeo03M?>_2C%h;A>cPUb1n6Mr3c`bx=AGb@{m#zK_)_sO& z7@kAnxZA;Bo|ERm-Y)k%hea54OVB@`V34U-Gh}{n?Ci<;dXS_l%#u_)B0F^{ z-o={ki<$0h`f|++NftD~co!1R!HJ%ck}knWIND9ZNij2N>6g4PSgCdhb&=k@TPvs8=hdW*S?1IJE&1w!bbrXL?3J+#n*~sXUV+|8iBSq=Aj@Xwy6_Q_X zC+#@D-kwUflbf`f&%v*6l`q|_O|}nv3$;FmS%#2JAU&o^QI*Qprp(qt!5JBWLDj|( zI)VR%rk@dZrD9*2QN%few_+T7bV3q0-l3waEy8rYUspb>l}nb`DGO*Ap+|KKp0$(#T_ygCm-397O?qa$Mi7YfxZOyka?x}cw5o8<;mOn zD1=uQhwtJh{l-=a;qV*9BtxN8q{FxFdj$yXS1xh@B-WDZ)&g~1^=%Mc{d9IIA5k7# z3?*YLN;86DsUT-MGkfzO^J{YL?Q+XKi*Ynds+R|}`UIT5_F9f&_b8!k)E$oLpEb9~ zlMCZn=xmF_B^~=hHui-{@;PYl!MH!}G)4~LoyX#_{{UJgRR87S#i(Thd;s9@1gTl^r?zob zC4r_wA(|?W;&>p|UGlnyhGkXQo{k2R{4bIk}i19Xwx$2&HO)=$rU~l>Q`|@Wyj;$dh60Jqrt-pK{Co8XkRe=H(~G z?UEyp{k6JRD^UOu-m@XMGlEeW2TU>?fuZc4=>WjE;0)Ew@y8Lb@I z;+V3~OcV^jZS6u}1;jb=Xu*_z`R7K`%kU^~Bs2(^fTH_VB;f;SKk?{e9^?3qY1SzU z%$+-7-kzKR*tzk1$M}OU6|z_uy?$ysJ_*8@#Zzt)wYG+HfwkY9oKTo_SF|Soc7mJz z=ZOZIlzITabc2{FS~XK`REoh1c%<=E#iWxU(+-A+D0?lh8VxP_*06T9JomOnk=iGH z7fKhNU7QE4b%>6PgRf}@;?2wN9?u7@ZRHnEls$VY4hFN`m=`PWxCP#O%z4(T#+p)( zfqv)_O5HoLvJnMs3!z+FRoEWu%{36;Xgau4p#PmikUc^(s0>I!VHGi!dRkqXh~ z)?o=uBTXap*=oewNR?CT8Q0GuA38QrkZu$7Qw{*&>QVK>kl>5y7itgUOmdceq5gAg zAa6}{5<~}J-oCaHwNb2*jl(&L=nQ}TrCr*}=>u)GQ4|>TP1SrV$v<0+k1*O{qgZe= zuW6y=eqH;_ou>Uy66~Q1m16|us$5JUgs=PT z+eKCk#ld&>m5bBufVr_%X#Qkyka^i%m*RCc%q;j0Mu>!BxR0)G<*U>Oy)t~RSZ-ei zJPmy#^Ixu;{zk3C2Lb7Cb-5qij*>7R9t^;cOGl@s= z(iQ2=aaw+;vaW1!F}Fh{bGKPVea`ic!g*%pdf%VHp%43#)-xs-_KyWqvXfonPPq+; zpDPD{GyjP^(lgTD#r=3vKdU9RJ%rOTq1vlAv+t>vfxm`3sfwUGk1IT@+#oNBi_?~8 zP|NYSVH4AUz!LgAzTNdp_qab&EkF?w`kzdWhOM=Dst;Yrj>gSOeqpg% zoSAHM!fzJgFz(3_M4pZpeB`ccrUVq=?pR5sj8U_yS*AXpgXp(jp;C1Lv=Id?&h;bm zk{xZptztU5zU!I~I?kewC~kA*t}_epn&>hnRPiRZ+)3ERW8M~QkRz_e7^aU| z*X=7wvKwj`0DosKEoucEMVLH?xhLi0_U=I)7!%~h-RJhIBjVAFygT(-vb|8-HKPVo}ZF!1U?#b zB))jZULjs;`daSHoWi{kXdl@B1i-!BAAijKg$wK=ao9;pskt6lj11wcK3k1hSy?9( zJq&5#z%q2n!VJt%D6{O+BYoY6^lj=f2$}>U4Dn&;0Xe+lgT9qDzY_kXD*$Vsb9D+J{Sqn`IQhs_;Evi1@WFv6!!O-(M;kqdbR=r7zZSk^7W? zp#6aa;Sede4IQFD4(p@K)LbeZidz#SDonGOBHF+Ze%`n9sXcQ#aU|pc@Z!1Lco4Ek z=CLt-!UzU{)qaln>ZLXhg}o$ZcAW`#JcY*5;(nv2>5G1dek1cy@J**#5UYOcQU&$+ ziRo~nRLc&cQ8ft1wP|Y3t9Fu8;+%XKy#DHx5Ruq=jPzvfBEnVq)hlOwj&Yf09!09C zZC)6)IF!XjXnu48odY$cYbN1S)<{fHson_m-ow+mok}^%uAQrE;~!YkwpgwdT})58 zWE>A{Kl8j>x)wU1ZEmOac+}5Wgc!9?zv@o8vzz`lK4y!VLXtsj#kWVFfU|9C+`xy$u3}#gUnJajB&C zWx&;*XjjW@M{Kg;Ej64d!k|;AXk%xR#@QR{q9mx{V*X8!{84<#k_i!aiMx%8=8+;e z;)lOp^3Glu(u?kEojhZlPm5&OIwO)l!~Pl%Z?Y-K87xtiJNbgHFK9!*yjewAohq}M z9Xr@C-C2|Bb(8Hy%-Qcjw+sLX&Mcokt6kN~t*cYY{VO~343Jqq;8+wergAyimFmlz zTPpBwTXS%AldiDZ@}*stw1M5n$U|)nS(R;-B&tx`pMH9w?D8M&4smKKi`V;~5V_>% z6mq8=1-er?4Ry<~pr74F?_j4OwfmTpJy=QoO%;=leUh6la6Q=Fl@Wtp(qSeV@E=9)^D~ek zJ!qkNQAsz(GB}C}^_2M0SB`Sp>#a(2e!*}gglNn|JD|lv-ZfpzATv7=Hl9>5WDsk% znw?1D`zY)mj2hwYDVKWGx5P)qXA~^FPY#`**JEiv&lZZSF!I2Jz z(Zc(*E8X+9XU5YJM!vWK!ghUbpKBMBL}#0!4u&6u_oi&I;F^(3$&Ha z`7tU9vAK+yHPUKMLvqwv%K~Jg9IA)V-FLB!H@J!PM)e+eUsL0ljZZw5A%7l|molO<}(tkpLbe>qay%2lgqkCq9b$ z@c;OL_PeYIU_JdQYKv}Dfl(fll>2LBv0+e+ed9@9>+d>-Wd4sSz9h)R{5wEn%qlXN ztVu<$wb+(iL4QJsP$*!%X?b-rHosm_)!qkv-`p$u%Z_1MA58?6n5!} zy%19}_6r!rF&Uc39B42wZBVR-ELr$JLNn-RrX2d=UZw6x;q1L_LTa9JgyTj-V|WJ(e^!o1#ub^`ky)r=zp4>h z5hT}L^ky@)4~u{egGGPQJ!1KNA8NSaL%=A%+bA2T1I^7lwu$O;?~{~@pk-KqYNw~p zG|t6Gx6C+$LxO3mX03{E9AdzD?I$D{BO}|bJHImR#&-;w1gMCC^`}v8%iBBtroPla zhzPCYIGUW(lywhhsHiTp9Ye)v$Z%{mJ*j}!qz@{nvM5DS0=9nXpM|i(wCEjgXBZ_? zUkHvvzkMEwbFULM(??H!2f*XzKsB0)NMuI8w6;VadA7XEWo)hJhI%7|yok~$kB-$g zK6nUnLY1lE*;~z)+vt7xhhZ`f7oFN;05bE=&Dh>P+Z^6ow`3)nw&V{?Ual|ug`I|r z{!y)rCM_8+7dm_W+`QhGNY(Voztt`{e*1_s7PxKH3&g*W->3cGw#((sJR(uz51UeYS=W~79sA{1+D)a`0JBiv3vIMix<#&C2V#KTy5;0@b+K7y7dKjk*$fFA!On z>g&A4`KeGV_vg*#c?>eK?6!R>NrSvxKuucs=Pu!Q5ya9{PhHS~!%b(c*SDwsc%$c? z^TUGHzi^p&_p4YgDe+K=$uD9gQd;iVx2MK);^-uS5ei;4K3A>{({$OPnlf(L2au-R zUxps-oz!yPlNA1V=I87}7kff0981P$Czyc;Vc?w|mp5NCuIh0;U*Vx*8&1!Rm<4C; zk>8y{qR~Z-mQ+2liLOV9MrC`^zpiwJoYLF{^`7rv^DQ__1JOx^Dr++)RZyERm!w*L zMGIpJd*u>U9km??-Q0Om09A9a&@+DPVDF9)F4~qdJeB~WvkHB#b;Mndfsy$OAKGK0 n8y?=@7+-llM*kY~_3mNYHxWfz4ur$~KLE%`D2kVf8om8Lqa>Hu literal 0 HcmV?d00001 diff --git a/docs/src/assets/img/write-components-with-python.png b/docs/src/assets/img/write-components-with-python.png new file mode 100644 index 0000000000000000000000000000000000000000..ba34cdf9e66c5b631a829ef1951014851b04cc50 GIT binary patch literal 15412 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 qn!hSB - -You will need to set up a Python environment to run the ReactPy-Django test suite. - -

- ---- - -## Running Tests - -This repository uses [Nox](https://nox.thea.codes/en/stable/) to run tests. For a full test of available scripts run `nox -l`. - -If you plan to run tests, you will need to install the following dependencies first: - -- [Python 3.9+](https://www.python.org/downloads/) -- [Git](https://git-scm.com/downloads) - -Once done, you should clone this repository: - -```bash linenums="0" -git clone https://github.com/reactive-python/reactpy-django.git -cd reactpy-django -pip install -e . -r requirements.txt --upgrade -``` - -## Full Test Suite - -By running the command below you can run the full test suite: - -```bash linenums="0" -nox -s test -``` - -Or, if you want to run the tests in the background: - -```bash linenums="0" -nox -s test -- --headless -``` - -## Django Tests - -If you want to only run our Django tests in your current environment, you can use the following command: - -```bash linenums="0" -cd tests -python manage.py test -``` - -## Django Test Webserver - -If you want to manually run the Django test application, you can use the following command: - -```bash linenums="0" -cd tests -python manage.py runserver -``` diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index 2dbbc4e6..d3d2eb25 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -2,8 +2,8 @@ django sanic plotly nox -websocket -websockets +WebSocket +WebSockets changelog async pre @@ -16,7 +16,6 @@ refetched refetching html jupyter -webserver iframe keyworded stylesheet diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md deleted file mode 100644 index 6c57fda3..00000000 --- a/docs/src/features/hooks.md +++ /dev/null @@ -1,328 +0,0 @@ -## Overview - -

- -Prefabricated hooks can be used within your `components.py` to help simplify development. - -

- -!!! note - - Looking for standard React hooks? - - This package only contains Django specific hooks. Standard hooks can be found within [`reactive-python/reactpy`](https://reactpy.dev/docs/reference/hooks-api.html#basic-hooks). - ---- - -## Use Query - -The `use_query` hook is used fetch Django ORM queries. - -The function you provide into this hook must return either a `Model` or `QuerySet`. - -=== "components.py" - - ```python - {% include "../../python/use-query.py" %} - ``` - -=== "models.py" - - ```python - {% include "../../python/example/models.py" %} - ``` - -??? example "See Interface" - - **Parameters** - - | Name | Type | Description | Default | - | --- | --- | --- | --- | - | `options` | `QueryOptions | None` | An optional `QueryOptions` object that can modify how the query is executed. | None | - | `query` | `Callable[_Params, _Result | None]` | A callable that returns a Django `Model` or `QuerySet`. | N/A | - | `*args` | `_Params.args` | Positional arguments to pass into `query`. | N/A | - | `**kwargs` | `_Params.kwargs` | Keyword arguments to pass into `query`. | N/A | - - **Returns** - - | Type | Description | - | --- | --- | - | `Query[_Result | None]` | An object containing `loading`/`error` states, your `data` (if the query has successfully executed), and a `refetch` callable that can be used to re-run the query. | - -??? question "How can I provide arguments to my query function?" - - `*args` and `**kwargs` can be provided to your query function via `use_query` parameters. - - === "components.py" - - ```python - {% include "../../python/use-query-args.py" %} - ``` - -??? question "Why does `get_items` in the example return `TodoItem.objects.all()`?" - - This was a technical design decision to based on [Apollo's `useQuery` hook](https://www.apollographql.com/docs/react/data/queries/), but ultimately helps avoid Django's `SynchronousOnlyOperation` exceptions. - - The `use_query` hook ensures the provided `Model` or `QuerySet` executes all [deferred](https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.get_deferred_fields)/[lazy queries](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy) safely prior to reaching your components. - -??? question "How can I use `QueryOptions` to customize fetching behavior?" - - **`thread_sensitive`** - - Whether to run your synchronous query function in thread-sensitive mode. Thread-sensitive mode is turned on by default due to Django ORM limitations. See Django's [`sync_to_async` docs](https://docs.djangoproject.com/en/dev/topics/async/#sync-to-async) docs for more information. - - This setting only applies to sync query functions, and will be ignored for async functions. - - === "components.py" - - ```python - {% include "../../python/use-query-thread-sensitive.py" %} - ``` - - --- - - **`postprocessor`** - - {% include-markdown "../../includes/orm.md" start="" end="" %} - - However, if you... - - 1. Want to use this hook to defer IO intensive tasks to be computed in the background - 2. Want to to utilize `use_query` with a different ORM - - ... then you can either set a custom `postprocessor`, or disable all postprocessing behavior by modifying the `QueryOptions.postprocessor` parameter. In the example below, we will set the `postprocessor` to `None` to disable postprocessing behavior. - - === "components.py" - - ```python - {% include "../../python/use-query-postprocessor-disable.py" %} - ``` - - If you wish to create a custom `postprocessor`, you will need to create a callable. - - The first argument of `postprocessor` must be the query `data`. All proceeding arguments - are optional `postprocessor_kwargs` (see below). This `postprocessor` must return - the modified `data`. - - === "components.py" - - ```python - {% include "../../python/use-query-postprocessor-change.py" %} - ``` - - --- - - **`postprocessor_kwargs`** - - {% include-markdown "../../includes/orm.md" start="" end="" %} - - However, if you have deep nested trees of relational data, this may not be a desirable behavior. In these scenarios, you may prefer to manually fetch these relational fields using a second `use_query` hook. - - You can disable the prefetching behavior of the default `postprocessor` (located at `reactpy_django.utils.django_query_postprocessor`) via the `QueryOptions.postprocessor_kwargs` parameter. - - === "components.py" - - ```python - {% include "../../python/use-query-postprocessor-kwargs.py" %} - ``` - - _Note: In Django's ORM design, the field name to access foreign keys is [postfixed with `_set`](https://docs.djangoproject.com/en/dev/topics/db/examples/many_to_one/) by default._ - -??? question "Can I define async query functions?" - - Async functions are supported by `use_query`. You can use them in the same way as a sync query function. - - However, be mindful of Django async ORM restrictions. - - === "components.py" - - ```python - {% include "../../python/use-query-async.py" %} - ``` - -??? question "Can I make ORM calls without hooks?" - - {% include-markdown "../../includes/orm.md" start="" end="" %} - -## Use Mutation - -The `use_mutation` hook is used to create, update, or delete Django ORM objects. - -The function you provide into this hook will have no return value. - -=== "components.py" - - ```python - {% include "../../python/use-mutation.py" %} - ``` - -=== "models.py" - - ```python - {% include "../../python/example/models.py" %} - ``` - -??? example "See Interface" - - **Parameters** - - | Name | Type | Description | Default | - | --- | --- | --- | --- | - | `mutate` | `Callable[_Params, bool | None]` | A callable that performs Django ORM create, update, or delete functionality. If this function returns `False`, then your `refetch` function will not be used. | N/A | - | `refetch` | `Callable[..., Any] | Sequence[Callable[..., Any]] | None` | A `query` function (used by the `use_query` hook) or a sequence of `query` functions that will be called if the mutation succeeds. This is useful for refetching data after a mutation has been performed. | `None` | - - **Returns** - - | Type | Description | - | --- | --- | - | `Mutation[_Params]` | An object containing `loading`/`error` states, a `reset` callable that will set `loading`/`error` states to defaults, and a `execute` callable that will run the query. | - -??? question "How can I provide arguments to my mutation function?" - - `*args` and `**kwargs` can be provided to your mutation function via `mutation.execute` parameters. - - === "components.py" - - ```python - {% include "../../python/use-mutation-args-kwargs.py" %} - ``` - -??? question "Can `use_mutation` trigger a refetch of `use_query`?" - - Yes, `use_mutation` can queue a refetch of a `use_query` via the `refetch=...` argument. - - The example below is a merge of the `use_query` and `use_mutation` examples above with the addition of a `refetch` argument on `use_mutation`. - - Please note that any `use_query` hooks that use `get_items` will be refetched upon a successful mutation. - - === "components.py" - - ```python - {% include "../../python/use-mutation-query-refetch.py" %} - ``` - - === "models.py" - - ```python - {% include "../../python/example/models.py" %} - ``` - -??? question "Can I make a failed `use_mutation` try again?" - - Yes, a `use_mutation` can be re-performed by calling `reset()` on your `use_mutation` instance. - - For example, take a look at `reset_event` below. - - === "components.py" - - ```python - {% include "../../python/use-mutation-reset.py" %} - ``` - - === "models.py" - - ```python - {% include "../../python/example/models.py" %} - ``` - -??? question "Can I make ORM calls without hooks?" - - {% include-markdown "../../includes/orm.md" start="" end="" %} - -## Use Connection - -You can fetch the Django Channels [websocket](https://channels.readthedocs.io/en/stable/topics/consumers.html#asyncjsonwebsocketconsumer) at any time by using `use_connection`. - -=== "components.py" - - ```python - {% include "../../python/use-connection.py" %} - ``` - -??? example "See Interface" - - **Parameters** - - `None` - - **Returns** - - | Type | Description | - | --- | --- | - | `Connection` | The component's websocket. | - -## Use Scope - -This is a shortcut that returns the Websocket's [`scope`](https://channels.readthedocs.io/en/stable/topics/consumers.html#scope). - -=== "components.py" - - ```python - {% include "../../python/use-scope.py" %} - ``` - -??? example "See Interface" - - **Parameters** - - `None` - - **Returns** - - | Type | Description | - | --- | --- | - | `MutableMapping[str, Any]` | The websocket's `scope`. | - -## Use Location - -This is a shortcut that returns the Websocket's `path`. - -You can expect this hook to provide strings such as `/reactpy/my_path`. - -=== "components.py" - - ```python - {% include "../../python/use-location.py" %} - ``` - -??? example "See Interface" - - **Parameters** - - `None` - - **Returns** - - | Type | Description | - | --- | --- | - | `Location` | A object containing the current URL's `pathname` and `search` query. | - -??? info "This hook's behavior will be changed in a future update" - - This hook will be updated to return the browser's currently active path. This change will come in alongside ReactPy URL routing support. - - Check out [reactive-python/reactpy-router#2](https://github.com/idom-team/idom-router/issues/2) for more information. - -## Use Origin - -This is a shortcut that returns the Websocket's `origin`. - -You can expect this hook to provide strings such as `http://example.com`. - -=== "components.py" - - ```python - {% include "../../python/use-origin.py" %} - ``` - -??? example "See Interface" - - **Parameters** - - `None` - - **Returns** - - | Type | Description | - | --- | --- | - | `str | None` | A string containing the browser's current origin, obtained from websocket headers (if available). | diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md deleted file mode 100644 index 3917d766..00000000 --- a/docs/src/features/settings.md +++ /dev/null @@ -1,38 +0,0 @@ -## Overview - -

- -Your **Django project's** `settings.py` can modify the behavior of ReactPy. - -

- -!!! note - - The default configuration of ReactPy is suitable for the vast majority of use cases. - - You should only consider changing settings when the necessity arises. - ---- - -## Primary Configuration - - - -These are ReactPy-Django's default settings values. You can modify these values in your **Django project's** `settings.py` to change the behavior of ReactPy. - -| Setting | Default Value | Example Value(s) | Description | -| --- | --- | --- | --- | -| `REACTPY_CACHE` | `#!python "default"` | `#!python "my-reactpy-cache"` | Cache used to store ReactPy web modules. ReactPy benefits from a fast, well indexed cache.
We recommend installing [`redis`](https://redis.io/) or [`python-diskcache`](https://grantjenks.com/docs/diskcache/tutorial.html#djangocache). | -| `REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Database used to store ReactPy session data. ReactPy requires a multiprocessing-safe and thread-safe database.
If configuring `REACTPY_DATABASE`, it is mandatory to use our database router like such:
`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` | -| `REACTPY_SESSION_MAX_AGE` | `#!python 259200` | `#!python 0`, `#!python 60`, `#!python 96000` | Maximum seconds to store ReactPy session data, such as `args` and `kwargs` passed into your component template tag.
Use `#!python 0` to not store any session data. | -| `REACTPY_URL_PREFIX` | `#!python "reactpy/"` | `#!python "rp/"`, `#!python "render/reactpy/"` | The prefix to be used for all ReactPy websocket and HTTP URLs. | -| `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python None`, `#!python "example_project.my_query_postprocessor"` | Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function. Postprocessor functions can be async or sync, and the parameters must contain the arg `#!python data`. Set `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` to `#!python None` to globally disable the default postprocessor. | -| `REACTPY_AUTH_BACKEND` | `#!python "django.contrib.auth.backends.ModelBackend"` | `#!python "example_project.auth.MyModelBackend"` | Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:
1. You are using `AuthMiddlewareStack` and...
2. You are using Django's `AUTHENTICATION_BACKENDS` setting and...
3. Your Django user model does not define a `backend` attribute. | -| `REACTPY_BACKHAUL_THREAD` | `#!python False` | `#!python True` | Whether to render ReactPy components in a dedicated thread. This allows the webserver to process web traffic while during ReactPy rendering.
Vastly improves throughput with web servers such as `hypercorn` and `uvicorn`. | -| `REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir" ]` | The default host(s) that can render your ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing.
You can use the `host` argument in your [template tag](../features/template-tag.md#component) as a manual override. | -| `REACTPY_RECONNECT_INTERVAL` | `#!python 750` | `#!python 100`, `#!python 2500`, `#!python 6000` | Milliseconds between client reconnection attempts. This value will gradually increase if `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` is greater than `#!python 1`. | -| `REACTPY_RECONNECT_MAX_INTERVAL` | `#!python 60000` | `#!python 10000`, `#!python 25000`, `#!python 900000` | Maximum milliseconds between client reconnection attempts. This allows setting an upper bound on how high `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` can increase the time between reconnection attempts. | -| `REACTPY_RECONNECT_MAX_RETRIES` | `#!python 150` | `#!python 0`, `#!python 5`, `#!python 300` | Maximum number of reconnection attempts before the client gives up. | -| `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` | `#!python 1.25` | `#!python 1`, `#!python 1.5`, `#!python 3` | Multiplier for the time between client reconnection attempts. On each reconnection attempt, the `REACTPY_RECONNECT_INTERVAL` will be multiplied by this to increase the time between attempts. You can keep time between each reconnection the same by setting this to `#!python 1`. | - - diff --git a/docs/src/features/utils.md b/docs/src/features/utils.md deleted file mode 100644 index 9ba8e312..00000000 --- a/docs/src/features/utils.md +++ /dev/null @@ -1,65 +0,0 @@ -## Overview - -

- -Utility functions provide various miscellaneous functionality. These are typically not used, but are available for advanced use cases. - -

- ---- - -## Django Query Postprocessor - -This is the default postprocessor for the `use_query` hook. - -This postprocessor is designed to avoid Django's `SynchronousOnlyException` by recursively fetching all fields within a `Model` or `QuerySet` to prevent [lazy execution](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy). - -=== "components.py" - - ```python - {% include "../../python/django-query-postprocessor.py" %} - ``` - -=== "models.py" - - ```python - {% include "../../python/example/models.py" %} - ``` - -??? example "See Interface" - - **Parameters** - - | Name | Type | Description | Default | - | --- | --- | --- | --- | - | `data` | `QuerySet | Model` | The `Model` or `QuerySet` to recursively fetch fields from. | N/A | - | `many_to_many` | `bool` | Whether or not to recursively fetch `ManyToManyField` relationships. | `True` | - | `many_to_one` | `bool` | Whether or not to recursively fetch `ForeignKey` relationships. | `True` | - - **Returns** - - | Type | Description | - | --- | --- | - | `QuerySet | Model` | The `Model` or `QuerySet` with all fields fetched. | - -## Register Component - -The `register_component` function is used manually register a root component with ReactPy. - -You should always call `register_component` within a Django [`AppConfig.ready()` method](https://docs.djangoproject.com/en/4.2/ref/applications/#django.apps.AppConfig.ready) to retain compatibility with ASGI webserver workers. - -=== "apps.py" - - ```python - {% include "../../python/register-component.py" %} - ``` - -??? question "Do I need to register my components?" - - You typically will not need to use this function. - - For security reasons, ReactPy does not allow non-registered components to be root components. However, all components contained within Django templates are automatically considered root components. - - You only need to use this function if your host application does not contain any HTML templates that [reference](../features/template-tag.md#component) your components. - - A common scenario where this is needed is when you are modifying the [template tag `host = ...` argument](../features/template-tag.md#component) in order to configure a dedicated Django application as a rendering server for ReactPy. On this dedicated rendering server, you would need to manually register your components. diff --git a/docs/src/get-started/choose-django-app.md b/docs/src/get-started/choose-django-app.md deleted file mode 100644 index 1594baa5..00000000 --- a/docs/src/get-started/choose-django-app.md +++ /dev/null @@ -1,26 +0,0 @@ -## Overview - -

- -Set up a **Django Project** with at least one app. - -

- -!!! note - - If you have reached this point, you should have already [installed ReactPy-Django](../get-started/installation.md) through the previous steps. - ---- - -## Deciding which Django App to use - -You will now need to pick at least one **Django app** to start using ReactPy-Django on. - -For the following examples, we will assume the following: - -1. You have a **Django app** named `my_app`, which was created by Django's [`startapp` command](https://docs.djangoproject.com/en/dev/intro/tutorial01/#creating-the-polls-app). -2. You have placed `my_app` directly into your **Django project** folder (`./example_project/my_app`). This is common for small projects. - -??? question "How do I organize my Django project for ReactPy?" - - ReactPy-Django has no project structure requirements. Organize everything as you wish, just like any **Django project**. diff --git a/docs/src/get-started/create-component.md b/docs/src/get-started/create-component.md deleted file mode 100644 index 1f94c308..00000000 --- a/docs/src/get-started/create-component.md +++ /dev/null @@ -1,40 +0,0 @@ -## Overview - -

- -You can let ReactPy know what functions are components by using the `#!python @component` decorator. - -

- ---- - -## Declaring a function as a root component - -You will need a file to start creating ReactPy components. - -We recommend creating a `components.py` file within your chosen **Django app** to start out. For this example, the file path will look like this: `./example_project/my_app/components.py`. - -Within this file, you can define your component functions and then add ReactPy's `#!python @component` decorator. - -=== "components.py" - - {% include-markdown "../../../README.md" start="" end="" %} - -??? question "What should I name my ReactPy files and functions?" - - You have full freedom in naming/placement of your files and functions. - - We recommend creating a `components.py` for small **Django apps**. If your app has a lot of components, you should consider breaking them apart into individual modules such as `components/navbar.py`. - - Ultimately, components are referenced by Python dotted path in `my-template.html` ([_see next step_](./use-template-tag.md)). So, at minimum your component path needs to be valid to Python's `importlib`. - -??? question "What does the decorator actually do?" - - While not all components need to be decorated, there are a few features this decorator adds to your components. - - 1. The ability to be used as a root component. - - The decorator is required for any component that you want to reference in your Django templates ([_see next step_](./use-template-tag.md)). - 2. The ability to use [hooks](../features/hooks.md). - - The decorator is required on any component where hooks are defined. - 3. Scoped failures. - - If a decorated component generates an exception, then only that one component will fail to render. diff --git a/docs/src/get-started/installation.md b/docs/src/get-started/installation.md deleted file mode 100644 index be727947..00000000 --- a/docs/src/get-started/installation.md +++ /dev/null @@ -1,125 +0,0 @@ -## Overview - -

- -[ReactPy-Django](https://github.com/reactive-python/reactpy-django) can be used to add used to add [ReactPy](https://github.com/reactive-python/reactpy) support to an existing **Django project**. Minimal configuration is required to get started. - -

- -!!! note - - These docs assumes you have already created [a **Django project**](https://docs.djangoproject.com/en/dev/intro/tutorial01/), which involves creating and installing at least one **Django app**. - - If do not have a **Django project**, check out this [9 minute YouTube tutorial](https://www.youtube.com/watch?v=ZsJRXS_vrw0) created by _IDG TECHtalk_. - ---- - -## Step 1: Install from PyPI - -```bash linenums="0" -pip install reactpy-django -``` - -## Step 2: Configure [`settings.py`](https://docs.djangoproject.com/en/dev/topics/settings/) - -In your settings you will need to add `reactpy_django` to [`INSTALLED_APPS`](https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-INSTALLED_APPS). - -=== "settings.py" - - ```python - {% include "../../python/configure-installed-apps.py" %} - ``` - -??? warning "Enable Django Channels ASGI (Required)" - - ReactPy-Django requires ASGI Websockets from [Django Channels](https://github.com/django/channels). - - If you have not enabled ASGI on your **Django project** yet, you will need to - - 1. Install `channels[daphne]` - 2. Add `daphne` to `INSTALLED_APPS` - 3. Set your `ASGI_APPLICATION` variable. - - === "settings.py" - - ```python - {% include "../../python/configure-channels.py" %} - ``` - - Consider reading the [Django Channels Docs](https://channels.readthedocs.io/en/stable/installation.html) for more info. - -??? note "Configure ReactPy settings (Optional)" - - {% include "../features/settings.md" start="" end="" %} - -## Step 3: Configure [`urls.py`](https://docs.djangoproject.com/en/dev/topics/http/urls/) - -Add ReactPy HTTP paths to your `urlpatterns`. - -=== "urls.py" - - ```python - {% include "../../python/configure-urls.py" %} - ``` - -## Step 4: Configure [`asgi.py`](https://docs.djangoproject.com/en/dev/howto/deployment/asgi/) - -Register ReactPy's Websocket using `REACTPY_WEBSOCKET_ROUTE`. - -=== "asgi.py" - - ```python - {% include "../../python/configure-asgi.py" %} - ``` - -??? note "Add `AuthMiddlewareStack` and `SessionMiddlewareStack` (Optional)" - - There are many situations where you need to access the Django `User` or `Session` objects within ReactPy components. For example, if you want to: - - 1. Access the `User` that is currently logged in - 2. Login or logout the current `User` - 3. Access Django's `Session` object - - In these situations will need to ensure you are using `AuthMiddlewareStack` and/or `SessionMiddlewareStack`. - - ```python linenums="0" - {% include "../../python/configure-asgi-middleware.py" start="# start" %} - ``` - -??? question "Where is my `asgi.py`?" - - If you do not have an `asgi.py`, follow the [`channels` installation guide](https://channels.readthedocs.io/en/stable/installation.html). - -## Step 5: Run database migrations - -Run Django's database migrations to initialize ReactPy-Django's database table. - -```bash linenums="0" -python manage.py migrate -``` - -## Step 6: Check your configuration - -Run Django's check command to verify if ReactPy was set up correctly. - -```bash linenums="0" -python manage.py check -``` - -## Step 7: Create your first component! - -The [following steps](./choose-django-app.md) will show you how to create your first ReactPy component. - -Prefer a quick summary? Read the **At a Glance** section below. - -!!! info "At a Glance" - - **`my_app/components.py`** - - {% include-markdown "../../../README.md" start="" end="" %} - - --- - - **`my_app/templates/my-template.html`** - - {% include-markdown "../../../README.md" start="" end="" %} diff --git a/docs/src/get-started/learn-more.md b/docs/src/get-started/learn-more.md deleted file mode 100644 index 3ee45968..00000000 --- a/docs/src/get-started/learn-more.md +++ /dev/null @@ -1,17 +0,0 @@ -# :confetti_ball: Congratulations :confetti_ball: - -

- -If you followed the previous steps, you have now created a "Hello World" component using ReactPy-Django! - -

- -!!! info "Deep Dive" - - The docs you are reading only covers our Django integration. To learn more, check out one of the following links: - - - [ReactPy-Django Feature Reference](../features/components.md) - - [ReactPy Core Documentation](https://reactpy.dev/docs/guides/creating-interfaces/index.html) - - [Ask Questions on Discord](https://discord.gg/uNb5P4hA9X) - - Additionally, the vast majority of tutorials/guides you find for ReactJS can be applied to ReactPy. diff --git a/docs/src/get-started/register-view.md b/docs/src/get-started/register-view.md deleted file mode 100644 index 472d722b..00000000 --- a/docs/src/get-started/register-view.md +++ /dev/null @@ -1,41 +0,0 @@ -## Overview - -

- -Render your template containing your ReactPy component using a Django view. - -

- -!!! Note - - We assume you have [created a Django View](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view) before, but we have included a simple example below. - ---- - -## Creating a Django view and URL path - -Within your **Django app**'s `views.py` file, you will need to create a function to render the HTML template containing your ReactPy components. - -In this example, we will create a view that renders `my-template.html` ([_from the previous step_](./use-template-tag.md)). - -=== "views.py" - - ```python - {% include "../../python/example/views.py" %} - ``` - -We will add this new view into your [`urls.py`](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view). - -=== "urls.py" - - ```python - {% include "../../python/example/urls.py" %} - ``` - -??? 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/#include) to link it all together. diff --git a/docs/src/get-started/run-webserver.md b/docs/src/get-started/run-webserver.md deleted file mode 100644 index cb4f87f1..00000000 --- a/docs/src/get-started/run-webserver.md +++ /dev/null @@ -1,27 +0,0 @@ -## Overview - -

- -Run a webserver to display your Django view. - -

- ---- - -## Viewing your component using a webserver - -To test your new Django view, run the following command to start up a development webserver. - -```bash linenums="0" -python manage.py runserver -``` - -Now you can navigate to your **Django project** URL that contains a ReactPy component, such as [`http://127.0.0.1:8000/example/`](http://127.0.0.1:8000/example/) ([_from the previous step_](./register-view.md)). - -If you copy-pasted our example component, you will now see your component display "Hello World". - -!!! warning "Pitfall" - - Do not use `manage.py runserver` for production. - - This command is only intended for development purposes. For production deployments make sure to read [Django's documentation](https://docs.djangoproject.com/en/dev/howto/deployment/). diff --git a/docs/src/get-started/use-template-tag.md b/docs/src/get-started/use-template-tag.md deleted file mode 100644 index 8ef210fd..00000000 --- a/docs/src/get-started/use-template-tag.md +++ /dev/null @@ -1,27 +0,0 @@ -## Overview - -

- -Decide where the component will be displayed by using our template tag. - -

- ---- - -## Embedding a component in a template - -{% include-markdown "../../../README.md" start="" end="" %} - -Additionally, you can pass in `args` and `kwargs` into your component function. After reading the code below, pay attention to how the function definition for `hello_world` ([_from the previous step_](./create-component.md)) accepts a `recipient` argument. - -=== "my-template.html" - - {% include-markdown "../../../README.md" start="" end="" %} - -{% include-markdown "../features/template-tag.md" start="" end="" %} - -{% include-markdown "../features/template-tag.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/src/index.md b/docs/src/index.md index 6b8b3aaa..384ec5b6 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1,17 +1,6 @@ --- +template: home.html hide: - navigation - toc --- - -![ReactPy Django](https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg){ align=left style=height:40px } - -# ReactPy Django - -{% include-markdown "../../README.md" start="" end="" %} - -{% include-markdown "../../README.md" start="" end="" %} - -## Resources - -{% include-markdown "../../README.md" start="" end="" %} diff --git a/docs/src/learn/add-reactpy-to-a-django-project.md b/docs/src/learn/add-reactpy-to-a-django-project.md new file mode 100644 index 00000000..7d7c949f --- /dev/null +++ b/docs/src/learn/add-reactpy-to-a-django-project.md @@ -0,0 +1,128 @@ +## Overview + +

+ +If you want to add some interactivity to your existing **Django project**, you don't have to rewrite it in ReactPy. Use [ReactPy-Django](https://github.com/reactive-python/reactpy-django) to add [ReactPy](https://github.com/reactive-python/reactpy) to your existing stack, and render interactive components anywhere. + +

+ +!!! note + + These docs assumes you have already created [a **Django project**](https://docs.djangoproject.com/en/dev/intro/tutorial01/), which involves creating and installing at least one **Django app**. + + If do not have a **Django project**, check out this [9 minute YouTube tutorial](https://www.youtube.com/watch?v=ZsJRXS_vrw0) created by _IDG TECHtalk_. + +--- + +## Step 1: Install from PyPI + +Run the following command to install [`reactpy-django`](https://pypi.org/project/reactpy-django/) in your Python environment. + +```bash linenums="0" +pip install reactpy-django +``` + +## Step 2: Configure `settings.py` + +Add `#!python "reactpy_django"` to [`INSTALLED_APPS`](https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-INSTALLED_APPS) in your [`settings.py`](https://docs.djangoproject.com/en/dev/topics/settings/) file. + +=== "settings.py" + + ```python + {% include "../../python/configure-installed-apps.py" %} + ``` + +??? warning "Enable ASGI and Django Channels (Required)" + + ReactPy-Django requires Django ASGI and [Django Channels](https://github.com/django/channels) WebSockets. + + If you have not enabled ASGI on your **Django project** yet, here is a summary of the [`django`](https://docs.djangoproject.com/en/dev/howto/deployment/asgi/) and [`channels`](https://channels.readthedocs.io/en/stable/installation.html) installation docs: + + 1. Install `channels[daphne]` + 2. Add `#!python "daphne"` to `#!python INSTALLED_APPS`. + + ```python linenums="0" + {% include "../../python/configure-channels-installed-app.py" %} + ``` + + 3. Set your `#!python ASGI_APPLICATION` variable. + + ```python linenums="0" + {% include "../../python/configure-channels-asgi-app.py" %} + ``` + +??? note "Configure ReactPy settings (Optional)" + + {% include "../reference/settings.md" start="" end="" %} + +## Step 3: Configure `urls.py` + +Add ReactPy HTTP paths to your `#!python urlpatterns` in your [`urls.py`](https://docs.djangoproject.com/en/dev/topics/http/urls/) file. + +=== "urls.py" + + ```python + {% include "../../python/configure-urls.py" %} + ``` + +## Step 4: Configure `asgi.py` + +Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [`asgi.py`](https://docs.djangoproject.com/en/dev/howto/deployment/asgi/) file. + +=== "asgi.py" + + ```python + {% include "../../python/configure-asgi.py" %} + ``` + +??? note "Add `#!python AuthMiddlewareStack` and `#!python SessionMiddlewareStack` (Optional)" + + There are many situations where you need to access the Django `#!python User` or `#!python Session` objects within ReactPy components. For example, if you want to: + + 1. Access the `#!python User` that is currently logged in + 2. Login or logout the current `#!python User` + 3. Access Django's `#!python Session` object + + In these situations will need to ensure you are using `#!python AuthMiddlewareStack` and/or `#!python SessionMiddlewareStack`. + + ```python linenums="0" + {% include "../../python/configure-asgi-middleware.py" start="# start" %} + ``` + +??? question "Where is my `asgi.py`?" + + If you do not have an `asgi.py`, follow the [`channels` installation guide](https://channels.readthedocs.io/en/stable/installation.html). + +## Step 5: Run database migrations + +Run Django's [`migrate` command](https://docs.djangoproject.com/en/dev/topics/migrations/) to initialize ReactPy-Django's database table. + +```bash linenums="0" +python manage.py migrate +``` + +## Step 6: Check your configuration + +Run Django's [`check` command](https://docs.djangoproject.com/en/dev/ref/django-admin/#check) to verify if ReactPy was set up correctly. + +```bash linenums="0" +python manage.py check +``` + +## Step 7: Create your first component + +The [next step](./your-first-component.md) will show you how to create your first ReactPy component. + +Prefer a quick summary? Read the **At a Glance** section below. + +!!! info "At a Glance: Your First Component" + + **`my_app/components.py`** + + {% include-markdown "../../../README.md" start="" end="" %} + + --- + + **`my_app/templates/my-template.html`** + + {% include-markdown "../../../README.md" start="" end="" %} diff --git a/docs/src/learn/your-first-component.md b/docs/src/learn/your-first-component.md new file mode 100644 index 00000000..e7ddcd65 --- /dev/null +++ b/docs/src/learn/your-first-component.md @@ -0,0 +1,131 @@ +## Overview + +

+ +Components are one of the core concepts of ReactPy. They are the foundation upon which you build user interfaces (UI), which makes them the perfect place to start your journey! + +

+ +!!! note + + If you have reached this point, you should have already [installed ReactPy-Django](../learn/add-reactpy-to-a-django-project.md) through the previous steps. + +--- + +## Selecting a Django App + +You will now need to pick at least one **Django app** to start using ReactPy-Django on. + +For the following examples, we will assume the following: + +1. You have a **Django app** named `my_app`, which was created by Django's [`startapp` command](https://docs.djangoproject.com/en/dev/intro/tutorial01/#creating-the-polls-app). +2. You have placed `my_app` directly into your **Django project** folder (`./example_project/my_app`). This is common for small projects. + +??? question "How do I organize my Django project for ReactPy?" + + ReactPy-Django has no project structure requirements. Organize everything as you wish, just like any **Django project**. + +## Defining a component + +You will need a file to start creating ReactPy components. + +We recommend creating a `components.py` file within your chosen **Django app** to start out. For this example, the file path will look like this: `./example_project/my_app/components.py`. + +Within this file, you can define your component functions using ReactPy's `#!python @component` decorator. + +=== "components.py" + + {% include-markdown "../../../README.md" start="" end="" %} + +??? question "What should I name my ReactPy files and functions?" + + You have full freedom in naming/placement of your files and functions. + + We recommend creating a `components.py` for small **Django apps**. If your app has a lot of components, you should consider breaking them apart into individual modules such as `components/navbar.py`. + + Ultimately, components are referenced by Python dotted path in `my-template.html` ([_see next step_](#embedding-in-a-template)). This path must be valid to Python's `#!python importlib`. + +??? question "What does the decorator actually do?" + + While not all components need to be decorated, there are a few features this decorator adds to your components. + + 1. The ability to be used as a root component. + - The decorator is required for any component that you want to reference in your Django templates ([_see next step_](#embedding-in-a-template)). + 2. The ability to use [hooks](../reference/hooks.md). + - The decorator is required on any component where hooks are defined. + 3. Scoped failures. + - If a decorated component generates an exception, then only that one component will fail to render. + +## Embedding in a template + +In your **Django app**'s HTML template, you can now embed your ReactPy component using the `#!jinja {% component %}` template tag. Within this tag, you will need to type in the dotted path to the component. + +Additionally, you can pass in `#!python args` and `#!python kwargs` into your component function. After reading the code below, pay attention to how the function definition for `#!python hello_world` ([_from the previous step_](#defining-a-component)) accepts a `#!python recipient` argument. + +=== "my-template.html" + + {% include-markdown "../../../README.md" start="" end="" %} + +{% include-markdown "../reference/template-tag.md" start="" end="" %} + +{% include-markdown "../reference/template-tag.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). + +## Setting up a Django view + +Within your **Django app**'s `views.py` file, you will need to [create a view function](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view) to render the HTML template `my-template.html` ([_from the previous step_](#embedding-in-a-template)). + +=== "views.py" + + ```python + {% include "../../python/example/views.py" %} + ``` + +We will add this new view into your [`urls.py`](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view) and define what URL it should be accessible at. + +=== "urls.py" + + ```python + {% include "../../python/example/urls.py" %} + ``` + +??? 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/#include) to link it all together. + +## Viewing your component + +To test your new Django view, run the following command to start up a development web server. + +```bash linenums="0" +python manage.py runserver +``` + +Now you can navigate to your **Django project** URL that contains a ReactPy component, such as [`http://127.0.0.1:8000/example/`](http://127.0.0.1:8000/example/) ([_from the previous step_](#setting-up-a-django-view)). + +If you copy-pasted our example component, you will now see your component display "Hello World". + +??? warning "Do not use `manage.py runserver` for production" + + This command is only intended for development purposes. For production deployments make sure to read [Django's documentation](https://docs.djangoproject.com/en/dev/howto/deployment/). + +## Learn more + +**Congratulations!** If you followed the previous steps, you have now created a "Hello World" component using ReactPy-Django! + +!!! info "Deep Dive" + + The docs you are reading only covers our Django integration. To learn more, check out one of the following links: + + - [ReactPy-Django Feature Reference](../reference/components.md) + - [ReactPy Core Documentation](https://reactpy.dev/docs/guides/creating-interfaces/index.html) + - [Ask Questions on Discord](https://discord.gg/uNb5P4hA9X) + + Additionally, the vast majority of tutorials/guides you find for ReactJS can be applied to ReactPy. diff --git a/docs/src/features/components.md b/docs/src/reference/components.md similarity index 51% rename from docs/src/features/components.md rename to docs/src/reference/components.md index f4dae25a..d3f235da 100644 --- a/docs/src/features/components.md +++ b/docs/src/reference/components.md @@ -24,24 +24,24 @@ Convert any Django view into a ReactPy component by using this decorator. Compat | Name | Type | Description | Default | | --- | --- | --- | --- | - | `view` | `Callable | View` | The view function or class to convert. | N/A | - | `compatibility` | `bool` | If True, the component will be rendered in an iframe. When using compatibility mode `tranforms`, `strict_parsing`, `request`, `args`, and `kwargs` arguments will be ignored. | `False` | - | `transforms` | `Sequence[Callable[[VdomDict], Any]]` | A list of functions that transforms the newly generated VDOM. The functions will be called on each VDOM node. | `tuple` | - | `strict_parsing` | `bool` | If True, an exception will be generated if the HTML does not perfectly adhere to HTML5. | `True` | + | `#!python view` | `#!python Callable | View` | The view function or class to convert. | N/A | + | `#!python compatibility` | `#!python bool` | If `#!python True`, the component will be rendered in an iframe. When using compatibility mode `#!python tranforms`, `#!python strict_parsing`, `#!python request`, `#!python args`, and `#!python kwargs` arguments will be ignored. | `#!python False` | + | `#!python transforms` | `#!python Sequence[Callable[[VdomDict], Any]]` | A list of functions that transforms the newly generated VDOM. The functions will be called on each VDOM node. | `#!python tuple` | + | `#!python strict_parsing` | `#!python bool` | If `#!python True`, an exception will be generated if the HTML does not perfectly adhere to HTML5. | `#!python True` | **Returns** | Type | Description | | --- | --- | - | `_ViewComponentConstructor` | A function that takes `request, *args, key, **kwargs` and returns a ReactPy component. All parameters are directly provided to your view, besides `key` which is used by ReactPy. | + | `#!python _ViewComponentConstructor` | A function that takes `#!python request, *args, key, **kwargs` and returns a ReactPy component. All parameters are directly provided to your view, besides `#!python key` which is used by ReactPy. | -??? Warning "Potential information exposure when using `compatibility = True`" +??? Warning "Potential information exposure when using `#!python compatibility = True`" - When using `compatibility` mode, ReactPy automatically exposes a URL to your view. + When using `#!python compatibility` mode, ReactPy automatically exposes a URL to your view. It is your responsibility to ensure privileged information is not leaked via this method. - You must implement a method to ensure only authorized users can access your view. This can be done via directly writing conditionals into your view, or by adding decorators such as [`user_passes_test`](https://docs.djangoproject.com/en/dev/topics/auth/default/#django.contrib.auth.decorators.user_passes_test) to your views. For example... + You must implement a method to ensure only authorized users can access your view. This can be done via directly writing conditionals into your view, or by adding decorators such as [`#!python user_passes_test`](https://docs.djangoproject.com/en/dev/topics/auth/default/#django.contrib.auth.decorators.user_passes_test) to your views. For example... === "Function Based View" @@ -57,17 +57,17 @@ Convert any Django view into a ReactPy component by using this decorator. Compat ??? info "Existing limitations" - There are currently several limitations of using `view_to_component` that may be resolved in a future version of `reactpy_django`. + There are currently several limitations of using `#!python view_to_component` that may be resolved in a future version. - Requires manual intervention to change request methods beyond `GET`. - ReactPy events cannot conveniently be attached to converted view HTML. - Has no option to automatically intercept local anchor link (such as `#!html `) click events. - _Please note these limitations do not exist when using `compatibility` mode._ + _Please note these limitations do not exist when using `#!python compatibility` mode._ ??? question "How do I use this for Class Based Views?" - You can simply pass your Class Based View directly into `view_to_component`. + You can simply pass your Class Based View directly into `#!python view_to_component`. === "components.py" @@ -77,7 +77,7 @@ Convert any Django view into a ReactPy component by using this decorator. Compat ??? question "How do I transform views from external libraries?" - In order to convert external views, you can utilize `view_to_component` as a function, rather than a decorator. + In order to convert external views, you can utilize `#!python view_to_component` as a function, rather than a decorator. === "components.py" @@ -85,11 +85,11 @@ Convert any Django view into a ReactPy component by using this decorator. Compat {% include "../../python/vtc-func.py" %} ``` -??? question "How do I provide `request`, `args`, and `kwargs` to a view?" +??? question "How do I provide `#!python request`, `#!python args`, and `#!python kwargs` to a view?" - **`Request`** + **`#!python Request`** - You can use the `request` parameter to provide the view a custom request object. + You can use the `#!python request` parameter to provide the view a custom request object. === "components.py" @@ -99,9 +99,9 @@ Convert any Django view into a ReactPy component by using this decorator. Compat --- - **`args` and `kwargs`** + **`#!python args` and `#!python kwargs`** - You can use the `args` and `kwargs` parameters to provide positional and keyworded arguments to a view. + You can use the `#!python args` and `#!python kwargs` parameters to provide positional and keyworded arguments to a view. === "components.py" @@ -109,15 +109,15 @@ Convert any Django view into a ReactPy component by using this decorator. Compat {% include "../../python/vtc-args-kwargs.py" %} ``` -??? question "How do I use `strict_parsing`, `compatibility`, and `transforms`?" +??? question "How do I use `#!python strict_parsing`, `#!python compatibility`, and `#!python transforms`?" - **`strict_parsing`** + **`#!python strict_parsing`** By default, an exception will be generated if your view's HTML does not perfectly adhere to HTML5. However, there are some circumstances where you may not have control over the original HTML, so you may be unable to fix it. Or you may be relying on non-standard HTML tags such as `#!html Hello World `. - In these scenarios, you may want to rely on best-fit parsing by setting the `strict_parsing` parameter to `False`. + In these scenarios, you may want to rely on best-fit parsing by setting the `#!python strict_parsing` parameter to `#!python False`. === "components.py" @@ -129,11 +129,11 @@ Convert any Django view into a ReactPy component by using this decorator. Compat --- - **`compatibility`** + **`#!python compatibility`** For views that rely on HTTP responses other than `GET` (such as `PUT`, `POST`, `PATCH`, etc), you should consider using compatibility mode to render your view within an iframe. - Any view can be rendered within compatibility mode. However, the `transforms`, `strict_parsing`, `request`, `args`, and `kwargs` arguments do not apply to compatibility mode. + Any view can be rendered within compatibility mode. However, the `#!python transforms`, `#!python strict_parsing`, `#!python request`, `#!python args`, and `#!python kwargs` arguments do not apply to compatibility mode. @@ -143,17 +143,17 @@ Convert any Django view into a ReactPy component by using this decorator. Compat {% include "../../python/vtc-compatibility.py" %} ``` - _Note: By default the `compatibility` iframe is unstyled, and thus won't look pretty until you add some CSS._ + _Note: By default the `#!python compatibility` iframe is unstyled, and thus won't look pretty until you add some CSS._ --- - **`transforms`** + **`#!python transforms`** - After your view has been turned into [VDOM](https://reactpy.dev/docs/reference/specifications.html#vdom) (python dictionaries), `view_to_component` will call your `transforms` functions on every VDOM node. + After your view has been turned into [VDOM](https://reactpy.dev/docs/reference/specifications.html#vdom) (python dictionaries), `#!python view_to_component` will call your `#!python transforms` functions on every VDOM node. This allows you to modify your view prior to rendering. - For example, if you are trying to modify the text of a node with a certain `id`, you can create a transform like such: + For example, if you are trying to modify the text of a node with a certain `#!python id`, you can create a transform like such: === "components.py" @@ -177,22 +177,22 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering. | Name | Type | Description | Default | | --- | --- | --- | --- | - | `static_path` | `str` | The path to the static file. This path is identical to what you would use on a `static` template tag. | N/A | - | `key` | `Key | None` | A key to uniquely identify this component which is unique amongst a component's immediate siblings | `None` | + | `#!python static_path` | `#!python str` | The path to the static file. This path is identical to what you would use on a `static` template tag. | N/A | + | `#!python key` | `#!python Key | None` | A key to uniquely identify this component which is unique amongst a component's immediate siblings | `#!python None` | **Returns** | Type | Description | | --- | --- | - | `Component` | A ReactPy component. | + | `#!python Component` | A ReactPy component. | -??? question "Should I put `django_css` at the top of my HTML?" +??? question "Should I put `#!python django_css` at the top of my HTML?" Yes, if the stylesheet contains styling for your component. -??? question "Can I load static CSS using `html.link` instead?" +??? question "Can I load static CSS using `#!python html.link` instead?" - While you can load stylesheets with `html.link`, keep in mind that loading this way **does not** ensure load order. Thus, your stylesheet will be loaded after your component is displayed. This would likely cause unintended visual behavior, so use this at your own discretion. + While you can load stylesheets with `#!python html.link`, keep in mind that loading this way **does not** ensure load order. Thus, your stylesheet will be loaded after your component is displayed. This would likely cause unintended visual behavior, so use this at your own discretion. Here's an example on what you should avoid doing for Django static files: @@ -202,9 +202,9 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering. ??? question "How do I load external CSS?" - `django_css` can only be used with local static files. + `#!python django_css` can only be used with local static files. - For external CSS, substitute `django_css` with `html.link`. + For external CSS, substitute `#!python django_css` with `#!python html.link`. ```python {% include "../../python/django-css-external-link.py" %} @@ -214,7 +214,7 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering. Traditionally, stylesheets are loaded in your `#!html ` using the `#!jinja {% load static %}` template tag. - To help improve webpage load times, you can use the `django_css` component to defer loading your stylesheet until it is needed. + To help improve webpage load times, you can use the `#!python django_css` component to defer loading your stylesheet until it is needed. ## Django JS @@ -232,22 +232,22 @@ Allows you to defer loading JavaScript until a component begins rendering. This | Name | Type | Description | Default | | --- | --- | --- | --- | - | `static_path` | `str` | The path to the static file. This path is identical to what you would use on a `static` template tag. | N/A | - | `key` | `Key | None` | A key to uniquely identify this component which is unique amongst a component's immediate siblings | `None` | + | `#!python static_path` | `#!python str` | The path to the static file. This path is identical to what you would use on a `static` template tag. | N/A | + | `#!python key` | `#!python Key | None` | A key to uniquely identify this component which is unique amongst a component's immediate siblings | `#!python None` | **Returns** | Type | Description | | --- | --- | - | `Component` | A ReactPy component. | + | `#!python Component` | A ReactPy component. | -??? question "Should I put `django_js` at the bottom of my HTML?" +??? question "Should I put `#!python django_js` at the bottom of my HTML?" Yes, if your scripts are reliant on the contents of the component. -??? question "Can I load static JavaScript using `html.script` instead?" +??? question "Can I load static JavaScript using `#!python html.script` instead?" - While you can load JavaScript with `html.script`, keep in mind that loading this way **does not** ensure load order. Thus, your JavaScript will likely be loaded at an arbitrary time after your component is displayed. + While you can load JavaScript with `#!python html.script`, keep in mind that loading this way **does not** ensure load order. Thus, your JavaScript will likely be loaded at an arbitrary time after your component is displayed. Here's an example on what you should avoid doing for Django static files: @@ -257,9 +257,9 @@ Allows you to defer loading JavaScript until a component begins rendering. This ??? question "How do I load external JS?" - `django_js` can only be used with local static files. + `#!python django_js` can only be used with local static files. - For external JavaScript, substitute `django_js` with `html.script`. + For external JavaScript, substitute `#!python django_js` with `#!python html.script`. ```python {% include "../../python/django-js-remote-script.py" %} @@ -269,4 +269,4 @@ Allows you to defer loading JavaScript until a component begins rendering. This Traditionally, JavaScript is loaded in your `#!html ` using the `#!jinja {% load static %}` template tag. - To help improve webpage load times, you can use the `django_js` component to defer loading your JavaScript until it is needed. + To help improve webpage load times, you can use the `#!python django_js` component to defer loading your JavaScript until it is needed. diff --git a/docs/src/features/decorators.md b/docs/src/reference/decorators.md similarity index 51% rename from docs/src/features/decorators.md rename to docs/src/reference/decorators.md index ee79a4e8..59440366 100644 --- a/docs/src/features/decorators.md +++ b/docs/src/reference/decorators.md @@ -10,11 +10,11 @@ Decorator functions can be used within your `components.py` to help simplify dev ## Auth Required -You can limit access to a component to users with a specific `auth_attribute` by using this decorator (with or without parentheses). +You can limit access to a component to users with a specific `#!python auth_attribute` by using this decorator (with or without parentheses). -By default, this decorator checks if the user is logged in and not deactivated (`is_active`). +By default, this decorator checks if the user is logged in and not deactivated (`#!python is_active`). -This decorator is commonly used to selectively render a component only if a user [`is_staff`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.User.is_staff) or [`is_superuser`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.User.is_superuser). +This decorator is commonly used to selectively render a component only if a user [`#!python is_staff`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.User.is_staff) or [`#!python is_superuser`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.User.is_superuser). === "components.py" @@ -28,20 +28,20 @@ This decorator is commonly used to selectively render a component only if a user | Name | Type | Description | Default | | --- | --- | --- | --- | - | `auth_attribute` | `str` | The value to check within the user object. This is checked in the form of `UserModel.`. | `#!python "is_active"` | - | `fallback` | `ComponentType`, `VdomDict`, `None` | The `component` or `reactpy.html` snippet to render if the user is not authenticated. | `None` | + | `#!python auth_attribute` | `#!python str` | The value to check within the user object. This is checked via `#!python getattr(scope["user"], auth_attribute)`. | `#!python "is_active"` | + | `#!python fallback` | `#!python ComponentType | VdomDict | None` | The `#!python component` or `#!python reactpy.html` snippet to render if the user is not authenticated. | `#!python None` | **Returns** | Type | Description | | --- | --- | - | `Component` | A ReactPy component. | - | `VdomDict` | An `reactpy.html` snippet. | - | `None` | No component render. | + | #!python Component` | A ReactPy component. | + | #!python VdomDict` | A `#!python reactpy.html` snippet. | + | #!python None` | No component render. | ??? question "How do I render a different component if authentication fails?" - You can use a component with the `fallback` argument, as seen below. + You can use a component with the `#!python fallback` argument, as seen below. === "components.py" @@ -49,9 +49,9 @@ This decorator is commonly used to selectively render a component only if a user {% include "../../python/auth-required-component-fallback.py" %} ``` -??? question "How do I render a simple `reactpy.html` snippet if authentication fails?" +??? question "How do I render a simple `#!python reactpy.html` snippet if authentication fails?" - You can use a `reactpy.html` snippet with the `fallback` argument, as seen below. + You can use a `#!python reactpy.html` snippet with the `#!python fallback` argument, as seen below. === "components.py" @@ -59,9 +59,9 @@ This decorator is commonly used to selectively render a component only if a user {% include "../../python/auth-required-vdom-fallback.py" %} ``` -??? question "How can I check if a user `is_staff`?" +??? question "How can I check if a user `#!python is_staff`?" - You can set the `auth_attribute` to `is_staff`, as seen blow. + You can do this by setting `#!python auth_attribute="is_staff"`, as seen blow. === "components.py" @@ -73,7 +73,7 @@ This decorator is commonly used to selectively render a component only if a user You will need to be using a [custom user model](https://docs.djangoproject.com/en/dev/topics/auth/customizing/#specifying-a-custom-user-model) within your Django instance. - For example, if your user model has the field `is_really_cool` ... + For example, if your user model has the field `#!python is_really_cool` ... === "models.py" diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md new file mode 100644 index 00000000..62986930 --- /dev/null +++ b/docs/src/reference/hooks.md @@ -0,0 +1,328 @@ +## Overview + +

+ +Prefabricated hooks can be used within your `components.py` to help simplify development. + +

+ +!!! note + + Looking for standard React hooks? + + This package only contains Django specific hooks. Standard hooks can be found within [`reactive-python/reactpy`](https://reactpy.dev/docs/reference/hooks-api.html#basic-hooks). + +--- + +## Use Query + +This hook is used [read](https://www.sumologic.com/glossary/crud/) data from the Django ORM. + +The query function you provide must return either a `#!python Model` or `#!python QuerySet`. + +=== "components.py" + + ```python + {% include "../../python/use-query.py" %} + ``` + +=== "models.py" + + ```python + {% include "../../python/example/models.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | `#!python options` | `#!python QueryOptions | None` | An optional `#!python QueryOptions` object that can modify how the query is executed. | `#!python None` | + | `#!python query` | `#!python Callable[_Params, _Result | None]` | A callable that returns a Django `#!python Model` or `#!python QuerySet`. | N/A | + | `#!python *args` | `#!python _Params.args` | Positional arguments to pass into `#!python query`. | N/A | + | `#!python **kwargs` | `#!python _Params.kwargs` | Keyword arguments to pass into `#!python query`. | N/A | + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python Query[_Result | None]` | An object containing `#!python loading`/`#!python error` states, your `#!python data` (if the query has successfully executed), and a `#!python refetch` callable that can be used to re-run the query. | + +??? question "How can I provide arguments to my query function?" + + `#!python *args` and `#!python **kwargs` can be provided to your query function via `#!python use_query` parameters. + + === "components.py" + + ```python + {% include "../../python/use-query-args.py" %} + ``` + +??? question "Why does `#!python get_items` in the example return `#!python TodoItem.objects.all()`?" + + This was a technical design decision to based on [Apollo's `#!javascript useQuery` hook](https://www.apollographql.com/docs/react/data/queries/), but ultimately helps avoid Django's `#!python SynchronousOnlyOperation` exceptions. + + The `#!python use_query` hook ensures the provided `#!python Model` or `#!python QuerySet` executes all [deferred](https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.get_deferred_fields)/[lazy queries](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy) safely prior to reaching your components. + +??? question "How can I use `#!python QueryOptions` to customize fetching behavior?" + + **`#!python thread_sensitive`** + + Whether to run your synchronous query function in thread-sensitive mode. Thread-sensitive mode is turned on by default due to Django ORM limitations. See Django's [`#!python sync_to_async` docs](https://docs.djangoproject.com/en/dev/topics/async/#sync-to-async) docs for more information. + + This setting only applies to sync query functions, and will be ignored for async functions. + + === "components.py" + + ```python + {% include "../../python/use-query-thread-sensitive.py" %} + ``` + + --- + + **`#!python postprocessor`** + + {% include-markdown "../../includes/orm.md" start="" end="" %} + + However, if you... + + 1. Want to use this hook to defer IO intensive tasks to be computed in the background + 2. Want to to utilize `#!python use_query` with a different ORM + + ... then you can either set a custom `#!python postprocessor`, or disable all postprocessing behavior by modifying the `#!python QueryOptions.postprocessor` parameter. In the example below, we will set the `#!python postprocessor` to `#!python None` to disable postprocessing behavior. + + === "components.py" + + ```python + {% include "../../python/use-query-postprocessor-disable.py" %} + ``` + + If you wish to create a custom `#!python postprocessor`, you will need to create a callable. + + The first argument of `#!python postprocessor` must be the query `#!python data`. All proceeding arguments + are optional `#!python postprocessor_kwargs` (see below). This `#!python postprocessor` must return + the modified `#!python data`. + + === "components.py" + + ```python + {% include "../../python/use-query-postprocessor-change.py" %} + ``` + + --- + + **`#!python postprocessor_kwargs`** + + {% include-markdown "../../includes/orm.md" start="" end="" %} + + However, if you have deep nested trees of relational data, this may not be a desirable behavior. In these scenarios, you may prefer to manually fetch these relational fields using a second `#!python use_query` hook. + + You can disable the prefetching behavior of the default `#!python postprocessor` (located at `#!python reactpy_django.utils.django_query_postprocessor`) via the `#!python QueryOptions.postprocessor_kwargs` parameter. + + === "components.py" + + ```python + {% include "../../python/use-query-postprocessor-kwargs.py" %} + ``` + + _Note: In Django's ORM design, the field name to access foreign keys is [postfixed with `_set`](https://docs.djangoproject.com/en/dev/topics/db/examples/many_to_one/) by default._ + +??? question "Can I define async query functions?" + + Async functions are supported by `#!python use_query`. You can use them in the same way as a sync query function. + + However, be mindful of Django async ORM restrictions. + + === "components.py" + + ```python + {% include "../../python/use-query-async.py" %} + ``` + +??? question "Can I make ORM calls without hooks?" + + {% include-markdown "../../includes/orm.md" start="" end="" %} + +## Use Mutation + +This hook is used to [create, update, or delete](https://www.sumologic.com/glossary/crud/) Django ORM objects. + +The mutation function you provide should have no return value. + +=== "components.py" + + ```python + {% include "../../python/use-mutation.py" %} + ``` + +=== "models.py" + + ```python + {% include "../../python/example/models.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | `#!python mutate` | `#!python Callable[_Params, bool | None]` | A callable that performs Django ORM create, update, or delete functionality. If this function returns `#!python False`, then your `#!python refetch` function will not be used. | N/A | + | `#!python refetch` | `#!python Callable[..., Any] | Sequence[Callable[..., Any]] | None` | A `#!python query` function (used by the `#!python use_query` hook) or a sequence of `#!python query` functions that will be called if the mutation succeeds. This is useful for refetching data after a mutation has been performed. | `#!python None` | + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python Mutation[_Params]` | An object containing `#!python loading`/`#!python error` states, a `#!python reset` callable that will set `#!python loading`/`#!python error` states to defaults, and a `#!python execute` callable that will run the query. | + +??? question "How can I provide arguments to my mutation function?" + + `#!python *args` and `#!python **kwargs` can be provided to your mutation function via #!python mutation.execute` parameters. + + === "components.py" + + ```python + {% include "../../python/use-mutation-args-kwargs.py" %} + ``` + +??? question "Can `#!python use_mutation` trigger a refetch of `#!python use_query`?" + + Yes, `#!python use_mutation` can queue a refetch of a `#!python use_query` via the `#!python refetch=...` argument. + + The example below is a merge of the `#!python use_query` and `#!python use_mutation` examples above with the addition of a `#!python use_mutation(refetch=...)` argument. + + Please note that any `#!python use_query` hooks that use `#!python get_items` will be refetched upon a successful mutation. + + === "components.py" + + ```python + {% include "../../python/use-mutation-query-refetch.py" %} + ``` + + === "models.py" + + ```python + {% include "../../python/example/models.py" %} + ``` + +??? question "Can I make a failed `#!python use_mutation` try again?" + + Yes, a `#!python use_mutation` can be re-performed by calling `#!python reset()` on your `#!python use_mutation` instance. + + For example, take a look at `#!python reset_event` below. + + === "components.py" + + ```python + {% include "../../python/use-mutation-reset.py" %} + ``` + + === "models.py" + + ```python + {% include "../../python/example/models.py" %} + ``` + +??? question "Can I make ORM calls without hooks?" + + {% include-markdown "../../includes/orm.md" start="" end="" %} + +## Use Connection + +This hook is used to fetch the Django Channels [WebSocket](https://channels.readthedocs.io/en/stable/topics/consumers.html#asyncjsonwebsocketconsumer). + +=== "components.py" + + ```python + {% include "../../python/use-connection.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + `#!python None` + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python Connection` | The component's WebSocket. | + +## Use Scope + +This is a shortcut that returns the WebSocket's [`#!python scope`](https://channels.readthedocs.io/en/stable/topics/consumers.html#scope). + +=== "components.py" + + ```python + {% include "../../python/use-scope.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + `#!python None` + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python MutableMapping[str, Any]` | The WebSocket's `#!python scope`. | + +## Use Location + +This is a shortcut that returns the WebSocket's `#!python path`. + +You can expect this hook to provide strings such as `/reactpy/my_path`. + +=== "components.py" + + ```python + {% include "../../python/use-location.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + `#!python None` + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python Location` | An object containing the current URL's `#!python pathname` and `#!python search` query. | + +??? info "This hook's behavior will be changed in a future update" + + This hook will be updated to return the browser's currently active HTTP path. This change will come in alongside ReactPy URL routing support. + + Check out [reactive-python/reactpy-django#147](https://github.com/reactive-python/reactpy-django/issues/147) for more information. + +## Use Origin + +This is a shortcut that returns the WebSocket's `#!python origin`. + +You can expect this hook to provide strings such as `http://example.com`. + +=== "components.py" + + ```python + {% include "../../python/use-origin.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + `#!python None` + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python str | None` | A string containing the browser's current origin, obtained from WebSocket headers (if available). | diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md new file mode 100644 index 00000000..9f108a0d --- /dev/null +++ b/docs/src/reference/settings.md @@ -0,0 +1,38 @@ +## Overview + +

+ +Your **Django project's** `settings.py` can modify the behavior of ReactPy. + +

+ +!!! note + + The default configuration of ReactPy is suitable for the vast majority of use cases. + + You should only consider changing settings when the necessity arises. + +--- + +## Primary Configuration + + + +These are ReactPy-Django's default settings values. You can modify these values in your **Django project's** `settings.py` to change the behavior of ReactPy. + +| Setting | Default Value | Example Value(s) | Description | +| --- | --- | --- | --- | +| `#!python REACTPY_CACHE` | `#!python "default"` | `#!python "my-reactpy-cache"` | Cache used to store ReactPy web modules. ReactPy benefits from a fast, well indexed cache. We recommend installing [`redis`](https://redis.io/) or [`python-diskcache`](https://grantjenks.com/docs/diskcache/tutorial.html#djangocache). | +| `#!python REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Database used to store ReactPy session data. ReactPy requires a multiprocessing-safe and thread-safe database. If configuring `#!python REACTPY_DATABASE`, it is mandatory to enable our database router like such:
`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` | +| `#!python REACTPY_SESSION_MAX_AGE` | `#!python 259200` | `#!python 0`, `#!python 60`, `#!python 96000` | Maximum seconds to store ReactPy session data, such as `#!python args` and `#!python kwargs` passed into your component template tag. Use `#!python 0` to not store any session data. | +| `#!python REACTPY_URL_PREFIX` | `#!python "reactpy/"` | `#!python "rp/"`, `#!python "render/reactpy/"` | The prefix to be used for all ReactPy WebSocket and HTTP URLs. | +| `#!python REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python None`, `#!python "example_project.my_query_postprocessor"` | Dotted path to the default `#!python reactpy_django.hooks.use_query` postprocessor function. Postprocessor functions can be async or sync, and the parameters must contain the arg `#!python data`. Set `#!python REACTPY_DEFAULT_QUERY_POSTPROCESSOR` to `#!python None` to globally disable the default postprocessor. | +| `#!python REACTPY_AUTH_BACKEND` | `#!python "django.contrib.auth.backends.ModelBackend"` | `#!python "example_project.auth.MyModelBackend"` | Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:
1. You are using `#!python AuthMiddlewareStack` and...
2. You are using Django's `#!python AUTHENTICATION_BACKENDS` setting and...
3. Your Django user model does not define a `#!python backend` attribute. | +| `#!python REACTPY_BACKHAUL_THREAD` | `#!python False` | `#!python True` | Whether to render ReactPy components in a dedicated thread. This allows the web server to process traffic during ReactPy rendering. Vastly improves throughput with web servers such as [`hypercorn`](https://pgjones.gitlab.io/hypercorn/) and [`uvicorn`](https://www.uvicorn.org/). | +| `#!python REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir" ]` | The default host(s) that can render your ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing. You can use the `#!python host` argument in your [template tag](../reference/template-tag.md#component) as a manual override. | +| `#!python REACTPY_RECONNECT_INTERVAL` | `#!python 750` | `#!python 100`, `#!python 2500`, `#!python 6000` | Milliseconds between client reconnection attempts. This value will gradually increase if `#!python REACTPY_RECONNECT_BACKOFF_MULTIPLIER` is greater than `#!python 1`. | +| `#!python REACTPY_RECONNECT_MAX_INTERVAL` | `#!python 60000` | `#!python 10000`, `#!python 25000`, `#!python 900000` | Maximum milliseconds between client reconnection attempts. This allows setting an upper bound on how high `#!python REACTPY_RECONNECT_BACKOFF_MULTIPLIER` can increase the time between reconnection attempts. | +| `#!python REACTPY_RECONNECT_MAX_RETRIES` | `#!python 150` | `#!python 0`, `#!python 5`, `#!python 300` | Maximum number of reconnection attempts before the client gives up. | +| `#!python REACTPY_RECONNECT_BACKOFF_MULTIPLIER` | `#!python 1.25` | `#!python 1`, `#!python 1.5`, `#!python 3` | Multiplier for the time between client reconnection attempts. On each reconnection attempt, the `#!python REACTPY_RECONNECT_INTERVAL` will be multiplied by this to increase the time between attempts. You can keep time between each reconnection the same by setting this to `#!python 1`. | + + diff --git a/docs/src/features/template-tag.md b/docs/src/reference/template-tag.md similarity index 69% rename from docs/src/features/template-tag.md rename to docs/src/reference/template-tag.md index e04c5bb9..6f20be36 100644 --- a/docs/src/features/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -10,7 +10,7 @@ Django template tags can be used within your HTML templates to provide ReactPy f ## Component -The `component` template tag can be used to insert any number of ReactPy components onto your page. +This template tag can be used to insert any number of ReactPy components onto your page. === "my-template.html" @@ -22,18 +22,18 @@ The `component` template tag can be used to insert any number of ReactPy compone | Name | Type | Description | Default | | --- | --- | --- | --- | - | `dotted_path` | `str` | The dotted path to the component to render. | N/A | - | `*args` | `Any` | The positional arguments to provide to the component. | N/A | - | `class` | `str | None` | The HTML class to apply to the top-level component div. | `None` | - | `key` | `str | None` | Force the component's root node to use a [specific key value](https://reactpy.dev/docs/guides/creating-interfaces/rendering-data/index.html#organizing-items-with-keys). Using `key` within a template tag is effectively useless. | `None` | - | `host` | `str | None` | The host to use for the ReactPy connections. If set to `None`, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir` | `None` | - | `**kwargs` | `Any` | The keyword arguments to provide to the component. | N/A | + | `#!python dotted_path` | `#!python str` | The dotted path to the component to render. | N/A | + | `#!python *args` | `#!python Any` | The positional arguments to provide to the component. | N/A | + | `#!python class` | `#!python str | None` | The HTML class to apply to the top-level component div. | `#!python None` | + | `#!python key` | `#!python str | None` | Force the component's root node to use a [specific key value](https://reactpy.dev/docs/guides/creating-interfaces/rendering-data/index.html#organizing-items-with-keys). Using `#!python key` within a template tag is effectively useless. | `#!python None` | + | `#!python host` | `#!python str | None` | The host to use for the ReactPy connections. If set to `#!python None`, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir` | `#!python None` | + | `#!python **kwargs` | `#!python Any` | The keyword arguments to provide to the component. | N/A | **Returns** | Type | Description | | --- | --- | - | `Component` | A ReactPy component. | + | `#!python Component` | A ReactPy component. | @@ -65,7 +65,7 @@ The `component` template tag can be used to insert any number of ReactPy compone ??? question "Can I render components on a different server (distributed computing)?" - Yes! By using the `host` keyword argument, you can render components from a completely separate ASGI server. + Yes! By using the `#!python host` keyword argument, you can render components from a completely separate ASGI server. === "my-template.html" @@ -80,8 +80,8 @@ The `component` template tag can be used to insert any number of ReactPy compone Here's a couple of things to keep in mind: 1. If your host address are completely separate ( `origin1.com != origin2.com` ) you will need to [configure CORS headers](https://pypi.org/project/django-cors-headers/) on your main application during deployment. - 2. You will not need to register ReactPy HTTP or websocket paths on any applications that do not perform any component rendering. - 3. Your component will only be able to access `*args`/`**kwargs` you provide to the template tag if your applications share a common database. + 2. You will not need to register ReactPy HTTP or WebSocket paths on any applications that do not perform any component rendering. + 3. Your component will only be able to access `#!python *args`/`#!python **kwargs` you provide to the template tag if your applications share a common database. @@ -112,7 +112,7 @@ The `component` template tag can be used to insert any number of ReactPy compone ??? question "Can I use positional arguments instead of keyword arguments?" - You can use any combination of `*args`/`**kwargs` in your template tag. + You can use any combination of `#!python *args`/`#!python **kwargs` in your template tag. === "my-template.html" diff --git a/docs/src/reference/utils.md b/docs/src/reference/utils.md new file mode 100644 index 00000000..d71facc2 --- /dev/null +++ b/docs/src/reference/utils.md @@ -0,0 +1,65 @@ +## Overview + +

+ +Utility functions provide various miscellaneous functionality. These are typically not used, but are available for advanced use cases. + +

+ +--- + +## Django Query Postprocessor + +This is the default postprocessor for the `#!python use_query` hook. + +This postprocessor is designed to avoid Django's `#!python SynchronousOnlyException` by recursively fetching all fields within a `#!python Model` or `#!python QuerySet` to prevent [lazy execution](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy). + +=== "components.py" + + ```python + {% include "../../python/django-query-postprocessor.py" %} + ``` + +=== "models.py" + + ```python + {% include "../../python/example/models.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | `#!python data` | `#!python QuerySet | Model` | The `#!python Model` or `#!python QuerySet` to recursively fetch fields from. | N/A | + | `#!python many_to_many` | `#!python bool` | Whether or not to recursively fetch `#!python ManyToManyField` relationships. | `#!python True` | + | `#!python many_to_one` | `#!python bool` | Whether or not to recursively fetch `#!python ForeignKey` relationships. | `#!python True` | + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python QuerySet | Model` | The `#!python Model` or `#!python QuerySet` with all fields fetched. | + +## Register Component + +This function is used manually register a root component with ReactPy. + +=== "apps.py" + + ```python + {% include "../../python/register-component.py" %} + ``` + +??? warning "Only use this within `#!python AppConfig.ready()`" + + You should always call `#!python register_component` within a Django [`#!python AppConfig.ready()` method](https://docs.djangoproject.com/en/4.2/ref/applications/#django.apps.AppConfig.ready). This ensures you will retain multiprocessing compatibility, such as with ASGI web server workers. + +??? question "Do I need to use this?" + + You typically will not need to use this function. + + For security reasons, ReactPy does not allow non-registered components to be root components. However, all components contained within Django templates are automatically considered root components. + + This is typically only needed when you have a dedicated Django application as a rendering server that doesn't have templates, such as when modifying the [template tag `#!python host` argument](../reference/template-tag.md#component). On this dedicated rendering server, you would need to manually register your components. diff --git a/docs/src/static/css/extra.css b/docs/src/static/css/extra.css deleted file mode 100644 index d3967666..00000000 --- a/docs/src/static/css/extra.css +++ /dev/null @@ -1,376 +0,0 @@ -/* Variable overrides */ -:root { - --code-max-height: 17.25rem; -} - -[data-md-color-scheme="slate"] { - --md-code-hl-color: #ffffcf1c; - --md-hue: 225; - --md-default-bg-color: hsla(var(--md-hue), 15%, 16%, 1); - --md-default-bg-color--light: hsla(var(--md-hue), 15%, 16%, 0.54); - --md-default-bg-color--lighter: hsla(var(--md-hue), 15%, 16%, 0.26); - --md-default-bg-color--lightest: hsla(var(--md-hue), 15%, 16%, 0.07); - --md-code-bg-color: #16181d; - --md-primary-fg-color: #2b3540; - --md-default-fg-color--light: #fff; - --md-typeset-a-color: #00b0f0; - --md-code-hl-comment-color: hsla(var(--md-hue), 75%, 90%, 0.43); - --tabbed-labels-color: rgb(52 58 70); -} - -[data-md-color-scheme="default"] { - --tabbed-labels-color: #7d829e26; -} - -/* General admonition styling */ -/* TODO: Write this in a way that supports the light theme */ -[data-md-color-scheme="slate"] .md-typeset details, -[data-md-color-scheme="slate"] .md-typeset .admonition { - border-color: transparent !important; -} - -.md-typeset :is(.admonition, details) { - margin: 0.55em 0; -} - -.md-typeset .admonition { - font-size: 0.7rem; -} - -.md-typeset .admonition:focus-within, -.md-typeset details:focus-within { - box-shadow: var(--md-shadow-z1) !important; -} - -/* Colors for "summary" admonition */ -[data-md-color-scheme="slate"] .md-typeset .admonition.summary { - background: #353a45; - padding: 0.8rem 1.4rem; - border-radius: 0.8rem; -} - -[data-md-color-scheme="slate"] .md-typeset .summary .admonition-title { - font-size: 1rem; - background: transparent; - padding-left: 0.6rem; - padding-bottom: 0; -} - -[data-md-color-scheme="slate"] .md-typeset .summary .admonition-title:before { - display: none; -} - -[data-md-color-scheme="slate"] .md-typeset .admonition.summary { - border-color: #ffffff17 !important; -} - -/* Colors for "note" admonition */ -[data-md-color-scheme="slate"] .md-typeset .admonition.note { - background: rgb(43 110 98/ 0.2); - padding: 0.8rem 1.4rem; - border-radius: 0.8rem; -} - -[data-md-color-scheme="slate"] .md-typeset .note .admonition-title { - font-size: 1rem; - background: transparent; - padding-bottom: 0; - color: rgb(68 172 153); -} - -[data-md-color-scheme="slate"] .md-typeset .note .admonition-title:before { - font-size: 1.1rem; - background-color: rgb(68 172 153); -} - -.md-typeset .note > .admonition-title:before, -.md-typeset .note > summary:before { - -webkit-mask-image: var(--md-admonition-icon--abstract); - mask-image: var(--md-admonition-icon--abstract); -} - -/* Colors for "warning" admonition */ -[data-md-color-scheme="slate"] .md-typeset .admonition.warning { - background: rgb(182 87 0 / 0.2); - padding: 0.8rem 1.4rem; - border-radius: 0.8rem; -} - -[data-md-color-scheme="slate"] .md-typeset .warning .admonition-title { - font-size: 1rem; - background: transparent; - padding-bottom: 0; - color: rgb(219 125 39); -} - -[data-md-color-scheme="slate"] .md-typeset .warning .admonition-title:before { - font-size: 1.1rem; - background-color: rgb(219 125 39); -} - -/* Colors for "info" admonition */ -[data-md-color-scheme="slate"] .md-typeset .admonition.info { - background: rgb(43 52 145 / 0.2); - padding: 0.8rem 1.4rem; - border-radius: 0.8rem; -} - -[data-md-color-scheme="slate"] .md-typeset .info .admonition-title { - font-size: 1rem; - background: transparent; - padding-bottom: 0; - color: rgb(136 145 236); -} - -[data-md-color-scheme="slate"] .md-typeset .info .admonition-title:before { - font-size: 1.1rem; - background-color: rgb(136 145 236); -} - -/* Colors for "example" admonition */ -[data-md-color-scheme="slate"] .md-typeset .admonition.example { - background: rgb(94 104 126); - border-radius: 0.4rem; -} - -[data-md-color-scheme="slate"] .md-typeset .example .admonition-title { - background: rgb(78 87 105); - color: rgb(246 247 249); -} - -[data-md-color-scheme="slate"] .md-typeset .example .admonition-title:before { - background-color: rgb(246 247 249); -} - -[data-md-color-scheme="slate"] .md-typeset .admonition.example code { - background: transparent; - color: #fff; -} - -/* Move the sidebars to the edges of the page */ -.md-main__inner.md-grid { - margin-left: 0; - margin-right: 0; - max-width: unset; - display: flex; - justify-content: center; -} - -.md-sidebar--primary { - margin-right: auto; -} - -.md-sidebar.md-sidebar--secondary { - margin-left: auto; -} - -.md-content { - max-width: 56rem; -} - -/* Maintain content positioning even if sidebars are disabled */ -@media screen and (min-width: 76.1875em) { - .md-sidebar { - display: block; - } - - .md-sidebar[hidden] { - visibility: hidden; - } -} - -/* Sidebar styling */ -@media screen and (min-width: 76.1875em) { - .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { - text-transform: uppercase; - } - - .md-nav__title[for="__toc"] { - text-transform: uppercase; - margin: 0.5rem; - } - - .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { - color: rgb(133 142 159); - margin: 0.5rem; - } - - .md-nav__item .md-nav__link { - position: relative; - } - - .md-nav__link:is(:focus, :hover):not(.md-nav__link--active) { - color: unset; - } - - .md-nav__item - .md-nav__link:is(:focus, :hover):not(.md-nav__link--active):before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.2; - z-index: -1; - background-color: grey; - } - - .md-nav__item .md-nav__link--active:before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.15; - z-index: -1; - background-color: var(--md-typeset-a-color); - } - - .md-nav__link { - padding: 0.5rem 0.5rem 0.5rem 1rem; - margin: 0; - border-radius: 0 10px 10px 0; - font-weight: 600; - overflow: hidden; - } - - .md-sidebar__scrollwrap { - margin: 0; - } - - [dir="ltr"] - .md-nav--lifted - .md-nav[data-md-level="1"] - > .md-nav__list - > .md-nav__item { - padding: 0; - } - - .md-nav__item--nested .md-nav__item .md-nav__item { - padding: 0; - } - - .md-nav__item--nested .md-nav__item .md-nav__item .md-nav__link { - font-weight: 300; - } - - .md-nav__item--nested .md-nav__item .md-nav__item .md-nav__link { - font-weight: 400; - padding-left: 1.25rem; - } -} - -/* Table of Contents styling */ -@media screen and (min-width: 60em) { - [data-md-component="sidebar"] .md-nav__title[for="__toc"] { - text-transform: uppercase; - margin: 0.5rem; - margin-left: 0; - } - - [data-md-component="toc"] .md-nav__item .md-nav__link--active { - position: relative; - } - - [data-md-component="toc"] .md-nav__item .md-nav__link--active:before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.15; - z-index: -1; - background-color: var(--md-typeset-a-color); - } - - [data-md-component="toc"] .md-nav__link { - padding: 0.5rem 0.5rem; - margin: 0; - border-radius: 10px 0 0 10px; - } - [dir="ltr"] .md-sidebar__inner { - padding: 0; - } - - .md-nav__item { - padding: 0; - } -} - -/* Font changes */ -.md-typeset { - font-weight: 300; -} - -.md-typeset h1 { - font-weight: 500; - margin: 0; - font-size: 2.5em; -} - -.md-typeset h2 { - font-weight: 500; -} - -.md-typeset h3 { - font-weight: 600; -} - -/* Intro section styling */ -p.intro { - font-size: 0.9rem; - font-weight: 500; -} - -/* Hide invisible jump selectors */ -h2#overview { - visibility: hidden; - height: 0; - margin: 0; - padding: 0; -} - -/* Code blocks */ -.md-typeset pre > code { - border-radius: 16px; -} - -.md-typeset .highlighttable .linenos { - max-height: var(--code-max-height); - overflow: hidden; -} - -.md-typeset .tabbed-block .highlighttable code { - border-radius: 0; -} - -.md-typeset .tabbed-block { - border-bottom-left-radius: 8px; - border-bottom-right-radius: 8px; - overflow: hidden; -} - -.js .md-typeset .tabbed-labels { - background: var(--tabbed-labels-color); - border-top-left-radius: 8px; - border-top-right-radius: 8px; -} - -.md-typeset .tabbed-labels > label { - font-weight: 400; - font-size: 0.7rem; - padding-top: 0.55em; - padding-bottom: 0.35em; -} - -.md-typeset pre > code { - max-height: var(--code-max-height); -} - -/* Reduce height of outdated banner */ -.md-banner__inner { - margin: 0.45rem auto; -} diff --git a/mkdocs.yml b/mkdocs.yml index ae5129bc..e4e19c65 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,28 +2,22 @@ nav: - Home: index.md - Get Started: - - Install ReactPy-Django: get-started/installation.md - - Choose a Django App: get-started/choose-django-app.md - - Create a Component: get-started/create-component.md - - Use the Template Tag: get-started/use-template-tag.md - - Register a View: get-started/register-view.md - - Run the Webserver: get-started/run-webserver.md - - Learn More: get-started/learn-more.md + - Add ReactPy to a Django Project: learn/add-reactpy-to-a-django-project.md + - Your First Component: learn/your-first-component.md - Reference: - - Components: features/components.md - - Hooks: features/hooks.md - - Decorators: features/decorators.md - - Utilities: features/utils.md - - Template Tag: features/template-tag.md - - Settings: features/settings.md + - Components: reference/components.md + - Hooks: reference/hooks.md + - Decorators: reference/decorators.md + - Utilities: reference/utils.md + - Template Tag: reference/template-tag.md + - Settings: reference/settings.md - About: - Contribute: - - Code: contribute/code.md - - Docs: contribute/docs.md - - Running Tests: contribute/running-tests.md + - Code: about/code.md + - Docs: about/docs.md - GitHub Discussions: https://github.com/reactive-python/reactpy-django/discussions - Discord: https://discord.gg/uNb5P4hA9X - - Changelog: changelog/index.md + - Changelog: about/changelog.md theme: name: material @@ -34,19 +28,22 @@ theme: toggle: icon: material/white-balance-sunny name: Switch to light mode - primary: light blue - accent: light blue + primary: red # We use red to indicate that something is unthemed + accent: red - media: "(prefers-color-scheme: light)" scheme: default toggle: icon: material/weather-night name: Switch to dark mode - primary: black + primary: white + accent: red features: - navigation.instant - navigation.tabs + - navigation.tabs.sticky - navigation.top - content.code.copy + - search.highlight icon: repo: fontawesome/brands/github logo: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg @@ -93,10 +90,18 @@ extra: provider: mike extra_javascript: - - static/js/extra.js + - assets/js/main.js extra_css: - - static/css/extra.css + - assets/css/main.css + - assets/css/button.css + - assets/css/admonition.css + - assets/css/sidebar.css + - assets/css/navbar.css + - assets/css/table-of-contents.css + - assets/css/code.css + - assets/css/footer.css + - assets/css/home.css watch: - docs @@ -107,8 +112,8 @@ watch: site_name: ReactPy-Django site_author: Archmonger -site_description: React for Django developers. -copyright: Copyright © 2023 Reactive Python +site_description: It's React, but in Python. Now for Django developers. +copyright: Copyright © 2023 Reactive Python. repo_url: https://github.com/reactive-python/reactpy-django site_url: https://reactive-python.github.io/reactpy-django repo_name: reactive-python/reactpy-django diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index adc437d0..0025008b 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -60,7 +60,7 @@ def reactpy_warnings(app_configs, **kwargs): Warning( "Unstable configuration detected. REACTPY_BACKHAUL_THREAD is enabled " "and you running with Daphne.", - hint="Set settings.py:REACTPY_BACKHAUL_THREAD to False or use a different webserver.", + hint="Set settings.py:REACTPY_BACKHAUL_THREAD to False or use a different web server.", id="reactpy_django.W003", ) ) diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index a99da927..df552e42 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -24,7 +24,7 @@ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = "django-insecure-n!bd1#+7ufw5#9ipayu9k(lyu@za$c2ajbro7es(v8_7w1$=&c" -# Run in production mode when using a real webserver +# Run in production mode when using a real web server DEBUG = all( not sys.argv[0].endswith(substring) for substring in {"hypercorn", "uvicorn", "daphne"} From ad327c4396614cc5d0115c68a6e6dbd56fe7821b Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Thu, 7 Sep 2023 03:00:14 -0700 Subject: [PATCH 12/68] v3.5.1 (#183) --- CHANGELOG.md | 6 ++++-- docs/overrides/main.html | 2 +- docs/src/assets/css/navbar.css | 1 + src/reactpy_django/__init__.py | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 577f67f6..c0c95928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ Using the following categories, list your changes in this order: -## [Unreleased] +## [3.5.1] - 2023-09-07 ### Added @@ -41,6 +41,7 @@ Using the following categories, list your changes in this order: ### Changed - The default postprocessor can now disabled by setting `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` to `None`. +- Massive overhaul of docs styling. ## [3.5.0] - 2023-08-26 @@ -393,7 +394,8 @@ Using the following categories, list your changes in this order: - Support for IDOM within the Django -[unreleased]: https://github.com/reactive-python/reactpy-django/compare/3.5.0...HEAD +[unreleased]: https://github.com/reactive-python/reactpy-django/compare/3.5.1...HEAD +[3.5.1]: https://github.com/reactive-python/reactpy-django/compare/3.5.0...3.5.1 [3.5.0]: https://github.com/reactive-python/reactpy-django/compare/3.4.0...3.5.0 [3.4.0]: https://github.com/reactive-python/reactpy-django/compare/3.3.2...3.4.0 [3.3.2]: https://github.com/reactive-python/reactpy-django/compare/3.3.1...3.3.2 diff --git a/docs/overrides/main.html b/docs/overrides/main.html index bc3074e6..c63ca9e7 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -13,7 +13,7 @@ {% endblock %} {% block outdated %} -You're not viewing the latest official release. +You're not viewing the latest release. Click here to go to latest. diff --git a/docs/src/assets/css/navbar.css b/docs/src/assets/css/navbar.css index 32347ccb..23e725e3 100644 --- a/docs/src/assets/css/navbar.css +++ b/docs/src/assets/css/navbar.css @@ -87,6 +87,7 @@ margin: 0; margin-right: 0.8rem; margin-left: 0.2rem; + flex-grow: 0; } .md-header__topic { position: relative; diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index d5719f97..3985fcf4 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -8,7 +8,7 @@ REACTPY_WEBSOCKET_ROUTE, ) -__version__ = "3.5.0" +__version__ = "3.5.1" __all__ = [ "REACTPY_WEBSOCKET_PATH", "REACTPY_WEBSOCKET_ROUTE", From 749e70711f6a1af2eff139f6a22ace8ad194fcb5 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Thu, 7 Sep 2023 20:31:47 -0700 Subject: [PATCH 13/68] Add "No Match" text to interactive demo (#184) * Add "No Match" text to interactive demo * fix broken link --- README.md | 2 +- .../home-code-examples/add-interactivity-demo.html | 7 +++++++ docs/src/assets/css/navbar.css | 5 +++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8aebcd8b..0943c8cc 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Follow the links below to find out more about this project. - [Documentation](https://reactive-python.github.io/reactpy-django) - [GitHub Discussions](https://github.com/reactive-python/reactpy-django/discussions) - [Discord](https://discord.gg/uNb5P4hA9X) -- [Contributor Guide](https://reactive-python.github.io/reactpy-django/latest/contribute/code/) +- [Contributor Guide](https://reactive-python.github.io/reactpy-django/latest/about/code/) - [Code of Conduct](https://github.com/reactive-python/reactpy-django/blob/main/CODE_OF_CONDUCT.md) diff --git a/docs/overrides/home-code-examples/add-interactivity-demo.html b/docs/overrides/home-code-examples/add-interactivity-demo.html index d9e99579..48ac19a7 100644 --- a/docs/overrides/home-code-examples/add-interactivity-demo.html +++ b/docs/overrides/home-code-examples/add-interactivity-demo.html @@ -134,6 +134,7 @@

Introducing Quantum Components

+

diff --git a/docs/src/assets/css/navbar.css b/docs/src/assets/css/navbar.css index 23e725e3..4f0db7fa 100644 --- a/docs/src/assets/css/navbar.css +++ b/docs/src/assets/css/navbar.css @@ -31,6 +31,11 @@ margin: 0.2rem -0.8rem; } +[dir="ltr"] .md-header__title.md-header__title--active { + margin: 0; + transition: margin 0.35s ease; +} + /* Mobile Styling */ @media screen and (max-width: 60em) { label.md-header__button.md-icon[for="__drawer"] { From 1fbdfbb984f37e61d66047e28a68f7a85f866650 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Fri, 15 Sep 2023 18:53:30 -0700 Subject: [PATCH 14/68] SEO Compatible Rendering (#186) - SEO compatible rendering - Prerenders the initial component via the template tag using `vdom_to_html`, then loads the actual component in the background within a `hidden` div. When loaded, the prerender is replaced with the actual render. - `settings.py:REACTPY_PRERENDER` can be set to `True` to enable this behavior by default - Enable it on individual components via the template tag: `{% component "..." prerender="True" %}` - Docs styling, verbiage, and formatting tweaks - Rename undocumented utility function `reactpy_django.utils.ComponentPreloader` to `reactpy_django.utils.RootComponentFinder`. - Fix JavaScript being via `pip install -e .` on Windows. - Update PyPi package metadata - Update pull request template --- .github/pull_request_template.md | 11 +- CHANGELOG.md | 12 +++ docs/python/template-tag-bad-view.py | 2 +- docs/src/about/code.md | 2 +- docs/src/about/docs.md | 2 +- docs/src/assets/css/admonition.css | 30 +++--- docs/src/assets/css/code.css | 2 +- docs/src/assets/css/main.css | 4 +- docs/src/assets/css/navbar.css | 24 ++++- docs/src/assets/css/sidebar.css | 2 +- .../learn/add-reactpy-to-a-django-project.md | 2 + docs/src/reference/settings.md | 39 ++++--- docs/src/reference/template-tag.md | 15 +-- docs/src/reference/utils.md | 4 +- mkdocs.yml | 2 +- setup.py | 33 +++--- src/js/package-lock.json | 101 +++++++++++++++--- src/js/package.json | 3 + src/js/src/index.ts | 2 +- src/js/src/mount.tsx | 18 ++++ src/js/tsconfig.json | 9 ++ src/reactpy_django/apps.py | 4 +- src/reactpy_django/checks.py | 9 ++ src/reactpy_django/config.py | 5 + src/reactpy_django/exceptions.py | 4 + src/reactpy_django/hooks.py | 29 +++-- .../templates/reactpy/component.html | 8 +- src/reactpy_django/templatetags/reactpy.py | 55 +++++++++- src/reactpy_django/types.py | 3 +- src/reactpy_django/utils.py | 22 +++- tests/test_app/asgi.py | 3 - tests/test_app/prerender/__init__.py | 0 tests/test_app/prerender/components.py | 40 +++++++ tests/test_app/prerender/urls.py | 7 ++ tests/test_app/prerender/views.py | 5 + tests/test_app/settings.py | 23 +--- tests/test_app/templates/prerender.html | 30 ++++++ tests/test_app/tests/test_components.py | 34 ++++++ tests/test_app/urls.py | 13 ++- 39 files changed, 487 insertions(+), 126 deletions(-) create mode 100644 src/js/src/mount.tsx create mode 100644 tests/test_app/prerender/__init__.py create mode 100644 tests/test_app/prerender/components.py create mode 100644 tests/test_app/prerender/urls.py create mode 100644 tests/test_app/prerender/views.py create mode 100644 tests/test_app/templates/prerender.html diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f237e91d..7e988149 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,3 @@ -*By submitting this pull request you agree that all contributions to this project are made under the MIT license.* - ## Description A summary of the changes. @@ -8,6 +6,9 @@ A summary of the changes. Please update this checklist as you complete each item: -- [ ] Tests have been included for all bug fixes or added functionality. -- [ ] The changelog has been updated with any significant changes, if necessary. -- [ ] GitHub Issues which may be closed by this PR have been linked. +- [ ] Tests have been developed for bug fixes or new functionality. +- [ ] The changelog has been updated, if necessary. +- [ ] Documentation has been updated, if necessary. +- [ ] GitHub Issues closed by this PR have been linked. + +By submitting this pull request you agree that all contributions comply with this project's open source license(s). diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c95928..f8f0ec63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,18 @@ Using the following categories, list your changes in this order: +## [Unreleased] + +### Added + +- ReactPy components can now use SEO compatible rendering! + - `settings.py:REACTPY_PRERENDER` can be set to `True` to enable this behavior by default + - Or, you can enable it on individual components via the template tag: `{% component "..." prerender="True" %}` + +### Changed + +- Renamed undocumented utility function `reactpy_django.utils.ComponentPreloader` to `reactpy_django.utils.RootComponentFinder`. + ## [3.5.1] - 2023-09-07 ### Added diff --git a/docs/python/template-tag-bad-view.py b/docs/python/template-tag-bad-view.py index a798abb0..00d0d9f7 100644 --- a/docs/python/template-tag-bad-view.py +++ b/docs/python/template-tag-bad-view.py @@ -2,5 +2,5 @@ def example_view(request): - context_vars = {"dont_do_this": "example_project.my_app.components.hello_world"} + context_vars = {"my_variable": "example_project.my_app.components.hello_world"} return render(request, "my-template.html", context_vars) diff --git a/docs/src/about/code.md b/docs/src/about/code.md index b163d01c..b4790d5b 100644 --- a/docs/src/about/code.md +++ b/docs/src/about/code.md @@ -35,7 +35,7 @@ Then, by running the command below you can: - Download, build, and install Javascript dependencies ```bash linenums="0" -pip install -e . -r requirements.txt +pip install -e . -r requirements.txt --verbose --upgrade ``` !!! warning "Pitfall" diff --git a/docs/src/about/docs.md b/docs/src/about/docs.md index 6c2f413b..5dbd35dd 100644 --- a/docs/src/about/docs.md +++ b/docs/src/about/docs.md @@ -28,7 +28,7 @@ Then, by running the command below you can: - Self-host a test server for the documentation ```bash linenums="0" -pip install -e . -r requirements.txt --upgrade +pip install -r requirements.txt --upgrade ``` Finally, to verify that everything is working properly, you can manually run the docs preview web server. diff --git a/docs/src/assets/css/admonition.css b/docs/src/assets/css/admonition.css index f71fa55a..7813830c 100644 --- a/docs/src/assets/css/admonition.css +++ b/docs/src/assets/css/admonition.css @@ -1,20 +1,20 @@ [data-md-color-scheme="slate"] { --admonition-border-color: transparent; --admonition-expanded-border-color: rgba(255, 255, 255, 0.1); - --note-bg-color: rgb(43 110 98/ 0.2); + --note-bg-color: rgba(43, 110, 98, 0.2); --terminal-bg-color: #0c0c0c; --terminal-title-bg-color: #000; - --deep-dive-bg-color: rgb(43 52 145 / 0.2); + --deep-dive-bg-color: rgba(43, 52, 145, 0.2); --you-will-learn-bg-color: #353a45; - --pitfall-bg-color: rgb(182 87 0 / 0.2); + --pitfall-bg-color: rgba(182, 87, 0, 0.2); } [data-md-color-scheme="default"] { --admonition-border-color: rgba(0, 0, 0, 0.08); --admonition-expanded-border-color: var(--admonition-border-color); - --note-bg-color: rgb(244 251 249); - --terminal-bg-color: rgb(64 71 86); - --terminal-title-bg-color: rgb(35 39 47); - --deep-dive-bg-color: rgb(243 244 253); + --note-bg-color: rgb(244, 251, 249); + --terminal-bg-color: rgb(64, 71, 86); + --terminal-title-bg-color: rgb(35, 39, 47); + --deep-dive-bg-color: rgb(243, 244, 253); --you-will-learn-bg-color: rgb(246, 247, 249); --pitfall-bg-color: rgb(254, 245, 231); } @@ -81,12 +81,12 @@ React Name: "Note" font-size: 1rem; background: transparent; padding-bottom: 0; - color: rgb(68 172 153); + color: rgb(68, 172, 153); } .md-typeset .note .admonition-title:before { font-size: 1.1rem; - background: rgb(68 172 153); + background: rgb(68, 172, 153); } .md-typeset .note > .admonition-title:before, @@ -109,12 +109,12 @@ React Name: "Pitfall" font-size: 1rem; background: transparent; padding-bottom: 0; - color: rgb(219 125 39); + color: rgb(219, 125, 39); } .md-typeset .warning .admonition-title:before { font-size: 1.1rem; - background: rgb(219 125 39); + background: rgb(219, 125, 39); } /* @@ -131,12 +131,12 @@ React Name: "Deep Dive" font-size: 1rem; background: transparent; padding-bottom: 0; - color: rgb(136 145 236); + color: rgb(136, 145, 236); } .md-typeset .info .admonition-title:before { font-size: 1.1rem; - background: rgb(136 145 236); + background: rgb(136, 145, 236); } /* @@ -152,11 +152,11 @@ React Name: "Terminal" .md-typeset .example .admonition-title { background: var(--terminal-title-bg-color); - color: rgb(246 247 249); + color: rgb(246, 247, 249); } .md-typeset .example .admonition-title:before { - background: rgb(246 247 249); + background: rgb(246, 247, 249); } .md-typeset .admonition.example code { diff --git a/docs/src/assets/css/code.css b/docs/src/assets/css/code.css index d1556dc0..c5465498 100644 --- a/docs/src/assets/css/code.css +++ b/docs/src/assets/css/code.css @@ -9,7 +9,7 @@ --md-code-hl-color: #ffffcf1c; --md-code-bg-color: #16181d; --md-code-hl-comment-color: hsla(var(--md-hue), 75%, 90%, 0.43); - --code-tab-color: rgb(52 58 70); + --code-tab-color: rgb(52, 58, 70); --md-code-hl-name-color: #aadafc; --md-code-hl-string-color: hsl(21 49% 63% / 1); --md-code-hl-keyword-color: hsl(289.67deg 35% 60%); diff --git a/docs/src/assets/css/main.css b/docs/src/assets/css/main.css index 500ae4be..da5a74c4 100644 --- a/docs/src/assets/css/main.css +++ b/docs/src/assets/css/main.css @@ -3,7 +3,7 @@ --reactpy-color: #58b962; --reactpy-color-dark: #42914a; --reactpy-color-darker: #34743b; - --reactpy-color-opacity-10: rgb(88 185 98 / 10%); + --reactpy-color-opacity-10: rgba(88, 185, 98, 0.1); } [data-md-color-accent="red"] { @@ -12,7 +12,7 @@ } [data-md-color-scheme="slate"] { - --md-default-bg-color: rgb(35 39 47); + --md-default-bg-color: rgb(35, 39, 47); --md-default-bg-color--light: hsla(var(--md-hue), 15%, 16%, 0.54); --md-default-bg-color--lighter: hsla(var(--md-hue), 15%, 16%, 0.26); --md-default-bg-color--lightest: hsla(var(--md-hue), 15%, 16%, 0.07); diff --git a/docs/src/assets/css/navbar.css b/docs/src/assets/css/navbar.css index 4f0db7fa..33e8b14f 100644 --- a/docs/src/assets/css/navbar.css +++ b/docs/src/assets/css/navbar.css @@ -1,9 +1,11 @@ [data-md-color-scheme="slate"] { --md-header-border-color: rgb(255 255 255 / 5%); + --md-version-bg-color: #ffffff0d; } [data-md-color-scheme="default"] { --md-header-border-color: rgb(0 0 0 / 7%); + --md-version-bg-color: #ae58ee2e; } .md-header { @@ -28,12 +30,20 @@ } .md-version__list { - margin: 0.2rem -0.8rem; + margin: 0; + left: 0; + right: 0; + top: 2.5rem; } -[dir="ltr"] .md-header__title.md-header__title--active { - margin: 0; - transition: margin 0.35s ease; +.md-version { + background: var(--md-version-bg-color); + border-radius: 999px; + padding: 0 0.8rem; + margin: 0.3rem 0; + height: 1.8rem; + display: flex; + font-size: 0.7rem; } /* Mobile Styling */ @@ -97,6 +107,12 @@ .md-header__topic { position: relative; } + .md-header__title--active .md-header__topic { + transform: none; + opacity: 1; + pointer-events: auto; + z-index: 4; + } /* Search */ .md-search { diff --git a/docs/src/assets/css/sidebar.css b/docs/src/assets/css/sidebar.css index aeadf3b5..bf197138 100644 --- a/docs/src/assets/css/sidebar.css +++ b/docs/src/assets/css/sidebar.css @@ -28,7 +28,7 @@ } .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { - color: rgb(133 142 159); + color: rgb(133, 142, 159); margin: 0.5rem; } diff --git a/docs/src/learn/add-reactpy-to-a-django-project.md b/docs/src/learn/add-reactpy-to-a-django-project.md index 7d7c949f..311bc3c3 100644 --- a/docs/src/learn/add-reactpy-to-a-django-project.md +++ b/docs/src/learn/add-reactpy-to-a-django-project.md @@ -53,6 +53,8 @@ Add `#!python "reactpy_django"` to [`INSTALLED_APPS`](https://docs.djangoproject ??? note "Configure ReactPy settings (Optional)" + {% include "../reference/settings.md" start="" end="" %} + {% include "../reference/settings.md" start="" end="" %} ## Step 3: Configure `urls.py` diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md index 9f108a0d..3a013ed7 100644 --- a/docs/src/reference/settings.md +++ b/docs/src/reference/settings.md @@ -2,7 +2,11 @@

-Your **Django project's** `settings.py` can modify the behavior of ReactPy. + + +These are ReactPy-Django's default settings values. You can modify these values in your **Django project's** `settings.py` to change the behavior of ReactPy. + +

@@ -14,25 +18,34 @@ Your **Django project's** `settings.py` can modify the behavior of ReactPy. --- -## Primary Configuration - -These are ReactPy-Django's default settings values. You can modify these values in your **Django project's** `settings.py` to change the behavior of ReactPy. +## General Settings | Setting | Default Value | Example Value(s) | Description | | --- | --- | --- | --- | -| `#!python REACTPY_CACHE` | `#!python "default"` | `#!python "my-reactpy-cache"` | Cache used to store ReactPy web modules. ReactPy benefits from a fast, well indexed cache. We recommend installing [`redis`](https://redis.io/) or [`python-diskcache`](https://grantjenks.com/docs/diskcache/tutorial.html#djangocache). | -| `#!python REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Database used to store ReactPy session data. ReactPy requires a multiprocessing-safe and thread-safe database. If configuring `#!python REACTPY_DATABASE`, it is mandatory to enable our database router like such:
`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` | -| `#!python REACTPY_SESSION_MAX_AGE` | `#!python 259200` | `#!python 0`, `#!python 60`, `#!python 96000` | Maximum seconds to store ReactPy session data, such as `#!python args` and `#!python kwargs` passed into your component template tag. Use `#!python 0` to not store any session data. | -| `#!python REACTPY_URL_PREFIX` | `#!python "reactpy/"` | `#!python "rp/"`, `#!python "render/reactpy/"` | The prefix to be used for all ReactPy WebSocket and HTTP URLs. | -| `#!python REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python None`, `#!python "example_project.my_query_postprocessor"` | Dotted path to the default `#!python reactpy_django.hooks.use_query` postprocessor function. Postprocessor functions can be async or sync, and the parameters must contain the arg `#!python data`. Set `#!python REACTPY_DEFAULT_QUERY_POSTPROCESSOR` to `#!python None` to globally disable the default postprocessor. | +| `#!python REACTPY_URL_PREFIX` | `#!python "reactpy/"` | `#!python "rp/"`, `#!python "render/reactpy/"` | The prefix used for all ReactPy WebSocket and HTTP URLs. | +| `#!python REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python "example_project.postprocessor"`, `#!python None` | Dotted path to the default `#!python reactpy_django.hooks.use_query` postprocessor function. Postprocessor functions can be async or sync, and the function must contain a `#!python data` parameter. Set `#!python REACTPY_DEFAULT_QUERY_POSTPROCESSOR` to `#!python None` to globally disable the default postprocessor. | | `#!python REACTPY_AUTH_BACKEND` | `#!python "django.contrib.auth.backends.ModelBackend"` | `#!python "example_project.auth.MyModelBackend"` | Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:
1. You are using `#!python AuthMiddlewareStack` and...
2. You are using Django's `#!python AUTHENTICATION_BACKENDS` setting and...
3. Your Django user model does not define a `#!python backend` attribute. | -| `#!python REACTPY_BACKHAUL_THREAD` | `#!python False` | `#!python True` | Whether to render ReactPy components in a dedicated thread. This allows the web server to process traffic during ReactPy rendering. Vastly improves throughput with web servers such as [`hypercorn`](https://pgjones.gitlab.io/hypercorn/) and [`uvicorn`](https://www.uvicorn.org/). | -| `#!python REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir" ]` | The default host(s) that can render your ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing. You can use the `#!python host` argument in your [template tag](../reference/template-tag.md#component) as a manual override. | -| `#!python REACTPY_RECONNECT_INTERVAL` | `#!python 750` | `#!python 100`, `#!python 2500`, `#!python 6000` | Milliseconds between client reconnection attempts. This value will gradually increase if `#!python REACTPY_RECONNECT_BACKOFF_MULTIPLIER` is greater than `#!python 1`. | + +## Performance Settings + +| Setting | Default Value | Example Value(s) | Description | +| --- | --- | --- | --- | +| `#!python REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Multiprocessing-safe database used to store ReactPy session data. If configuring `#!python REACTPY_DATABASE`, it is mandatory to enable our database router like such:
`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` | +| `#!python REACTPY_CACHE` | `#!python "default"` | `#!python "my-reactpy-cache"` | Cache used for ReactPy JavaScript modules. We recommend installing [`redis`](https://redis.io/) or [`python-diskcache`](https://grantjenks.com/docs/diskcache/tutorial.html#djangocache). | +| `#!python REACTPY_BACKHAUL_THREAD` | `#!python False` | `#!python True` | Configures whether ReactPy components are rendered in a dedicated thread. This allows the web server to process traffic during ReactPy rendering. Vastly improves throughput with web servers such as [`hypercorn`](https://pgjones.gitlab.io/hypercorn/) and [`uvicorn`](https://www.uvicorn.org/). | +| `#!python REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir"]` | The default host(s) that can render your ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing. You can use the `#!python host` argument in your [template tag](../reference/template-tag.md#component) as a manual override. | +| `#!python REACTPY_PRERENDER` | `#!python False` | `#!python True` | Configures whether to pre-render your components, which enables SEO compatibility and increases perceived responsiveness. You can use the `#!python prerender` argument in your [template tag](../reference/template-tag.md#component) as a manual override. During pre-rendering, there are some key differences in behavior:
1. Only the component's first render is pre-rendered.
2. All `#!python connection` related hooks use HTTP.
3. `#!python html.script` is executed during both pre-render and render.
4. Component is non-interactive until a WebSocket connection is formed. | + +## Stability Settings + +| Setting | Default Value | Example Value(s) | Description | +| --- | --- | --- | --- | +| `#!python REACTPY_RECONNECT_INTERVAL` | `#!python 750` | `#!python 100`, `#!python 2500`, `#!python 6000` | Milliseconds between client reconnection attempts. | +| `#!python REACTPY_RECONNECT_BACKOFF_MULTIPLIER` | `#!python 1.25` | `#!python 1`, `#!python 1.5`, `#!python 3` | On each reconnection attempt, the `#!python REACTPY_RECONNECT_INTERVAL` will be multiplied by this value to increase the time between attempts. You can keep time between each reconnection the same by setting this to `#!python 1`. | | `#!python REACTPY_RECONNECT_MAX_INTERVAL` | `#!python 60000` | `#!python 10000`, `#!python 25000`, `#!python 900000` | Maximum milliseconds between client reconnection attempts. This allows setting an upper bound on how high `#!python REACTPY_RECONNECT_BACKOFF_MULTIPLIER` can increase the time between reconnection attempts. | | `#!python REACTPY_RECONNECT_MAX_RETRIES` | `#!python 150` | `#!python 0`, `#!python 5`, `#!python 300` | Maximum number of reconnection attempts before the client gives up. | -| `#!python REACTPY_RECONNECT_BACKOFF_MULTIPLIER` | `#!python 1.25` | `#!python 1`, `#!python 1.5`, `#!python 3` | Multiplier for the time between client reconnection attempts. On each reconnection attempt, the `#!python REACTPY_RECONNECT_INTERVAL` will be multiplied by this to increase the time between attempts. You can keep time between each reconnection the same by setting this to `#!python 1`. | +| `#!python REACTPY_SESSION_MAX_AGE` | `#!python 259200` | `#!python 0`, `#!python 60`, `#!python 96000` | Maximum seconds to store ReactPy component sessions. This includes data such as `#!python *args` and `#!python **kwargs` passed into your component template tag. Use `#!python 0` to not store any session data. | diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index 6f20be36..b9b9017f 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -25,8 +25,9 @@ This template tag can be used to insert any number of ReactPy components onto yo | `#!python dotted_path` | `#!python str` | The dotted path to the component to render. | N/A | | `#!python *args` | `#!python Any` | The positional arguments to provide to the component. | N/A | | `#!python class` | `#!python str | None` | The HTML class to apply to the top-level component div. | `#!python None` | - | `#!python key` | `#!python str | None` | Force the component's root node to use a [specific key value](https://reactpy.dev/docs/guides/creating-interfaces/rendering-data/index.html#organizing-items-with-keys). Using `#!python key` within a template tag is effectively useless. | `#!python None` | - | `#!python host` | `#!python str | None` | The host to use for the ReactPy connections. If set to `#!python None`, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir` | `#!python None` | + | `#!python key` | `#!python Any` | Force the component's root node to use a [specific key value](https://reactpy.dev/docs/guides/creating-interfaces/rendering-data/index.html#organizing-items-with-keys). Using `#!python key` within a template tag is effectively useless. | `#!python None` | + | `#!python host` | `#!python str | None` | The host to use for the ReactPy connections. If unset, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir` | `#!python None` | + | `#!python prerender` | `#!python str` | If `#!python "True"`, the component will pre-rendered, which enables SEO compatibility and increases perceived responsiveness. | `#!python "False"` | | `#!python **kwargs` | `#!python Any` | The keyword arguments to provide to the component. | N/A | **Returns** @@ -37,11 +38,11 @@ This template tag can be used to insert any number of ReactPy components onto yo -??? warning "Do not use context variables for the ReactPy component name" +??? warning "Do not use context variables for the component path" - Our preprocessor relies on the template tag containing a string. + The ReactPy component finder (`#!python reactpy_django.utils.RootComponentFinder`) requires that your component path is a string. - **Do not** use Django template/context variables for the component path. Failure to follow this warning can result in unexpected behavior. + **Do not** use Django template/context variables for the component path. Failure to follow this warning can result in unexpected behavior, such as components that will not render. For example, **do not** do the following: @@ -52,7 +53,7 @@ This template tag can be used to insert any number of ReactPy components onto yo {% component "example_project.my_app.components.hello_world" recipient="World" %} - {% component dont_do_this recipient="World" %} + {% component my_variable recipient="World" %} ``` === "views.py" @@ -81,7 +82,7 @@ This template tag can be used to insert any number of ReactPy components onto yo 1. If your host address are completely separate ( `origin1.com != origin2.com` ) you will need to [configure CORS headers](https://pypi.org/project/django-cors-headers/) on your main application during deployment. 2. You will not need to register ReactPy HTTP or WebSocket paths on any applications that do not perform any component rendering. - 3. Your component will only be able to access `#!python *args`/`#!python **kwargs` you provide to the template tag if your applications share a common database. + 3. Your component will only be able to access your template tag's `#!python *args`/`#!python **kwargs` if your applications share a common database. diff --git a/docs/src/reference/utils.md b/docs/src/reference/utils.md index d71facc2..e5c10057 100644 --- a/docs/src/reference/utils.md +++ b/docs/src/reference/utils.md @@ -52,9 +52,9 @@ This function is used manually register a root component with ReactPy. {% include "../../python/register-component.py" %} ``` -??? warning "Only use this within `#!python AppConfig.ready()`" +??? warning "Only use this within `#!python MyAppConfig.ready()`" - You should always call `#!python register_component` within a Django [`#!python AppConfig.ready()` method](https://docs.djangoproject.com/en/4.2/ref/applications/#django.apps.AppConfig.ready). This ensures you will retain multiprocessing compatibility, such as with ASGI web server workers. + You should always call `#!python register_component` within a Django [`#!python MyAppConfig.ready()` method](https://docs.djangoproject.com/en/dev/ref/applications/#django.apps.AppConfig.ready). This ensures you will retain multiprocessing compatibility, such as with ASGI web server workers. ??? question "Do I need to use this?" diff --git a/mkdocs.yml b/mkdocs.yml index e4e19c65..9269b109 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -112,7 +112,7 @@ watch: site_name: ReactPy-Django site_author: Archmonger -site_description: It's React, but in Python. Now for Django developers. +site_description: It's React, but in Python. Now with Django integration. copyright: Copyright © 2023 Reactive Python. repo_url: https://github.com/reactive-python/reactpy-django site_url: https://reactive-python.github.io/reactpy-django diff --git a/setup.py b/setup.py index 9fecde36..9174e81a 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ from logging import StreamHandler, getLogger from pathlib import Path -from setuptools import find_packages, setup +from setuptools import find_namespace_packages, setup from setuptools.command.develop import develop from setuptools.command.sdist import sdist @@ -47,27 +47,35 @@ def list2cmdline(cmd_list): package = { "name": name, "python_requires": ">=3.9", - "packages": find_packages(str(src_dir)), + "packages": find_namespace_packages(str(src_dir)), "package_dir": {"": "src"}, - "description": "Control the web with Python", - "author": "Ryan Morshead", - "author_email": "ryan.morshead@gmail.com", + "description": "It's React, but in Python. Now with Django integration.", + "author": "Mark Bakhit", + "author_email": "archiethemonger@gmail.com", "url": "https://github.com/reactive-python/reactpy-django", "license": "MIT", "platforms": "Linux, Mac OS X, Windows", - "keywords": ["interactive", "widgets", "DOM", "React"], + "keywords": [ + "interactive", + "reactive", + "widgets", + "DOM", + "React", + "ReactJS", + "ReactPy", + ], "include_package_data": True, "zip_safe": False, "classifiers": [ "Framework :: Django", "Framework :: Django :: 4.0", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Operating System :: OS Independent", "Intended Audience :: Developers", "Intended Audience :: Science/Research", "Topic :: Multimedia :: Graphics", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", "Environment :: Web Environment", ], } @@ -129,9 +137,8 @@ def run(self): log.info(f"> {list2cmdline(args_list)}") subprocess.run(args_list, cwd=js_dir, check=True) except Exception: - log.error("Failed to update NPM") log.error(traceback.format_exc()) - raise + log.error("Failed to update NPM, continuing anyway...") log.info("Installing Javascript...") try: @@ -139,8 +146,8 @@ def run(self): 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()) + log.error("Failed to install Javascript") raise log.info("Building Javascript...") @@ -149,8 +156,8 @@ def run(self): log.info(f"> {list2cmdline(args_list)}") subprocess.run(args_list, cwd=js_dir, check=True) except Exception: - log.error("Failed to build Javascript") log.error(traceback.format_exc()) + log.error("Failed to build Javascript") raise log.info("Successfully built Javascript") diff --git a/src/js/package-lock.json b/src/js/package-lock.json index 84321d16..f4ffe4f8 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -13,8 +13,11 @@ "@rollup/plugin-commonjs": "^24.0.1", "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-replace": "^5.0.2", + "@types/react": "^17.0", + "@types/react-dom": "^17.0", "prettier": "^3.0.2", - "rollup": "^3.28.1" + "rollup": "^3.28.1", + "typescript": "^4.9.5" } }, "node_modules/@jridgewell/sourcemap-codec": { @@ -158,12 +161,44 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==" }, + "node_modules/@types/prop-types": { + "version": "15.7.5", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", + "dev": true + }, + "node_modules/@types/react": { + "version": "17.0.65", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.65.tgz", + "integrity": "sha512-oxur785xZYHvnI7TRS61dXbkIhDPnGfsXKv0cNXR/0ml4SipRIFpSMzA7HMEfOywFwJ5AOnPrXYTEiTRUQeGlQ==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "17.0.20", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.20.tgz", + "integrity": "sha512-4pzIjSxDueZZ90F52mU3aPoogkHIoSIDG+oQ+wQK7Cy2B9S+MvOqY0uEA/qawKz381qrEDkvpwyt8Bm31I8sbA==", + "dev": true, + "dependencies": { + "@types/react": "^17" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "dev": true }, + "node_modules/@types/scheduler": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", + "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", + "dev": true + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -197,6 +232,12 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "dev": true + }, "node_modules/deepmerge": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", @@ -389,7 +430,7 @@ "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "peer": true, "engines": { "node": ">=0.10.0" @@ -521,16 +562,15 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, "node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", - "peer": true, + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.17" + "node": ">=4.2.0" } }, "node_modules/wrappy": { @@ -618,12 +658,44 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==" }, + "@types/prop-types": { + "version": "15.7.5", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", + "dev": true + }, + "@types/react": { + "version": "17.0.65", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.65.tgz", + "integrity": "sha512-oxur785xZYHvnI7TRS61dXbkIhDPnGfsXKv0cNXR/0ml4SipRIFpSMzA7HMEfOywFwJ5AOnPrXYTEiTRUQeGlQ==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "@types/react-dom": { + "version": "17.0.20", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.20.tgz", + "integrity": "sha512-4pzIjSxDueZZ90F52mU3aPoogkHIoSIDG+oQ+wQK7Cy2B9S+MvOqY0uEA/qawKz381qrEDkvpwyt8Bm31I8sbA==", + "dev": true, + "requires": { + "@types/react": "^17" + } + }, "@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "dev": true }, + "@types/scheduler": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", + "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", + "dev": true + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -651,6 +723,12 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, + "csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "dev": true + }, "deepmerge": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", @@ -806,7 +884,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "peer": true }, "once": { @@ -895,10 +973,9 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, "typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", - "peer": true + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==" }, "wrappy": { "version": "1.0.2", diff --git a/src/js/package.json b/src/js/package.json index 40596a0d..0c61ec46 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -13,6 +13,9 @@ "@rollup/plugin-commonjs": "^24.0.1", "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-replace": "^5.0.2", + "@types/react": "^17.0", + "@types/react-dom": "^17.0", + "typescript": "^4.9.5", "prettier": "^3.0.2", "rollup": "^3.28.1" }, diff --git a/src/js/src/index.ts b/src/js/src/index.ts index 53d67c6f..56a85aac 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -1,4 +1,4 @@ -import { mount } from "@reactpy/client"; +import { mount } from "./mount"; import { ReactPyDjangoClient } from "./client"; export function mountComponent( diff --git a/src/js/src/mount.tsx b/src/js/src/mount.tsx new file mode 100644 index 00000000..4d7cdcb3 --- /dev/null +++ b/src/js/src/mount.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { render } from "react-dom"; +import { Layout } from "@reactpy/client/src/components"; +import { ReactPyDjangoClient } from "./client"; + +export function mount(element: HTMLElement, client: ReactPyDjangoClient): void { + const prerenderElement = document.getElementById(element.id + "-prerender"); + if (prerenderElement) { + element.hidden = true; + client.onMessage("layout-update", ({ path, model }) => { + if (prerenderElement) { + prerenderElement.replaceWith(element); + element.hidden = false; + } + }); + } + render(, element); +} diff --git a/src/js/tsconfig.json b/src/js/tsconfig.json index 7da4aa77..7e5ec6cb 100644 --- a/src/js/tsconfig.json +++ b/src/js/tsconfig.json @@ -3,5 +3,14 @@ "target": "ES2017", "module": "esnext", "moduleResolution": "node", + "jsx": "react", }, + "paths": { + "react": [ + "./node_modules/preact/compat/" + ], + "react-dom": [ + "./node_modules/preact/compat/" + ] + } } diff --git a/src/reactpy_django/apps.py b/src/reactpy_django/apps.py index 67a39cb2..f0f2c455 100644 --- a/src/reactpy_django/apps.py +++ b/src/reactpy_django/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -from reactpy_django.utils import ComponentPreloader +from reactpy_django.utils import RootComponentFinder class ReactPyConfig(AppConfig): @@ -8,4 +8,4 @@ class ReactPyConfig(AppConfig): def ready(self): # Populate the ReactPy component registry when Django is ready - ComponentPreloader().run() + RootComponentFinder().run() diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 0025008b..489888eb 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -473,4 +473,13 @@ def reactpy_errors(app_configs, **kwargs): ) ) + if not isinstance(config.REACTPY_PRERENDER, bool): + errors.append( + Error( + "Invalid type for REACTPY_PRERENDER.", + hint="REACTPY_PRERENDER should be a boolean.", + id="reactpy_django.E021", + ) + ) + return errors diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index dc350e2a..56011c83 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -107,3 +107,8 @@ "REACTPY_RECONNECT_BACKOFF_MULTIPLIER", 1.25, # Default to 25% backoff per connection attempt ) +REACTPY_PRERENDER: bool = getattr( + settings, + "REACTPY_PRERENDER", + False, +) diff --git a/src/reactpy_django/exceptions.py b/src/reactpy_django/exceptions.py index 5cdcb719..49c6fef3 100644 --- a/src/reactpy_django/exceptions.py +++ b/src/reactpy_django/exceptions.py @@ -8,3 +8,7 @@ class ComponentDoesNotExistError(AttributeError): class InvalidHostError(ValueError): ... + + +class ComponentCarrierError(ValueError): + ... diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index c60bbf1c..8115de56 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -48,16 +48,27 @@ def use_origin() -> str | None: this will be None.""" scope = _use_scope() try: - return next( - ( - header[1].decode("utf-8") - for header in scope["headers"] - if header[0] == b"origin" - ), - None, - ) + if scope["type"] == "websocket": + return next( + ( + header[1].decode("utf-8") + for header in scope["headers"] + if header[0] == b"origin" + ), + None, + ) + if scope["type"] == "http": + host = next( + ( + header[1].decode("utf-8") + for header in scope["headers"] + if header[0] == b"host" + ) + ) + return f"{scope['scheme']}://{host}" if host else None except Exception: - return None + _logger.info("Failed to get origin") + return None def use_scope() -> dict[str, Any]: diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html index 75a65dbe..1a6b4669 100644 --- a/src/reactpy_django/templates/reactpy/component.html +++ b/src/reactpy_django/templates/reactpy/component.html @@ -1,9 +1,11 @@ {% load static %} -{% if reactpy_failure %} -{% if reactpy_debug_mode %} + +{% if reactpy_failure and reactpy_debug_mode %} {% firstof reactpy_error "UnknownError" %}: "{% firstof reactpy_dotted_path "UnknownPath" %}" {% endif %} -{% else %} + +{% if not reactpy_failure %} +{% if reactpy_prerender_html %}
{{ reactpy_prerender_html|safe }}
{% endif %}
+Reactive Python and affiliates. +' repo_url: https://github.com/reactive-python/reactpy-django site_url: https://reactive-python.github.io/reactpy-django repo_name: reactive-python/reactpy-django diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 31df3e2e..6884276b 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -124,16 +124,16 @@ def view_to_component( Keyword Args: view: The view function or class to convert. - compatibility: If True, the component will be rendered in an iframe. - When using compatibility mode `tranforms`, `strict_parsing`, `request`, + compatibility: If True, the component will be rendered in an iframe. \ + When using compatibility mode `tranforms`, `strict_parsing`, `request`, \ `args, and `kwargs` arguments will be ignored. - transforms: A list of functions that transforms the newly generated VDOM. + transforms: A list of functions that transforms the newly generated VDOM. \ The functions will be called on each VDOM node. - strict_parsing: If True, an exception will be generated if the HTML does not + strict_parsing: If True, an exception will be generated if the HTML does not \ perfectly adhere to HTML5. Returns: - A function that takes `request: HttpRequest | None, *args: Any, key: Key | None, **kwargs: Any` + A function that takes `request: HttpRequest | None, *args: Any, key: Key | None, **kwargs: Any` \ and returns a ReactPy component. """ @@ -172,9 +172,9 @@ def django_css(static_path: str, key: Key | None = None): """Fetches a CSS static file for use within ReactPy. This allows for deferred CSS loading. Args: - static_path: The path to the static file. This path is identical to what you would - use on a `static` template tag. - key: A key to uniquely identify this component which is unique amongst a component's + static_path: The path to the static file. This path is identical to what you would \ + use on Django's `{% static %}` template tag + key: A key to uniquely identify this component which is unique amongst a component's \ immediate siblings """ @@ -190,9 +190,9 @@ def django_js(static_path: str, key: Key | None = None): """Fetches a JS static file for use within ReactPy. This allows for deferred JS loading. Args: - static_path: The path to the static file. This path is identical to what you would - use on a `static` template tag. - key: A key to uniquely identify this component which is unique amongst a component's + static_path: The path to the static file. This path is identical to what you would \ + use on Django's `{% static %}` template tag. + key: A key to uniquely identify this component which is unique amongst a component's \ immediate siblings """ diff --git a/src/reactpy_django/decorators.py b/src/reactpy_django/decorators.py index fc24fdfd..4f3befe2 100644 --- a/src/reactpy_django/decorators.py +++ b/src/reactpy_django/decorators.py @@ -19,8 +19,8 @@ def auth_required( This decorator can be used with or without parentheses. Args: - auth_attribute: The value to check within the user object. - This is checked in the form of `UserModel.`. + auth_attribute: The value to check within the user object. \ + This is checked in the form of `UserModel.`. \ fallback: The component or VDOM (`reactpy.html` snippet) to render if the user is not authenticated. """ diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 8115de56..2fbdd88f 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -229,12 +229,13 @@ def use_mutation(*args: Any, **kwargs: Any) -> Mutation[_Params]: """Hook to create, update, or delete Django ORM objects. Args: - mutate: A callable that performs Django ORM create, update, or delete - functionality. If this function returns `False`, then your `refetch` + mutation: A callable that performs Django ORM create, update, or delete \ + functionality. If this function returns `False`, then your `refetch` \ function will not be used. - refetch: A `query` function (used by the `use_query` hook) or a sequence of `query` - functions that will be called if the mutation succeeds. This is useful for - refetching data after a mutation has been performed. + refetch: A query function (the function you provide to your `use_query` \ + hook) or a sequence of query functions that need a `refetch` if the \ + mutation succeeds. This is useful for refreshing data after a mutation \ + has been performed. """ loading, set_loading = use_state(False) From 6f79c4cf7ed8b0b4cd048817aa90ab12997caabc Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 24 Sep 2023 23:39:37 -0700 Subject: [PATCH 17/68] `view_to_iframe` component (#188) - The `compatibility` argument on `reactpy_django.components.view_to_component` is deprecated. - Using `reactpy_django.components.view_to_component` as a decorator is deprecated. - `reactpy_django.utils.register_iframe` function has been added. - `reactpy_django.components.view_to_iframe` component has been added - It is now recommended to call `as_view()` when using `view_to_component` or `view_to_iframe` with Class Based Views. - Thread sensitivity has been enabled in all locations where ORM queries are possible. --- CHANGELOG.md | 9 + docs/python/hello_world_app_config_cbv.py | 11 + docs/python/hello_world_app_config_fbv.py | 11 + docs/python/hello_world_args_kwargs.py | 5 + docs/python/hello_world_cbv.py | 7 + docs/python/hello_world_fbv.py | 5 + docs/python/hello_world_fbv_with_id.py | 5 + docs/python/register-component.py | 5 +- docs/python/use-mutation-thread-sensitive.py | 2 +- docs/python/views.py | 7 + docs/python/vtc-args-kwargs.py | 21 -- docs/python/vtc-args.py | 23 ++ docs/python/vtc-cbv-compatibility.py | 10 - docs/python/vtc-cbv.py | 12 +- docs/python/vtc-compatibility.py | 15 - docs/python/vtc-fbv-compat.py | 8 - docs/python/vtc-func.py | 12 - docs/python/vtc-request.py | 20 -- docs/python/vtc-strict-parsing.py | 8 +- docs/python/vtc-transforms.py | 13 +- docs/python/vtc.py | 8 +- docs/python/vti-args.py | 20 ++ docs/python/vti-cbv.py | 13 + docs/python/vti-extra-props.py | 15 + docs/python/vti.py | 13 + docs/src/reference/components.md | 230 +++++++++++---- docs/src/reference/decorators.md | 2 +- docs/src/reference/hooks.md | 14 +- docs/src/reference/settings.md | 8 +- docs/src/reference/utils.md | 80 ++++-- mkdocs.yml | 2 +- src/reactpy_django/checks.py | 2 +- src/reactpy_django/components.py | 262 +++++++++++------- src/reactpy_django/config.py | 5 +- src/reactpy_django/exceptions.py | 8 + src/reactpy_django/http/urls.py | 6 +- src/reactpy_django/http/views.py | 23 +- .../templates/reactpy/component.html | 2 +- src/reactpy_django/types.py | 9 - src/reactpy_django/utils.py | 102 ++++--- src/reactpy_django/websocket/consumer.py | 4 +- tests/test_app/apps.py | 16 ++ tests/test_app/components.py | 36 ++- tests/test_app/templates/base.html | 17 +- tests/test_app/templates/errors.html | 31 +++ tests/test_app/tests/test_components.py | 66 +++-- tests/test_app/urls.py | 1 + tests/test_app/views.py | 26 +- 48 files changed, 814 insertions(+), 416 deletions(-) create mode 100644 docs/python/hello_world_app_config_cbv.py create mode 100644 docs/python/hello_world_app_config_fbv.py create mode 100644 docs/python/hello_world_args_kwargs.py create mode 100644 docs/python/hello_world_cbv.py create mode 100644 docs/python/hello_world_fbv.py create mode 100644 docs/python/hello_world_fbv_with_id.py create mode 100644 docs/python/views.py delete mode 100644 docs/python/vtc-args-kwargs.py create mode 100644 docs/python/vtc-args.py delete mode 100644 docs/python/vtc-cbv-compatibility.py delete mode 100644 docs/python/vtc-compatibility.py delete mode 100644 docs/python/vtc-fbv-compat.py delete mode 100644 docs/python/vtc-func.py delete mode 100644 docs/python/vtc-request.py create mode 100644 docs/python/vti-args.py create mode 100644 docs/python/vti-cbv.py create mode 100644 docs/python/vti-extra-props.py create mode 100644 docs/python/vti.py create mode 100644 tests/test_app/apps.py create mode 100644 tests/test_app/templates/errors.html diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f0ec63..cf739c05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,10 +39,19 @@ Using the following categories, list your changes in this order: - ReactPy components can now use SEO compatible rendering! - `settings.py:REACTPY_PRERENDER` can be set to `True` to enable this behavior by default - Or, you can enable it on individual components via the template tag: `{% component "..." prerender="True" %}` +- `reactpy_django.components.view_to_iframe` component has been added, which uses an `