From 0a4b98eaaeaac8d98800e14c1713f7ec1a68487f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 11 Sep 2023 15:00:54 -0700 Subject: [PATCH 01/18] minor docs tweaks --- 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 +- mkdocs.yml | 2 +- 6 files changed, 40 insertions(+), 24 deletions(-) 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/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 From df3907e92d287fc7ea181d4c384d77a27da72a97 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 14 Sep 2023 15:00:10 -0700 Subject: [PATCH 02/18] Skeleton for URL router --- src/js/src/utils.ts | 5 ++--- src/reactpy_django/router/__init__.py | 0 src/reactpy_django/router/components.py | 0 3 files changed, 2 insertions(+), 3 deletions(-) create mode 100644 src/reactpy_django/router/__init__.py create mode 100644 src/reactpy_django/router/components.py diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts index a3f653ce..92c39189 100644 --- a/src/js/src/utils.ts +++ b/src/js/src/utils.ts @@ -68,9 +68,8 @@ export function nextInterval( maxInterval: number ): number { return Math.min( - currentInterval * - // increase interval by backoff multiplier - backoffMultiplier, + // increase interval by backoff multiplier + currentInterval * backoffMultiplier, // don't exceed max interval maxInterval ); diff --git a/src/reactpy_django/router/__init__.py b/src/reactpy_django/router/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/reactpy_django/router/components.py b/src/reactpy_django/router/components.py new file mode 100644 index 00000000..e69de29b From 988df2ffb5e35d89978bc9e61e79d31b74f37659 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 16 Sep 2023 03:38:49 -0700 Subject: [PATCH 03/18] named backhaul thread --- src/reactpy_django/websocket/consumer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index c6a47c27..82a69fb7 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -34,7 +34,9 @@ def start_backhaul_loop(): backhaul_loop.run_forever() -backhaul_thread = Thread(target=start_backhaul_loop, daemon=True) +backhaul_thread = Thread( + target=start_backhaul_loop, daemon=True, name="ReactPyBackhaul" +) class ReactpyAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer): From 7496542e01ca1b75cd97cffcba6da19b443d679c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 14 Dec 2023 17:57:42 -0800 Subject: [PATCH 04/18] Embed the initial HTTP path into the WebSocket URL --- src/js/src/client.ts | 6 +++++- src/js/src/index.ts | 9 ++++++++- src/js/src/types.ts | 24 ++++++++++++------------ src/js/src/utils.ts | 2 +- src/reactpy_django/websocket/consumer.py | 10 +++++----- 5 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/js/src/client.ts b/src/js/src/client.ts index 6f79df77..6966a0f0 100644 --- a/src/js/src/client.ts +++ b/src/js/src/client.ts @@ -1,4 +1,8 @@ -import { BaseReactPyClient, ReactPyClient, ReactPyModule } from "@reactpy/client"; +import { + BaseReactPyClient, + ReactPyClient, + ReactPyModule, +} from "@reactpy/client"; import { createReconnectingWebSocket } from "./utils"; import { ReactPyDjangoClientProps, ReactPyUrls } from "./types"; diff --git a/src/js/src/index.ts b/src/js/src/index.ts index 56a85aac..97b20efd 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -39,10 +39,17 @@ export function mountComponent( } } + // Embed the initial HTTP path into the WebSocket URL + let componentUrl = new URL(`${wsOrigin}/${urlPrefix}/${componentPath}`); + componentUrl.searchParams.append("http_pathname", window.location.pathname); + if (window.location.search) { + componentUrl.searchParams.append("http_search", window.location.search); + } + // Configure a new ReactPy client const client = new ReactPyDjangoClient({ urls: { - componentUrl: `${wsOrigin}/${urlPrefix}/${componentPath}`, + componentUrl: componentUrl, query: document.location.search, jsModules: `${httpOrigin}/${jsModulesPath}`, }, diff --git a/src/js/src/types.ts b/src/js/src/types.ts index 54a0b604..b31276bc 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -1,17 +1,17 @@ export type ReconnectOptions = { - startInterval: number; - maxInterval: number; - maxRetries: number; - backoffMultiplier: number; -} + startInterval: number; + maxInterval: number; + maxRetries: number; + backoffMultiplier: number; +}; export type ReactPyUrls = { - componentUrl: string; - query: string; - jsModules: string; -} + componentUrl: URL; + query: string; + jsModules: string; +}; export type ReactPyDjangoClientProps = { - urls: ReactPyUrls; - reconnectOptions: ReconnectOptions; -} + urls: ReactPyUrls; + reconnectOptions: ReconnectOptions; +}; diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts index 92c39189..56e231e2 100644 --- a/src/js/src/utils.ts +++ b/src/js/src/utils.ts @@ -1,5 +1,5 @@ export function createReconnectingWebSocket(props: { - url: string; + url: URL; readyPromise: Promise; onOpen?: () => void; onMessage: (message: MessageEvent) => void; diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 82a69fb7..7b989944 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -9,6 +9,7 @@ from datetime import timedelta from threading import Thread from typing import Any, MutableMapping, Sequence +from urllib.parse import parse_qs import dill as pickle import orjson @@ -151,14 +152,13 @@ async def run_dispatcher(self): scope = self.scope dotted_path = scope["url_route"]["kwargs"]["dotted_path"] uuid = scope["url_route"]["kwargs"].get("uuid") - search = scope["query_string"].decode() + query_string = parse_qs(scope["query_string"].decode(), strict_parsing=True) + http_pathname = query_string.get("http_pathname", [""])[0] + http_search = query_string.get("http_search", [""])[0] self.recv_queue: asyncio.Queue = asyncio.Queue() connection = Connection( # For `use_connection` scope=scope, - location=Location( - pathname=scope["path"], - search=f"?{search}" if (search and (search != "undefined")) else "", - ), + location=Location(pathname=http_pathname, search=http_search), carrier=ComponentWebsocket(self.close, self.disconnect, dotted_path), ) now = timezone.now() From 0abc3978d9c748b42f8dc9bd0a160e94553dc5d0 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 14 Dec 2023 22:09:53 -0800 Subject: [PATCH 05/18] First cut at `django_router` --- requirements/pkg-deps.txt | 1 + src/reactpy_django/router/components.py | 57 +++++++++++++++++++++++++ src/reactpy_django/router/converters.py | 31 ++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 src/reactpy_django/router/converters.py diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 8eecf7bc..c6102c18 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,6 +1,7 @@ channels >=4.0.0 django >=4.2.0 reactpy >=1.0.2, <1.1.0 +reactpy-router >=0.1.1, <1.0.0 aiofile >=3.0 dill >=0.3.5 orjson >=3.6.0 diff --git a/src/reactpy_django/router/components.py b/src/reactpy_django/router/components.py index e69de29b..8eec3cf2 100644 --- a/src/reactpy_django/router/components.py +++ b/src/reactpy_django/router/components.py @@ -0,0 +1,57 @@ +import re +from typing import Any + +from reactpy_router.core import create_router +from reactpy_router.simple import STAR_PATTERN, ConverterMapping +from reactpy_router.types import Route + +from reactpy_django.router.converters import CONVERTERS + +PARAM_PATTERN = re.compile(r"<(?P\w+)(?P:\w+)?>") + + +# TODO: Make reactpy_router's SimpleRouter generic enough to where we don't have to define our own +class DjangoResolver: + """A simple route resolver that uses regex to match paths""" + + def __init__(self, route: Route) -> None: + self.element = route.element + self.pattern, self.converters = parse_path(route.path) + self.key = self.pattern.pattern + + def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: + match = self.pattern.match(path) + if match: + return ( + self.element, + {k: self.converters[k](v) for k, v in match.groupdict().items()}, + ) + return None + + +# TODO: Make reactpy_router's parse_path generic enough to where we don't have to define our own +def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]: + if path == "*": + return STAR_PATTERN, {} + + pattern = "^" + last_match_end = 0 + converters: ConverterMapping = {} + for match in PARAM_PATTERN.finditer(path): + param_name = match.group("name") + param_type = (match.group("type") or "str").lstrip(":") + try: + param_conv = CONVERTERS[param_type] + except KeyError as e: + raise ValueError( + f"Unknown conversion type {param_type!r} in {path!r}" + ) from e + pattern += re.escape(path[last_match_end : match.start()]) + pattern += f"(?P<{param_name}>{param_conv['regex']})" + converters[param_name] = param_conv["func"] + last_match_end = match.end() + pattern += f"{re.escape(path[last_match_end:])}$" + return re.compile(pattern), converters + + +django_router = create_router(DjangoResolver) diff --git a/src/reactpy_django/router/converters.py b/src/reactpy_django/router/converters.py new file mode 100644 index 00000000..69ccb697 --- /dev/null +++ b/src/reactpy_django/router/converters.py @@ -0,0 +1,31 @@ +from django.urls.converters import ( + IntConverter, + PathConverter, + SlugConverter, + StringConverter, + UUIDConverter, +) +from reactpy_router.simple import ConversionInfo + +CONVERTERS: dict[str, ConversionInfo] = { + "int": { + "regex": IntConverter().regex, + "func": IntConverter().to_python, + }, + "path": { + "regex": PathConverter().regex, + "func": PathConverter().to_python, + }, + "slug": { + "regex": SlugConverter().regex, + "func": SlugConverter().to_python, + }, + "str": { + "regex": StringConverter().regex, + "func": StringConverter().to_python, + }, + "uuid": { + "regex": UUIDConverter().regex, + "func": UUIDConverter().to_python, + }, +} From 041891af67b64a7d71c6187ddc909efa3dcf1aa3 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 14 Dec 2023 22:10:01 -0800 Subject: [PATCH 06/18] Rebuilding JavaScript docs --- docs/src/about/code.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/src/about/code.md b/docs/src/about/code.md index b4790d5b..5ac66404 100644 --- a/docs/src/about/code.md +++ b/docs/src/about/code.md @@ -92,3 +92,12 @@ If you want to manually run the Django test application, you can use the followi cd tests python manage.py runserver ``` + +## Rebuilding JavaScript + +If you want to rebuild this repository's JavaScript, you can use the following command: + +```bash linenums="0" +cd src/js +npm run build +``` From eff04ce9fbb03906fb0c625fcd136d1aabbec72e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 17 Dec 2023 15:40:43 -0800 Subject: [PATCH 07/18] fix typo --- src/reactpy_django/router/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_django/router/components.py b/src/reactpy_django/router/components.py index 8eec3cf2..72681f8a 100644 --- a/src/reactpy_django/router/components.py +++ b/src/reactpy_django/router/components.py @@ -10,7 +10,7 @@ PARAM_PATTERN = re.compile(r"<(?P\w+)(?P:\w+)?>") -# TODO: Make reactpy_router's SimpleRouter generic enough to where we don't have to define our own +# TODO: Make reactpy_router's SimpleResolver generic enough to where we don't have to define our own class DjangoResolver: """A simple route resolver that uses regex to match paths""" From e2cc1ef0cb35c087391924f934f9be56e71cc872 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 20 Dec 2023 13:32:12 -0800 Subject: [PATCH 08/18] functional router --- docs/python/use-location.py | 2 +- docs/src/reference/hooks.md | 10 +-------- src/reactpy_django/hooks.py | 3 +++ src/reactpy_django/router/components.py | 9 ++++---- src/reactpy_django/router/converters.py | 30 +++---------------------- 5 files changed, 12 insertions(+), 42 deletions(-) diff --git a/docs/python/use-location.py b/docs/python/use-location.py index 18611789..6c9e8713 100644 --- a/docs/python/use-location.py +++ b/docs/python/use-location.py @@ -5,4 +5,4 @@ @component def my_component(): my_location = use_location() - return html.div(str(my_location)) + return html.div(my_location.pathname + my_location.search) diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 62986930..3d0684b8 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -275,9 +275,7 @@ This is a shortcut that returns the WebSocket's [`#!python scope`](https://chann ## 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`. +This is a shortcut that returns the browser's current `#!python path` as a `Location` object. === "components.py" @@ -297,12 +295,6 @@ You can expect this hook to provide strings such as `/reactpy/my_path`. | --- | --- | | `#!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`. diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 8115de56..c09f80a8 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -38,6 +38,7 @@ ] = DefaultDict(set) +# TODO: Remove this in the next version def use_location() -> Location: """Get the current route as a `Location` object""" return _use_location() @@ -71,6 +72,7 @@ def use_origin() -> str | None: return None +# TODO: Remove this in the next version def use_scope() -> dict[str, Any]: """Get the current ASGI scope dictionary""" scope = _use_scope() @@ -81,6 +83,7 @@ def use_scope() -> dict[str, Any]: raise TypeError(f"Expected scope to be a dict, got {type(scope)}") +# TODO: Remove this in the next version def use_connection() -> Connection: """Get the current `Connection` object""" return _use_connection() diff --git a/src/reactpy_django/router/components.py b/src/reactpy_django/router/components.py index 72681f8a..da8259ec 100644 --- a/src/reactpy_django/router/components.py +++ b/src/reactpy_django/router/components.py @@ -1,13 +1,15 @@ +from __future__ import annotations + import re from typing import Any from reactpy_router.core import create_router -from reactpy_router.simple import STAR_PATTERN, ConverterMapping +from reactpy_router.simple import ConverterMapping from reactpy_router.types import Route from reactpy_django.router.converters import CONVERTERS -PARAM_PATTERN = re.compile(r"<(?P\w+)(?P:\w+)?>") +PARAM_PATTERN = re.compile(r"<(?P:\w+)?(?P\w+)>") # TODO: Make reactpy_router's SimpleResolver generic enough to where we don't have to define our own @@ -31,9 +33,6 @@ def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: # TODO: Make reactpy_router's parse_path generic enough to where we don't have to define our own def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]: - if path == "*": - return STAR_PATTERN, {} - pattern = "^" last_match_end = 0 converters: ConverterMapping = {} diff --git a/src/reactpy_django/router/converters.py b/src/reactpy_django/router/converters.py index 69ccb697..3611f63e 100644 --- a/src/reactpy_django/router/converters.py +++ b/src/reactpy_django/router/converters.py @@ -1,31 +1,7 @@ -from django.urls.converters import ( - IntConverter, - PathConverter, - SlugConverter, - StringConverter, - UUIDConverter, -) +from django.urls.converters import get_converters from reactpy_router.simple import ConversionInfo CONVERTERS: dict[str, ConversionInfo] = { - "int": { - "regex": IntConverter().regex, - "func": IntConverter().to_python, - }, - "path": { - "regex": PathConverter().regex, - "func": PathConverter().to_python, - }, - "slug": { - "regex": SlugConverter().regex, - "func": SlugConverter().to_python, - }, - "str": { - "regex": StringConverter().regex, - "func": StringConverter().to_python, - }, - "uuid": { - "regex": UUIDConverter().regex, - "func": UUIDConverter().to_python, - }, + name: {"regex": converter.regex, "func": converter.to_python} + for name, converter in get_converters().items() } From e9d7a1f94848e78dac9cf35605936341fb78027c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 22 Dec 2023 20:55:01 -0800 Subject: [PATCH 09/18] test environment --- src/reactpy_django/router/components.py | 4 +-- tests/test_app/router/__init__.py | 0 tests/test_app/router/components.py | 38 +++++++++++++++++++++++++ tests/test_app/router/urls.py | 7 +++++ tests/test_app/router/views.py | 5 ++++ tests/test_app/templates/router.html | 20 +++++++++++++ tests/test_app/urls.py | 1 + 7 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 tests/test_app/router/__init__.py create mode 100644 tests/test_app/router/components.py create mode 100644 tests/test_app/router/urls.py create mode 100644 tests/test_app/router/views.py create mode 100644 tests/test_app/templates/router.html diff --git a/src/reactpy_django/router/components.py b/src/reactpy_django/router/components.py index da8259ec..60aca8fd 100644 --- a/src/reactpy_django/router/components.py +++ b/src/reactpy_django/router/components.py @@ -9,7 +9,7 @@ from reactpy_django.router.converters import CONVERTERS -PARAM_PATTERN = re.compile(r"<(?P:\w+)?(?P\w+)>") +PARAM_PATTERN = re.compile(r"<(?P\w+:)?(?P\w+)>") # TODO: Make reactpy_router's SimpleResolver generic enough to where we don't have to define our own @@ -38,7 +38,7 @@ def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]: converters: ConverterMapping = {} for match in PARAM_PATTERN.finditer(path): param_name = match.group("name") - param_type = (match.group("type") or "str").lstrip(":") + param_type = (match.group("type") or "str").strip(":") try: param_conv = CONVERTERS[param_type] except KeyError as e: diff --git a/tests/test_app/router/__init__.py b/tests/test_app/router/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_app/router/components.py b/tests/test_app/router/components.py new file mode 100644 index 00000000..09e53ce5 --- /dev/null +++ b/tests/test_app/router/components.py @@ -0,0 +1,38 @@ +from reactpy import component, html, use_location +from reactpy_django.router.components import django_router +from reactpy_router import route, use_params, use_query + + +@component +def display_params(*args): + params = use_params() + return html._( + html.div(f"Params: {params}"), + *args, + ) + + +@component +def main(): + location = use_location() + query = use_query() + + route_info = html._( + html.div( + {"class_name": "router-path", "data-path": location.pathname}, + f"Path Name: {location.pathname}", + ), + html.div(f"Query String: {location.search}"), + html.div(f"Query: {query}"), + ) + + return django_router( + route("/router/", html.div("Path 1", route_info)), + route("/router/any//", display_params("Path 2", route_info)), + route("/router/integer//", display_params("Path 3", route_info)), + route("/router/path//", display_params("Path 4", route_info)), + route("/router/slug//", display_params("Path 5", route_info)), + route("/router/string//", display_params("Path 6", route_info)), + route("/router/uuid//", display_params("Path 7", route_info)), + route("/router/", None, route("abc/", display_params("Path 8", route_info))), + ) diff --git a/tests/test_app/router/urls.py b/tests/test_app/router/urls.py new file mode 100644 index 00000000..b497b951 --- /dev/null +++ b/tests/test_app/router/urls.py @@ -0,0 +1,7 @@ +from django.urls import re_path + +from test_app.router.views import router + +urlpatterns = [ + re_path(r"^router/(?P.*)/?$", router), +] diff --git a/tests/test_app/router/views.py b/tests/test_app/router/views.py new file mode 100644 index 00000000..6189f4a4 --- /dev/null +++ b/tests/test_app/router/views.py @@ -0,0 +1,5 @@ +from django.shortcuts import render + + +def router(request, path=None): + return render(request, "router.html", {}) diff --git a/tests/test_app/templates/router.html b/tests/test_app/templates/router.html new file mode 100644 index 00000000..ee15fb64 --- /dev/null +++ b/tests/test_app/templates/router.html @@ -0,0 +1,20 @@ +{% load static %} {% load reactpy %} + + + + + + + + + ReactPy + + + +

ReactPy Router Test Page

+
+ {% component "test_app.router.components.main" %} +
+ + + diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index fea71309..d80d40e2 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -35,6 +35,7 @@ class AccessUser: ), path("", include("test_app.prerender.urls")), path("", include("test_app.performance.urls")), + path("", include("test_app.router.urls")), path("reactpy/", include("reactpy_django.http.urls")), path("admin/", admin.site.urls), ] From ad04d777bfcc18aee62590f02ae8bb22ecf17446 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 31 Dec 2023 23:46:08 -0800 Subject: [PATCH 10/18] initial docs page --- docs/src/reference/router.md | 13 +++++++++++++ mkdocs.yml | 1 + 2 files changed, 14 insertions(+) create mode 100644 docs/src/reference/router.md diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md new file mode 100644 index 00000000..642f3758 --- /dev/null +++ b/docs/src/reference/router.md @@ -0,0 +1,13 @@ +## Overview + +

+ +We provide a version of [`reactpy-router`](https://github.com/reactive-python/reactpy-router) that is compatible with Django, and uses Django's URL routing syntax. + +

+ +--- + +## Django Router + +... diff --git a/mkdocs.yml b/mkdocs.yml index 5c308d00..d2f6bc4a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,6 +7,7 @@ nav: - Reference: - Components: reference/components.md - Hooks: reference/hooks.md + - Router: reference/router.md - Decorators: reference/decorators.md - Utilities: reference/utils.md - Template Tag: reference/template-tag.md From 3b3ec51198268c11218e17de8dadff71a3a9ee7d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 31 Dec 2023 23:46:24 -0800 Subject: [PATCH 11/18] exprot django router --- src/reactpy_django/router/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/reactpy_django/router/__init__.py b/src/reactpy_django/router/__init__.py index e69de29b..ea3e1d3b 100644 --- a/src/reactpy_django/router/__init__.py +++ b/src/reactpy_django/router/__init__.py @@ -0,0 +1,3 @@ +from reactpy_django.router.components import django_router + +__all__ = ["django_router"] From b58e668aca1efb4add7b609b027a6c715da4ab6e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 31 Dec 2023 23:46:34 -0800 Subject: [PATCH 12/18] add another test scenario --- tests/test_app/router/components.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_app/router/components.py b/tests/test_app/router/components.py index 09e53ce5..13982490 100644 --- a/tests/test_app/router/components.py +++ b/tests/test_app/router/components.py @@ -35,4 +35,8 @@ def main(): route("/router/string//", display_params("Path 6", route_info)), route("/router/uuid//", display_params("Path 7", route_info)), route("/router/", None, route("abc/", display_params("Path 8", route_info))), + route( + "/router/two///", + display_params("Path 9", route_info), + ), ) From d3749375e409c473fbbc22cfc56f5813819fec9e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 7 Jan 2024 04:12:09 -0800 Subject: [PATCH 13/18] fix merge conflicts --- docs/python/use-location.py | 4 ++-- docs/src/reference/hooks.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/python/use-location.py b/docs/python/use-location.py index a3e99d3e..d7afcbac 100644 --- a/docs/python/use-location.py +++ b/docs/python/use-location.py @@ -4,6 +4,6 @@ @component def my_component(): - my_location = use_location() + location = use_location() - return html.div(my_location.pathname + my_location.search) + return html.div(location.pathname + location.search) diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 66871685..176cb318 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -366,7 +366,7 @@ Shortcut that returns the WebSocket or HTTP connection's [scope](https://channel ### `#!python use_location()` -This is a shortcut that returns the browser's current `#!python path` as a `Location` object. +Shortcut that returns the browser's current `#!python Location`. === "components.py" From 94538d66a48188c35e1bc6a4237fa777e706ea75 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 7 Jan 2024 04:19:25 -0800 Subject: [PATCH 14/18] revise todo --- src/reactpy_django/hooks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index a08ef4f8..0ebf2ea8 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -45,7 +45,7 @@ ] = DefaultDict(set) -# TODO: Remove this in the next version +# TODO: Deprecate this once the equivalent hook gets moved to reactpy.hooks.* def use_location() -> Location: """Get the current route as a `Location` object""" return _use_location() @@ -79,7 +79,7 @@ def use_origin() -> str | None: return None -# TODO: Remove this in the next version +# TODO: Deprecate this once the equivalent hook gets moved to reactpy.hooks.* def use_scope() -> dict[str, Any]: """Get the current ASGI scope dictionary""" scope = _use_scope() @@ -90,7 +90,7 @@ def use_scope() -> dict[str, Any]: raise TypeError(f"Expected scope to be a dict, got {type(scope)}") -# TODO: Remove this in the next version +# TODO: Deprecate this once the equivalent hook gets moved to reactpy.hooks.* def use_connection() -> ConnectionType: """Get the current `Connection` object""" return _use_connection() From 472bae019aa1b635c4f60c95d26231a0b93a9355 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Jan 2024 23:33:43 -0800 Subject: [PATCH 15/18] fix merge conflict --- docs/src/reference/hooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 176cb318..3ba03266 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -386,7 +386,7 @@ Shortcut that returns the browser's current `#!python Location`. | --- | --- | | `#!python Location` | An object containing the current URL's `#!python pathname` and `#!python search` query. | -## Use Origin +### `#!python use_origin()` Shortcut that returns the WebSocket or HTTP connection's `#!python origin`. From f3c62858f3766709923fa0fd6903cba458b28ac2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 10 Jan 2024 00:55:03 -0800 Subject: [PATCH 16/18] router docs --- docs/python/django-router.py | 17 +++++++++++++++++ docs/src/reference/router.md | 34 +++++++++++++++++++++++++++++++--- mkdocs.yml | 2 +- src/reactpy_django/__init__.py | 3 ++- 4 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 docs/python/django-router.py diff --git a/docs/python/django-router.py b/docs/python/django-router.py new file mode 100644 index 00000000..714ca585 --- /dev/null +++ b/docs/python/django-router.py @@ -0,0 +1,17 @@ +from reactpy import component, html +from reactpy_django.router import django_router +from reactpy_router import route + + +@component +def my_component(): + return django_router( + route("/router/", html.div("Example 1")), + route("/router/any//", html.div("Example 2")), + route("/router/integer//", html.div("Example 3")), + route("/router/path//", html.div("Example 4")), + route("/router/slug//", html.div("Example 5")), + route("/router/string//", html.div("Example 6")), + route("/router/uuid//", html.div("Example 7")), + route("/router/two_values///", html.div("Example 9")), + ) diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md index 642f3758..7a280c6d 100644 --- a/docs/src/reference/router.md +++ b/docs/src/reference/router.md @@ -2,12 +2,40 @@

-We provide a version of [`reactpy-router`](https://github.com/reactive-python/reactpy-router) that is compatible with Django, and uses Django's URL routing syntax. +A variant of [`reactpy-router`](https://github.com/reactive-python/reactpy-router) that utilizes Django conventions.

+!!! abstract "Note" + + Looking for more details on URL routing? + + This package only contains Django specific URL routing features. Standard features can be found within [`reactive-python/reactpy-router`](https://reactive-python.github.io/reactpy-router/). + --- -## Django Router +## `#!python django_router(*routes)` + +=== "components.py" + + ```python + {% include "../../python/django-router.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | `#!python *routes` | `#!python Route` | An object from `reactpy-router` containing a `#!python path`, `#!python element`, and child `#!python *routes`. | N/A | + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python VdomDict | None` | The matched component/path after it has been fully rendered. | + +??? question "How is this different from `#!python reactpy_router.simple.router`?" -... + This component utilizes `reactpy-router` under the hood, but provides a more Django-like URL routing syntax. diff --git a/mkdocs.yml b/mkdocs.yml index 12bf352f..2eff331d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,7 +7,7 @@ nav: - Reference: - Components: reference/components.md - Hooks: reference/hooks.md - - Router: reference/router.md + - URL Router: reference/router.md - Decorators: reference/decorators.md - Utilities: reference/utils.md - Template Tag: reference/template-tag.md diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index 3985fcf4..fb9ed61d 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -2,7 +2,7 @@ import nest_asyncio -from reactpy_django import checks, components, decorators, hooks, types, utils +from reactpy_django import checks, components, decorators, hooks, router, types, utils from reactpy_django.websocket.paths import ( REACTPY_WEBSOCKET_PATH, REACTPY_WEBSOCKET_ROUTE, @@ -18,6 +18,7 @@ "types", "utils", "checks", + "router", ] # Fixes bugs with REACTPY_BACKHAUL_THREAD + built-in asyncio event loops. From 49de6de7970131123c7da79ceb193ada4b65910b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 10 Jan 2024 01:33:54 -0800 Subject: [PATCH 17/18] add changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a36624..a5dc50df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ Using the following categories, list your changes in this order: ### Added +- Built-in Single Page Application (SPA) support! + - `reactpy_django.router.django_router` can be used to render your Django application as a SPA. - SEO compatible rendering! - `settings.py:REACTPY_PRERENDER` can be set to `True` to make components pre-render by default. - Or, you can enable it on individual components via the template tag: `{% component "..." prerender="True" %}`. From ba1283149cc1aee6dbd916907ba7a997f3f35f6d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 10 Jan 2024 02:15:43 -0800 Subject: [PATCH 18/18] add tests --- tests/test_app/router/components.py | 4 +-- tests/test_app/tests/test_components.py | 47 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/tests/test_app/router/components.py b/tests/test_app/router/components.py index 13982490..8f020990 100644 --- a/tests/test_app/router/components.py +++ b/tests/test_app/router/components.py @@ -1,5 +1,5 @@ from reactpy import component, html, use_location -from reactpy_django.router.components import django_router +from reactpy_django.router import django_router from reactpy_router import route, use_params, use_query @@ -19,7 +19,7 @@ def main(): route_info = html._( html.div( - {"class_name": "router-path", "data-path": location.pathname}, + {"id": "router-path", "data-path": location.pathname}, f"Path Name: {location.pathname}", ), html.div(f"Query String: {location.search}"), diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index af936955..ca35cf50 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -553,3 +553,50 @@ def test_use_user_data_with_default(self): "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3'}", user_data_div.text_content(), ) + + def test_url_router(self): + new_page = self.browser.new_page() + try: + new_page.goto(f"{self.live_server_url}/router/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/", path.get_attribute("data-path")) + + new_page.goto(f"{self.live_server_url}/router/any/123/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/any/123/", path.get_attribute("data-path")) + + new_page.goto(f"{self.live_server_url}/router/integer/123/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/integer/123/", path.get_attribute("data-path")) + + new_page.goto(f"{self.live_server_url}/router/path/abc/123/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/path/abc/123/", path.get_attribute("data-path")) + + new_page.goto(f"{self.live_server_url}/router/slug/abc-123/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/slug/abc-123/", path.get_attribute("data-path")) + + new_page.goto(f"{self.live_server_url}/router/string/abc/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/string/abc/", path.get_attribute("data-path")) + + new_page.goto( + f"{self.live_server_url}/router/uuid/123e4567-e89b-12d3-a456-426614174000/" + ) + path = new_page.wait_for_selector("#router-path") + self.assertIn( + "/router/uuid/123e4567-e89b-12d3-a456-426614174000/", + path.get_attribute("data-path"), + ) + + new_page.goto(f"{self.live_server_url}/router/abc/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/abc/", path.get_attribute("data-path")) + + new_page.goto(f"{self.live_server_url}/router/two/123/abc/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/two/123/abc/", path.get_attribute("data-path")) + + finally: + new_page.close()