diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml index 11a7fa23..00172d4f 100644 --- a/.github/workflows/publish-develop-docs.yml +++ b/.github/workflows/publish-develop-docs.yml @@ -19,10 +19,11 @@ jobs: python-version: 3.x - name: Install dependencies run: pip install --upgrade pip hatch uv - - name: Publish Develop Docs + - name: Configure Git run: | git config user.name github-actions git config user.email github-actions@github.com - hatch run docs:deploy_develop + - name: Publish Develop Docs + run: hatch run docs:deploy_develop concurrency: group: publish-docs diff --git a/.github/workflows/publish-latest-docs.yml b/.github/workflows/publish-latest-docs.yml index 697b10da..41ced54d 100644 --- a/.github/workflows/publish-latest-docs.yml +++ b/.github/workflows/publish-latest-docs.yml @@ -19,10 +19,11 @@ jobs: python-version: 3.x - name: Install dependencies run: pip install --upgrade pip hatch uv - - name: Publish ${{ github.event.release.name }} Docs + - name: Configure Git run: | git config user.name github-actions git config user.email github-actions@github.com - hatch run docs:deploy_latest ${{ github.ref_name }} + - name: Publish ${{ github.event.release.name }} Docs + run: hatch run docs:deploy_latest ${{ github.ref_name }} concurrency: group: publish-docs diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-python.yml similarity index 76% rename from .github/workflows/publish-py.yml rename to .github/workflows/publish-python.yml index ae1ace7f..93fbc969 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-python.yml @@ -1,6 +1,3 @@ -# This workflows will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - name: Publish Python on: diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index 8faca864..0a7ae35a 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -11,7 +11,7 @@ on: - cron: "0 0 * * *" jobs: - python: + python-source: runs-on: ubuntu-latest strategy: matrix: @@ -29,6 +29,23 @@ jobs: run: pip install --upgrade pip hatch uv - name: Run Single DB Tests run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_single_db -v + + python-source-multi-db: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Use Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Python Dependencies + run: pip install --upgrade pip hatch uv - name: Run Multi-DB Tests run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_multi_db -v @@ -46,3 +63,18 @@ jobs: run: pip install --upgrade pip hatch uv - name: Check Python formatting run: hatch fmt src tests --check + + python-types: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Install Python Dependencies + run: pip install --upgrade pip hatch uv + - name: Check Python formatting + run: hatch run python:type_check diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c848e4..4c72dd2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,11 +21,30 @@ Don't forget to remove deprecated code on each major release! - Nothing (yet)! +### [5.2.0] - 2024-12-29 + +### Added + +- User login/logout features! + - `reactpy_django.hooks.use_auth` to provide **persistent** `login` and `logout` functionality to your components. + - `settings.py:REACTPY_AUTH_TOKEN_MAX_AGE` to control the maximum seconds before ReactPy's login token expires. + - `settings.py:REACTPY_CLEAN_AUTH_TOKENS` to control whether ReactPy should clean up expired authentication tokens during automatic cleanups. +- Automatically convert Django forms to ReactPy forms via the new `reactpy_django.components.django_form` component! +- The ReactPy component tree can now be forcibly re-rendered via the new `reactpy_django.hooks.use_rerender` hook. + +### Changed + +- Refactoring of internal code to improve maintainability. No changes to publicly documented API. + +### Fixed + +- Fixed bug where pre-rendered components could generate a `SynchronousOnlyOperation` exception if they access a freshly logged out Django user object. + ## [5.1.1] - 2024-12-02 ### Fixed -- Fixed regression in v5.1.0 where components would sometimes not output debug messages when `settings.py:DEBUG` is enabled. +- Fixed regression from the previous release where components would sometimes not output debug messages when `settings.py:DEBUG` is enabled. ### Changed @@ -525,7 +544,8 @@ Don't forget to remove deprecated code on each major release! - Support for IDOM within the Django -[Unreleased]: https://github.com/reactive-python/reactpy-django/compare/5.1.1...HEAD +[Unreleased]: https://github.com/reactive-python/reactpy-django/compare/5.2.0...HEAD +[5.2.0]: https://github.com/reactive-python/reactpy-django/compare/5.1.1...5.2.0 [5.1.1]: https://github.com/reactive-python/reactpy-django/compare/5.1.0...5.1.1 [5.1.0]: https://github.com/reactive-python/reactpy-django/compare/5.0.0...5.1.0 [5.0.0]: https://github.com/reactive-python/reactpy-django/compare/4.0.0...5.0.0 diff --git a/README.md b/README.md index 89d1fb11..f60e7a2d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ - [Multiple root components](https://reactive-python.github.io/reactpy-django/latest/reference/template-tag/) - [Cross-process communication/signaling](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#use-channel-layer) - [Django view to ReactPy component conversion](https://reactive-python.github.io/reactpy-django/latest/reference/components/#view-to-component) +- [Django form to ReactPy component conversion](https://reactive-python.github.io/reactpy-django/latest/reference/components/#django-form) - [Django static file access](https://reactive-python.github.io/reactpy-django/latest/reference/components/#django-css) - [Django database access](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#use-query) diff --git a/docs/examples/html/django_form_bootstrap.html b/docs/examples/html/django_form_bootstrap.html new file mode 100644 index 00000000..6aba84ca --- /dev/null +++ b/docs/examples/html/django_form_bootstrap.html @@ -0,0 +1,11 @@ +{% load django_bootstrap5 %} + + +{% bootstrap_css %} +{% bootstrap_javascript %} + + +{% bootstrap_form form %} +{% bootstrap_button button_type="submit" content="OK" %} +{% bootstrap_button button_type="reset" content="Reset" %} diff --git a/docs/examples/python/django_form.py b/docs/examples/python/django_form.py new file mode 100644 index 00000000..51960db1 --- /dev/null +++ b/docs/examples/python/django_form.py @@ -0,0 +1,10 @@ +from reactpy import component, html + +from example.forms import MyForm +from reactpy_django.components import django_form + + +@component +def basic_form(): + children = [html.input({"type": "submit"})] + return django_form(MyForm, bottom_children=children) diff --git a/docs/examples/python/django_form_bootstrap.py b/docs/examples/python/django_form_bootstrap.py new file mode 100644 index 00000000..449e1cc4 --- /dev/null +++ b/docs/examples/python/django_form_bootstrap.py @@ -0,0 +1,9 @@ +from reactpy import component + +from example.forms import MyForm +from reactpy_django.components import django_form + + +@component +def basic_form(): + return django_form(MyForm, form_template="bootstrap_form.html") diff --git a/docs/examples/python/django_form_class.py b/docs/examples/python/django_form_class.py new file mode 100644 index 00000000..e556295e --- /dev/null +++ b/docs/examples/python/django_form_class.py @@ -0,0 +1,5 @@ +from django import forms + + +class MyForm(forms.Form): + username = forms.CharField(label="Username") diff --git a/docs/examples/python/django_form_on_success.py b/docs/examples/python/django_form_on_success.py new file mode 100644 index 00000000..d8b6927c --- /dev/null +++ b/docs/examples/python/django_form_on_success.py @@ -0,0 +1,21 @@ +from reactpy import component, hooks, html +from reactpy_router import navigate + +from example.forms import MyForm +from reactpy_django.components import django_form +from reactpy_django.types import FormEventData + + +@component +def basic_form(): + submitted, set_submitted = hooks.use_state(False) + + def on_submit(event: FormEventData): + """This function will be called when the form is successfully submitted.""" + set_submitted(True) + + if submitted: + return navigate("/homepage") + + children = [html.input({"type": "submit"})] + return django_form(MyForm, on_success=on_submit, bottom_children=children) diff --git a/docs/examples/python/example/forms.py b/docs/examples/python/example/forms.py new file mode 100644 index 00000000..8d3eefc0 --- /dev/null +++ b/docs/examples/python/example/forms.py @@ -0,0 +1,4 @@ +from django import forms + + +class MyForm(forms.Form): ... diff --git a/docs/examples/python/example/models.py b/docs/examples/python/example/models.py new file mode 100644 index 00000000..2bf062b9 --- /dev/null +++ b/docs/examples/python/example/models.py @@ -0,0 +1,4 @@ +from django.db import models + + +class TodoItem(models.Model): ... diff --git a/docs/examples/python/pyscript_ffi.py b/docs/examples/python/pyscript_ffi.py new file mode 100644 index 00000000..d744dd88 --- /dev/null +++ b/docs/examples/python/pyscript_ffi.py @@ -0,0 +1,14 @@ +from pyscript import document, window +from reactpy import component, html + + +@component +def root(): + def on_click(event): + my_element = document.querySelector("#example") + my_element.innerText = window.location.hostname + + return html.div( + {"id": "example"}, + html.button({"onClick": on_click}, "Click Me!"), + ) diff --git a/docs/examples/python/use_auth.py b/docs/examples/python/use_auth.py new file mode 100644 index 00000000..2bb1bcbb --- /dev/null +++ b/docs/examples/python/use_auth.py @@ -0,0 +1,23 @@ +from django.contrib.auth import get_user_model +from reactpy import component, html + +from reactpy_django.hooks import use_auth, use_user + + +@component +def my_component(): + auth = use_auth() + user = use_user() + + async def login_user(event): + new_user, _created = await get_user_model().objects.aget_or_create(username="ExampleUser") + await auth.login(new_user) + + async def logout_user(event): + await auth.logout() + + return html.div( + f"Current User: {user}", + html.button({"onClick": login_user}, "Login"), + html.button({"onClick": logout_user}, "Logout"), + ) diff --git a/docs/examples/python/use_rerender.py b/docs/examples/python/use_rerender.py new file mode 100644 index 00000000..cd160e17 --- /dev/null +++ b/docs/examples/python/use_rerender.py @@ -0,0 +1,15 @@ +from uuid import uuid4 + +from reactpy import component, html + +from reactpy_django.hooks import use_rerender + + +@component +def my_component(): + rerender = use_rerender() + + def on_click(): + rerender() + + return html.div(f"UUID: {uuid4()}", html.button({"onClick": on_click}, "Rerender")) diff --git a/docs/includes/auth-middleware-stack.md b/docs/includes/auth-middleware-stack.md new file mode 100644 index 00000000..7cc0c7f8 --- /dev/null +++ b/docs/includes/auth-middleware-stack.md @@ -0,0 +1,3 @@ +```python linenums="0" +{% include "../examples/python/configure_asgi_middleware.py" start="# start" %} +``` diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 100b669b..bde5ac08 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -124,6 +124,6 @@ site_description: It's React, but in Python. Now with Django integration. copyright: '©
Reactive Python and affiliates. ' repo_url: https://github.com/reactive-python/reactpy-django site_url: https://reactive-python.github.io/reactpy-django -repo_name: ReactPy Django (GitHub) +repo_name: ReactPy Django edit_uri: edit/main/docs/src docs_dir: src diff --git a/docs/src/about/contributing.md b/docs/src/about/contributing.md index 59f4f989..b78a9508 100644 --- a/docs/src/about/contributing.md +++ b/docs/src/about/contributing.md @@ -62,6 +62,7 @@ By utilizing `hatch`, the following commands are available to manage the develop | `hatch fmt --formatter` | Run only formatters | | `hatch run javascript:check` | Run the JavaScript linter/formatter | | `hatch run javascript:fix` | Run the JavaScript linter/formatter and write fixes to disk | +| `hatch run python:type_check` | Run the Python type checker | ??? tip "Configure your IDE for linting" diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index 1b4ce080..d2ff722d 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -48,3 +48,4 @@ linter linters linting formatters +bootstrap_form 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 407fe61d..371893e1 100644 --- a/docs/src/learn/add-reactpy-to-a-django-project.md +++ b/docs/src/learn/add-reactpy-to-a-django-project.md @@ -87,9 +87,7 @@ Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [` In these situations will need to ensure you are using `#!python AuthMiddlewareStack`. - ```python linenums="0" - {% include "../../examples/python/configure_asgi_middleware.py" start="# start" %} - ``` + {% include "../../includes/auth-middleware-stack.md" %} ??? question "Where is my `asgi.py`?" diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index 4186af42..26feda67 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -156,7 +156,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. ??? info "Existing limitations" - There are currently several limitations of using `#!python view_to_component` that may be resolved in a future version. + There are currently several limitations of using `#!python view_to_component` that will be [resolved in a future version](https://github.com/reactive-python/reactpy-django/issues/269). - Requires manual intervention to change HTTP methods to anything other than `GET`. - ReactPy events cannot conveniently be attached to converted view HTML. @@ -292,12 +292,12 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. ??? info "Existing limitations" - There are currently several limitations of using `#!python view_to_iframe` that may be resolved in a future version. + There are currently several limitations of using `#!python view_to_iframe` which may be [resolved in a future version](https://github.com/reactive-python/reactpy-django/issues/268). - No built-in method of signalling events back to the parent component. - - All provided `#!python *args` and `#!python *kwargs` must be serializable values, since they are encoded into the URL. + - All provided `#!python args` and `#!python kwargs` must be serializable values, since they are encoded into the URL. - The `#!python iframe` will always load **after** the parent component. - - CSS styling for `#!python iframe` elements tends to be awkward/difficult. + - CSS styling for `#!python iframe` elements tends to be awkward. ??? question "How do I use this for Class Based Views?" @@ -381,6 +381,104 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. --- +## Django Form + +Automatically convert a Django form into a ReactPy component. + +Compatible with both [standard Django forms](https://docs.djangoproject.com/en/stable/topics/forms/#building-a-form) and [ModelForms](https://docs.djangoproject.com/en/stable/topics/forms/modelforms/). + +=== "components.py" + + ```python + {% include "../../examples/python/django_form.py" %} + ``` + +=== "forms.py" + + ```python + {% include "../../examples/python/django_form_class.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | `#!python form` | `#!python type[Form | ModelForm]` | The form to convert. | N/A | + | `#!python on_success` | `#!python AsyncFormEvent | SyncFormEvent | None` | A callback function that is called when the form is successfully submitted. | `#!python None` | + | `#!python on_error` | `#!python AsyncFormEvent | SyncFormEvent | None` | A callback function that is called when the form submission fails. | `#!python None` | + | `#!python on_receive_data` | `#!python AsyncFormEvent | SyncFormEvent | None` | A callback function that is called before newly submitted form data is rendered. | `#!python None` | + | `#!python on_change` | `#!python AsyncFormEvent | SyncFormEvent | None` | A callback function that is called when a form field is modified by the user. | `#!python None` | + | `#!python auto_save` | `#!python bool` | If `#!python True`, the form will automatically call `#!python save` on successful submission of a `#!python ModelForm`. This has no effect on regular `#!python Form` instances. | `#!python True` | + | `#!python extra_props` | `#!python dict[str, Any] | None` | Additional properties to add to the `#!html
` element. | `#!python None` | + | `#!python extra_transforms` | `#!python Sequence[Callable[[VdomDict], Any]] | None` | A list of functions that transforms the newly generated VDOM. The functions will be repeatedly called on each VDOM node. | `#!python None` | + | `#!python form_template` | `#!python str | None` | The template to use for the form. If `#!python None`, Django's default template is used. | `#!python None` | + | `#!python thread_sensitive` | `#!python bool` | Whether to run event callback functions in thread sensitive mode. This mode only applies to sync functions, and is turned on by default due to Django ORM limitations. | `#!python True` | + | `#!python top_children` | `#!python Sequence[Any]` | Additional elements to add to the top of the form. | `#!python tuple` | + | `#!python bottom_children` | `#!python Sequence[Any]` | Additional elements to add to the bottom of the form. | `#!python tuple` | + | `#!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 | + | --- | --- | + | `#!python Component` | A ReactPy component. | + +??? info "Existing limitations" + + The following fields are currently incompatible with `#!python django_form`: `#!python FileField`, `#!python ImageField`, `#!python SplitDateTimeField`, and `#!python MultiValueField`. + + Compatibility for these fields will be [added in a future version](https://github.com/reactive-python/reactpy-django/issues/270). + +??? question "How do I style these forms with Bootstrap?" + + You can style these forms by using a form styling library. In the example below, it is assumed that you have already installed [`django-bootstrap5`](https://pypi.org/project/django-bootstrap5/). + + After installing a form styling library, you can then provide ReactPy a custom `#!python form_template` parameter. This parameter allows you to specify a custom HTML template to use to render this the form. + + Note that you can also set a global default for `form_template` by using [`settings.py:REACTPY_DEFAULT_FORM_TEMPLATE`](./settings.md#reactpy_default_form_template). + + === "components.py" + + ```python + {% include "../../examples/python/django_form_bootstrap.py" %} + ``` + + === "forms.py" + + ```python + {% include "../../examples/python/django_form_class.py" %} + ``` + + === "bootstrap_form.html" + + ```jinja + {% include "../../examples/html/django_form_bootstrap.html" %} + ``` + +??? question "How do I handle form success/errors?" + + You can react to form state by providing a callback function to any of the following parameters: `#!python on_success`, `#!python on_error`, `#!python on_receive_data`, and `#!python on_change`. + + These functions will be called when the form is submitted. + + In the example below, we will use the `#!python on_success` parameter to change the URL upon successful submission. + + === "components.py" + + ```python + {% include "../../examples/python/django_form_on_success.py" %} + ``` + + === "forms.py" + + ```python + {% include "../../examples/python/django_form_class.py" %} + ``` + +--- + ## Django CSS Allows you to defer loading a CSS stylesheet until a component begins rendering. This stylesheet must be stored within [Django's static files](https://docs.djangoproject.com/en/stable/howto/static-files/). diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 65bf1727..89ce805c 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -46,7 +46,7 @@ Query functions can be sync or async. | --- | --- | --- | --- | | `#!python query` | `#!python Callable[FuncParams, Awaitable[Inferred]] | Callable[FuncParams, Inferred]` | A function that executes a query and returns some data. | N/A | | `#!python kwargs` | `#!python dict[str, Any] | None` | Keyword arguments to passed into the `#!python query` function. | `#!python None` | - | `#!python thread_sensitive` | `#!python bool` | Whether to run your query function in thread sensitive mode. This mode only applies to sync query functions, and is turned on by default due to Django ORM limitations. | `#!python True` | + | `#!python thread_sensitive` | `#!python bool` | Whether to run your query function in thread sensitive mode. This setting only applies to sync functions, and is turned on by default due to Django ORM limitations. | `#!python True` | | `#!python postprocessor` | `#!python AsyncPostprocessor | SyncPostprocessor | None` | A callable that processes the query `#!python data` before it is returned. The first argument of postprocessor function must be the query `#!python data`. All proceeding arguments are optional `#!python postprocessor_kwargs`. This postprocessor function must return the modified `#!python data`. | `#!python None` | | `#!python postprocessor_kwargs` | `#!python dict[str, Any] | None` | Keyworded arguments passed into the `#!python postprocessor` function. | `#!python None` | @@ -188,7 +188,7 @@ Mutation functions can be sync or async. | Name | Type | Description | Default | | --- | --- | --- | --- | | `#!python mutation` | `#!python Callable[FuncParams, bool | None] | Callable[FuncParams, Awaitable[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 thread_sensitive` | `#!python bool` | Whether to run the mutation in thread sensitive mode. This mode only applies to sync mutation functions, and is turned on by default due to Django ORM limitations. | `#!python True` | + | `#!python thread_sensitive` | `#!python bool` | Whether to run the mutation in thread sensitive mode. This setting only applies to sync functions, and is turned on by default due to Django ORM limitations. | `#!python True` | | `#!python refetch` | `#!python Callable[..., Any] | Sequence[Callable[..., Any]] | None` | A query function (the function you provide to your `#!python use_query` hook) or a sequence of query functions that need a `#!python refetch` if the mutation succeeds. This is useful for refreshing data after a mutation has been performed. | `#!python None` | **Returns** @@ -271,9 +271,86 @@ Mutation functions can be sync or async. --- +## User Hooks + +--- + +### Use Auth + +Provides a `#!python NamedTuple` containing `#!python async login` and `#!python async logout` functions. + +This hook utilizes the Django's authentication framework in a way that provides **persistent** login. + +=== "components.py" + + ```python + {% include "../../examples/python/use_auth.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + `#!python None` + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python UseAuthTuple` | A named tuple containing `#!python login` and `#!python logout` async functions. | + +??? warning "Extra Django configuration required" + + Your ReactPy WebSocket must utilize `#!python AuthMiddlewareStack` in order to use this hook. + + {% include "../../includes/auth-middleware-stack.md" %} + +??? question "Why use this instead of `#!python channels.auth.login`?" + + The `#!python channels.auth.*` functions cannot trigger re-renders of your ReactPy components. Additionally, they do not provide persistent authentication when used within ReactPy. + + Django's authentication design requires cookies to retain login status. ReactPy is rendered via WebSockets, and browsers do not allow active WebSocket connections to modify cookies. + + To work around this limitation, when `#!python use_auth().login()` is called within your application, ReactPy performs the following process... + + 1. The server authenticates the user into the WebSocket session + 2. The server generates a temporary login token linked to the WebSocket session + 3. The server commands the browser to fetch the login token via HTTP + 4. The client performs the HTTP request + 5. The server returns the HTTP response, which contains all necessary cookies + 6. The client stores these cookies in the browser + + This ultimately results in persistent authentication which will be retained even if the browser tab is refreshed. + +--- + +### Use User + +Shortcut that returns the WebSocket or HTTP connection's `#!python User`. + +=== "components.py" + + ```python + {% include "../../examples/python/use_user.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + `#!python None` + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python AbstractUser` | A Django `#!python User`, which can also be an `#!python AnonymousUser`. | + +--- + ### Use User Data -Store or retrieve a `#!python dict` containing user data specific to the connection's `#!python User`. +Store or retrieve a `#!python dict` containing arbitrary data specific to the connection's `#!python User`. This hook is useful for storing user-specific data, such as preferences, settings, or any generic key-value pairs. @@ -522,7 +599,7 @@ You can expect this hook to provide strings such as `http://example.com`. Shortcut that returns the root component's `#!python id` from the WebSocket or HTTP connection. -The root ID is a randomly generated `#!python uuid4`. It is notable to mention that it is persistent across the current connection. The `uuid` is reset when the page is refreshed. +The root ID is a randomly generated `#!python uuid4`. It is notable to mention that it is persistent across the current connection. The `uuid` is reset only when the page is refreshed. This is useful when used in combination with [`#!python use_channel_layer`](#use-channel-layer) to send messages to a specific component instance, and/or retain a backlog of messages in case that component is disconnected via `#!python use_channel_layer( ... , group_discard=False)`. @@ -546,14 +623,14 @@ This is useful when used in combination with [`#!python use_channel_layer`](#use --- -### Use User +### Use Re-render -Shortcut that returns the WebSocket or HTTP connection's `#!python User`. +Returns a function that can be used to trigger a re-render of the entire component tree. === "components.py" ```python - {% include "../../examples/python/use_user.py" %} + {% include "../../examples/python/use_rerender.py" %} ``` ??? example "See Interface" @@ -566,4 +643,4 @@ Shortcut that returns the WebSocket or HTTP connection's `#!python User`. | Type | Description | | --- | --- | - | `#!python AbstractUser` | A Django `#!python User`, which can also be an `#!python AnonymousUser`. | + | `#!python Callable[[], None]` | A function that triggers a re-render of the entire component tree. | diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md index 6b1c78c4..50d0b7db 100644 --- a/docs/src/reference/settings.md +++ b/docs/src/reference/settings.md @@ -6,12 +6,6 @@ These are ReactPy-Django's default settings values. You can modify these values

-!!! abstract "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. - --- ## General Settings @@ -34,7 +28,7 @@ The prefix used for all ReactPy WebSocket and HTTP URLs. **Example Value(s):** `#!python "example_project.postprocessor"`, `#!python None` -Dotted path to the default `#!python reactpy_django.hooks.use_query` postprocessor function. +Dotted path to the default postprocessor function used by the [`use_query`](./hooks.md#use-query) hook. Postprocessor functions can be async or sync. Here is an example of a sync postprocessor function: @@ -48,13 +42,29 @@ Set `#!python REACTPY_DEFAULT_QUERY_POSTPROCESSOR` to `#!python None` to disable --- +### `#!python REACTPY_DEFAULT_FORM_TEMPLATE` + +**Default:** `#!python None` + +**Example Value(s):** `#!python "my_templates/bootstrap_form.html"` + +File path to the default form template used by the [`django_form`](./components.md#django-form) component. + +This file path must be valid to Django's [template finder](https://docs.djangoproject.com/en/stable/topics/templates/#support-for-template-engines). + +--- + +## Authentication Settings + +--- + ### `#!python REACTPY_AUTH_BACKEND` **Default:** `#!python "django.contrib.auth.backends.ModelBackend"` **Example Value(s):** `#!python "example_project.auth.MyModelBackend"` -Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if: +Dotted path to the Django authentication backend to use for ReactPy components. This is typically needed if: 1. You are using `#!python settings.py:REACTPY_AUTO_RELOGIN=True` and... 2. You are using `#!python AuthMiddlewareStack` and... @@ -63,6 +73,22 @@ Dotted path to the Django authentication backend to use for ReactPy components. --- +### `#!python REACTPY_AUTH_TOKEN_MAX_AGE` + +**Default:** `#!python 30` + +**Example Value(s):** `#!python 5` + +Maximum seconds before ReactPy's login token expires. + +This setting exists because Django's authentication design requires cookies to retain login status. ReactPy is rendered via WebSockets, and browsers do not allow active WebSocket connections to modify cookies. + +To work around this limitation, this setting provides a maximum validity period of a temporary login token. When `#!python reactpy_django.hooks.use_auth().login()` is called within your application, ReactPy will automatically create this temporary login token and command the browser to fetch it via HTTP. + +This setting should be a reasonably low value, but still be high enough to account for a combination of client lag, slow internet, and server response time. + +--- + ### `#!python REACTPY_AUTO_RELOGIN` **Default:** `#!python False` @@ -129,9 +155,9 @@ This setting is incompatible with [`daphne`](https://github.com/django/daphne). **Example Value(s):** `#!python True` -Configures whether to use an async ReactPy rendering queue. When enabled, large renders will no longer block smaller renders from taking place. Additionally, prevents the rendering queue from being blocked on waiting for async effects to startup/shutdown (which is typically a relatively slow operation). +Configures whether to use an async ReactPy rendering queue. When enabled, large renders will no longer block smaller renders from taking place. Additionally, prevents the rendering queue from being blocked on waiting for async effects to startup/shutdown (which is a relatively slow operation). -This setting is currently experimental, and currently no effort is made to de-duplicate renders. For example, if parent and child components are scheduled to render at the same time, both renders will take place even though a single render of the parent component would have been sufficient. +This setting is currently in early release, and currently no effort is made to de-duplicate renders. For example, if parent and child components are scheduled to render at the same time, both renders will take place, even though a single render would have been sufficient. --- @@ -258,6 +284,16 @@ Configures whether ReactPy should clean up expired component sessions during aut --- +### `#!python REACTPY_CLEAN_AUTH_TOKENS` + +**Default:** `#!python True` + +**Example Value(s):** `#!python False` + +Configures whether ReactPy should clean up expired authentication tokens during automatic clean up operations. + +--- + ### `#!python REACTPY_CLEAN_USER_DATA` **Default:** `#!python True` diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index f969eb00..f41eaf44 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -214,9 +214,17 @@ The entire file path provided is loaded directly into the browser, and must have {% include "../../examples/python/pyodide_js_module.py" %} ``` - **PyScript FFI** + **PyScript Foreign Function Interface (FFI)** - ... + PyScript FFI has similar functionality to Pyodide's `js` module, but utilizes a different API. + + There are two importable modules available that are available within the FFI interface: `window` and `document`. + + === "root.py" + + ```python + {% include "../../examples/python/pyscript_ffi.py" %} + ``` **PyScript JS Modules** @@ -314,7 +322,7 @@ The entire file path provided is loaded directly into the browser, and must have This template tag configures the current page to be able to run `pyscript`. -You can optionally use this tag to configure the current PyScript environment. For example, you can include a list of Python packages to automatically install within the PyScript environment. +You can optionally use this tag to configure the current PyScript environment, such as adding dependencies. === "my_template.html" diff --git a/pyproject.toml b/pyproject.toml index dbb94c21..2354e3a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ installer = "uv" [[tool.hatch.build.hooks.build-scripts.scripts]] commands = [ "bun install --cwd src/js", - "bun build src/js/src/index.tsx --outfile src/reactpy_django/static/reactpy_django/client.js --minify", + "bun build src/js/src/index.ts --outfile src/reactpy_django/static/reactpy_django/client.js --minify", 'cd src/build_scripts && python copy_dir.py "src/js/node_modules/@pyscript/core/dist" "src/reactpy_django/static/reactpy_django/pyscript"', 'cd src/build_scripts && python copy_dir.py "src/js/node_modules/morphdom/dist" "src/reactpy_django/static/reactpy_django/morphdom"', ] @@ -94,6 +94,9 @@ extra-dependencies = [ "twisted", "tblib", "servestatic", + "django-bootstrap5", + "decorator", + ] matrix-name-format = "{variable}-{value}" @@ -140,13 +143,29 @@ pythonpath = [".", "tests/"] ################################ [tool.hatch.envs.django] -extra-dependencies = ["channels[daphne]>=4.0.0", "twisted", "servestatic"] +extra-dependencies = [ + "channels[daphne]>=4.0.0", + "twisted", + "servestatic", + "django-bootstrap5", + "decorator", + "playwright", +] [tool.hatch.envs.django.scripts] runserver = [ "cd tests && python manage.py migrate --noinput", "cd tests && python manage.py runserver", ] +makemigrations = ["cd tests && python manage.py makemigrations"] +clean = ["cd tests && python manage.py clean_reactpy -v 3"] +clean_sessions = ["cd tests && python manage.py clean_reactpy --sessions -v 3"] +clean_auth_tokens = [ + "cd tests && python manage.py clean_reactpy --auth-tokens -v 3", +] +clean_user_data = [ + "cd tests && python manage.py clean_reactpy --user-data -v 3", +] ####################################### # >>> Hatch Documentation Scripts <<< # @@ -179,6 +198,16 @@ linkcheck = [ deploy_latest = ["cd docs && mike deploy --push --update-aliases {args} latest"] deploy_develop = ["cd docs && mike deploy --push develop"] +################################ +# >>> Hatch Python Scripts <<< # +################################ + +[tool.hatch.envs.python] +extra-dependencies = ["django-stubs", "channels-redis", "pyright"] + +[tool.hatch.envs.python.scripts] +type_check = ["pyright src"] + ############################ # >>> Hatch JS Scripts <<< # ############################ @@ -195,7 +224,7 @@ fix = ["cd src/js && bun install", "cd src/js && bun run format"] ######################### [tool.ruff] -extend-exclude = ["*/migrations/*", ".venv/*", ".eggs/*", ".nox/*", "build/*"] +extend-exclude = ["*/migrations/*", ".venv/*", ".eggs/*", "build/*"] line-length = 120 format.preview = true lint.extend-ignore = [ diff --git a/src/js/src/client.ts b/src/js/src/client.ts index 93d2f00b..a856fb3a 100644 --- a/src/js/src/client.ts +++ b/src/js/src/client.ts @@ -30,14 +30,14 @@ export class ReactPyDjangoClient this.prerenderElement.remove(); this.prerenderElement = null; } - if (this.offlineElement) { + if (this.offlineElement && this.mountElement) { this.mountElement.hidden = true; this.offlineElement.hidden = false; } }, onOpen: () => { // If offlineElement exists, hide it and show the mountElement - if (this.offlineElement) { + if (this.offlineElement && this.mountElement) { this.offlineElement.hidden = true; this.mountElement.hidden = false; } diff --git a/src/js/src/components.ts b/src/js/src/components.ts new file mode 100644 index 00000000..176a1f30 --- /dev/null +++ b/src/js/src/components.ts @@ -0,0 +1,88 @@ +import { DjangoFormProps, HttpRequestProps } from "./types"; +import React from "react"; +import ReactDOM from "react-dom"; +/** + * Interface used to bind a ReactPy node to React. + */ +export function bind(node) { + return { + create: (type, props, children) => + React.createElement(type, props, ...children), + render: (element) => { + ReactDOM.render(element, node); + }, + unmount: () => ReactDOM.unmountComponentAtNode(node), + }; +} + +export function DjangoForm({ + onSubmitCallback, + formId, +}: DjangoFormProps): null { + React.useEffect(() => { + const form = document.getElementById(formId) as HTMLFormElement; + + // Submission event function + const onSubmitEvent = (event) => { + event.preventDefault(); + const formData = new FormData(form); + + // Convert the FormData object to a plain object by iterating through it + // If duplicate keys are present, convert the value into an array of values + const entries = formData.entries(); + const formDataArray = Array.from(entries); + const formDataObject = formDataArray.reduce((acc, [key, value]) => { + if (acc[key]) { + if (Array.isArray(acc[key])) { + acc[key].push(value); + } else { + acc[key] = [acc[key], value]; + } + } else { + acc[key] = value; + } + return acc; + }, {}); + + onSubmitCallback(formDataObject); + }; + + // Bind the event listener + if (form) { + form.addEventListener("submit", onSubmitEvent); + } + + // Unbind the event listener when the component dismounts + return () => { + if (form) { + form.removeEventListener("submit", onSubmitEvent); + } + }; + }, []); + + return null; +} + +export function HttpRequest({ method, url, body, callback }: HttpRequestProps) { + React.useEffect(() => { + fetch(url, { + method: method, + body: body, + }) + .then((response) => { + response + .text() + .then((text) => { + callback(response.status, text); + }) + .catch(() => { + callback(response.status, ""); + }); + }) + .catch(() => { + callback(520, ""); + }); + }, []); + + return null; +} diff --git a/src/js/src/index.ts b/src/js/src/index.ts new file mode 100644 index 00000000..01856c7d --- /dev/null +++ b/src/js/src/index.ts @@ -0,0 +1,2 @@ +export { HttpRequest, DjangoForm, bind } from "./components"; +export { mountComponent } from "./mount"; diff --git a/src/js/src/index.tsx b/src/js/src/mount.tsx similarity index 97% rename from src/js/src/index.tsx rename to src/js/src/mount.tsx index 51a387f3..81115f9e 100644 --- a/src/js/src/index.tsx +++ b/src/js/src/mount.tsx @@ -69,7 +69,7 @@ export function mountComponent( // Replace the prerender element with the real element on the first layout update if (client.prerenderElement) { client.onMessage("layout-update", ({ path, model }) => { - if (client.prerenderElement) { + if (client.prerenderElement && client.mountElement) { client.prerenderElement.replaceWith(client.mountElement); client.prerenderElement = null; } diff --git a/src/js/src/types.ts b/src/js/src/types.ts index eea8a866..1f0e2b23 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -18,3 +18,15 @@ export type ReactPyDjangoClientProps = { prerenderElement: HTMLElement | null; offlineElement: HTMLElement | null; }; + +export interface DjangoFormProps { + onSubmitCallback: (data: Object) => void; + formId: string; +} + +export interface HttpRequestProps { + method: string; + url: string; + body: string; + callback: (status: Number, response: string) => void; +} diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index 67bf30cf..3c07cd87 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -13,7 +13,7 @@ ) from reactpy_django.websocket.paths import REACTPY_WEBSOCKET_ROUTE -__version__ = "5.1.1" +__version__ = "5.2.0" __all__ = [ "REACTPY_WEBSOCKET_ROUTE", "components", diff --git a/src/reactpy_django/auth/__init__.py b/src/reactpy_django/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py new file mode 100644 index 00000000..e0a1e065 --- /dev/null +++ b/src/reactpy_django/auth/components.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import asyncio +import contextlib +from logging import getLogger +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from django.urls import reverse +from reactpy import component, hooks + +from reactpy_django.javascript_components import HttpRequest +from reactpy_django.models import AuthToken + +if TYPE_CHECKING: + from django.contrib.sessions.backends.base import SessionBase + +_logger = getLogger(__name__) + + +@component +def root_manager(child: Any): + """This component is serves as the parent component for any ReactPy component tree, + which allows for the management of the entire component tree.""" + scope = hooks.use_connection().scope + _, set_rerender = hooks.use_state(uuid4) + + @hooks.use_effect(dependencies=[]) + def setup_asgi_scope(): + """Store trigger functions in the websocket scope so that ReactPy-Django's hooks can command + any relevant actions.""" + scope["reactpy"]["rerender"] = rerender + + def rerender(): + """Event that can force a rerender of the entire component tree.""" + set_rerender(uuid4()) + + return child + + +@component +def auth_manager(): + """This component uses a client-side component alongside an authentication token + to make the client (browser) to switch the HTTP auth session, to make it match the websocket session. + + Used to force persistent authentication between Django's websocket and HTTP stack.""" + from reactpy_django import config + + sync_needed, set_sync_needed = hooks.use_state(False) + token = hooks.use_ref("") + scope = hooks.use_connection().scope + + @hooks.use_effect(dependencies=[]) + def setup_asgi_scope(): + """Store trigger functions in the websocket scope so that ReactPy-Django's hooks can command + any relevant actions.""" + scope["reactpy"]["synchronize_auth"] = synchronize_auth + + @hooks.use_effect(dependencies=[sync_needed]) + async def synchronize_auth_watchdog(): + """Detect if the client has taken too long to request a auth session synchronization. + + This effect will automatically be cancelled if the session is successfully + synchronized (via effect dependencies).""" + if sync_needed: + await asyncio.sleep(config.REACTPY_AUTH_TOKEN_MAX_AGE + 0.1) + await asyncio.to_thread( + _logger.warning, + f"Client did not switch authentication sessions within {config.REACTPY_AUTH_TOKEN_MAX_AGE} (REACTPY_AUTH_TOKEN_MAX_AGE) seconds.", + ) + set_sync_needed(False) + + async def synchronize_auth(): + """Event that can command the client to switch HTTP auth sessions (to match the websocket session).""" + session: SessionBase | None = scope.get("session") + if not session or not session.session_key: + return + + # Delete previous token to resolve race conditions where... + # 1. Login was called multiple times before the first one is completed. + # 2. Login was called, but the server failed to respond to the HTTP request. + if token.current: + with contextlib.suppress(AuthToken.DoesNotExist): + obj = await AuthToken.objects.aget(value=token.current) + await obj.adelete() + + # Create a fresh token + token.set_current(str(uuid4())) + + # Begin the process of synchronizing HTTP and websocket auth sessions + obj = await AuthToken.objects.acreate(value=token.current, session_key=session.session_key) + await obj.asave() + set_sync_needed(True) + + async def synchronize_auth_callback(status_code: int, response: str): + """This callback acts as a communication bridge, allowing the client to notify the server + of the status of auth session switch.""" + set_sync_needed(False) + if status_code >= 300 or status_code < 200: + await asyncio.to_thread( + _logger.error, + f"Client returned unexpected HTTP status code ({status_code}) while trying to synchronize authentication sessions.", + ) + + # If needed, synchronize authenication sessions by configuring all relevant session cookies. + # This is achieved by commanding the client to perform a HTTP request to our API endpoint + # that will set any required cookies. + if sync_needed: + return HttpRequest( + { + "method": "GET", + "url": reverse("reactpy:auth_manager", args=[token.current]), + "body": None, + "callback": synchronize_auth_callback, + }, + ) + + return None diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 888cc47d..32f38768 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -1,6 +1,7 @@ import contextlib import math import sys +from uuid import uuid4 from django.contrib.staticfiles.finders import find from django.core.checks import Error, Tags, Warning, register @@ -37,6 +38,7 @@ def reactpy_warnings(app_configs, **kwargs): try: reverse("reactpy:web_modules", kwargs={"file": "example"}) reverse("reactpy:view_to_iframe", kwargs={"dotted_path": "example"}) + reverse("reactpy:session_manager", args=[str(uuid4())]) except Exception: warnings.append( Warning( @@ -218,7 +220,7 @@ def reactpy_warnings(app_configs, **kwargs): ) ) - # Check if REACTPY_CLEAN_SESSION is not a valid property + # Check if user misspelled REACTPY_CLEAN_SESSIONS if getattr(settings, "REACTPY_CLEAN_SESSION", None): warnings.append( Warning( @@ -228,6 +230,27 @@ def reactpy_warnings(app_configs, **kwargs): ) ) + # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a large value + auth_token_timeout = config.REACTPY_AUTH_TOKEN_MAX_AGE + if isinstance(auth_token_timeout, int) and auth_token_timeout > 120: + warnings.append( + Warning( + "REACTPY_AUTH_TOKEN_MAX_AGE is set to a very large value.", + hint="It is suggested to keep REACTPY_AUTH_TOKEN_MAX_AGE under 120 seconds to prevent security risks.", + id="reactpy_django.W020", + ) + ) + + # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a small value + if isinstance(auth_token_timeout, int) and auth_token_timeout <= 2: + warnings.append( + Warning( + "REACTPY_AUTH_TOKEN_MAX_AGE is set to a very low value.", + hint="It is suggested to keep REACTPY_AUTH_TOKEN_MAX_AGE above 2 seconds to account for client and server latency.", + id="reactpy_django.W021", + ) + ) + return warnings @@ -511,4 +534,34 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_CLEAN_AUTH_TOKENS is a valid data type + if not isinstance(config.REACTPY_CLEAN_AUTH_TOKENS, bool): + errors.append( + Error( + "Invalid type for REACTPY_CLEAN_AUTH_TOKENS.", + hint="REACTPY_CLEAN_AUTH_TOKENS should be a boolean.", + id="reactpy_django.E027", + ) + ) + + # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a valid data type + if not isinstance(config.REACTPY_AUTH_TOKEN_MAX_AGE, int): + errors.append( + Error( + "Invalid type for REACTPY_AUTH_TOKEN_MAX_AGE.", + hint="REACTPY_AUTH_TOKEN_MAX_AGE should be an integer.", + id="reactpy_django.E028", + ) + ) + + # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a positive integer + if isinstance(config.REACTPY_AUTH_TOKEN_MAX_AGE, int) and config.REACTPY_AUTH_TOKEN_MAX_AGE < 0: + errors.append( + Error( + "Invalid value for REACTPY_AUTH_TOKEN_MAX_AGE.", + hint="REACTPY_AUTH_TOKEN_MAX_AGE should be a non-negative integer.", + id="reactpy_django.E029", + ) + ) + return errors diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index d9ed0e6a..f2ca561c 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -1,39 +1,35 @@ +"""This file contains Django related components. Most of these components utilize wrappers to fix type hints.""" + from __future__ import annotations import json -import os from typing import TYPE_CHECKING, Any, Callable, Union, cast from urllib.parse import urlencode -from uuid import uuid4 -from django.contrib.staticfiles.finders import find -from django.core.cache import caches from django.http import HttpRequest from django.urls import reverse from reactpy import component, hooks, html, utils from reactpy.types import ComponentType, Key, VdomDict from reactpy_django.exceptions import ViewNotRegisteredError -from reactpy_django.html import pyscript -from reactpy_django.utils import ( - generate_obj_name, - import_module, - render_pyscript_template, - render_view, - vdom_or_component_to_string, -) +from reactpy_django.forms.components import _django_form +from reactpy_django.pyscript.components import _pyscript_component +from reactpy_django.utils import cached_static_file, generate_obj_name, import_module, render_view if TYPE_CHECKING: from collections.abc import Sequence + from django.forms import Form, ModelForm from django.views import View + from reactpy_django.types import AsyncFormEvent, SyncFormEvent, ViewToComponentConstructor, ViewToIframeConstructor + def view_to_component( view: Callable | View | str, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> Any: +) -> ViewToComponentConstructor: """Converts a Django view to a ReactPy component. Keyword Args: @@ -52,7 +48,7 @@ def constructor( *args, key: Key | None = None, **kwargs, - ): + ) -> ComponentType: return _view_to_component( view=view, transforms=transforms, @@ -66,7 +62,7 @@ def constructor( return constructor -def view_to_iframe(view: Callable | View | str, extra_props: dict[str, Any] | None = None): +def view_to_iframe(view: Callable | View | str, extra_props: dict[str, Any] | None = None) -> ViewToIframeConstructor: """ Args: view: The view function or class to convert, or the dotted path to the view. @@ -82,13 +78,13 @@ def constructor( *args, key: Key | None = None, **kwargs, - ): + ) -> ComponentType: return _view_to_iframe(view=view, extra_props=extra_props, args=args, kwargs=kwargs, key=key) return constructor -def django_css(static_path: str, key: Key | None = None): +def django_css(static_path: str, key: Key | None = None) -> ComponentType: """Fetches a CSS static file for use within ReactPy. This allows for deferred CSS loading. Args: @@ -101,7 +97,7 @@ def django_css(static_path: str, key: Key | None = None): return _django_css(static_path=static_path, key=key) -def django_js(static_path: str, key: Key | None = None): +def django_js(static_path: str, key: Key | None = None) -> ComponentType: """Fetches a JS static file for use within ReactPy. This allows for deferred JS loading. Args: @@ -114,11 +110,69 @@ def django_js(static_path: str, key: Key | None = None): return _django_js(static_path=static_path, key=key) +def django_form( + form: type[Form | ModelForm], + *, + on_success: AsyncFormEvent | SyncFormEvent | None = None, + on_error: AsyncFormEvent | SyncFormEvent | None = None, + on_receive_data: AsyncFormEvent | SyncFormEvent | None = None, + on_change: AsyncFormEvent | SyncFormEvent | None = None, + auto_save: bool = True, + extra_props: dict[str, Any] | None = None, + extra_transforms: Sequence[Callable[[VdomDict], Any]] | None = None, + form_template: str | None = None, + thread_sensitive: bool = True, + top_children: Sequence[Any] = (), + bottom_children: Sequence[Any] = (), + key: Key | None = None, +) -> ComponentType: + """Converts a Django form to a ReactPy component. + + Args: + form: The form to convert. + + Keyword Args: + on_success: A callback function that is called when the form is successfully submitted. + on_error: A callback function that is called when the form submission fails. + on_receive_data: A callback function that is called before newly submitted form data is rendered. + on_change: A callback function that is called when a form field is modified by the user. + auto_save: If `True`, the form will automatically call `save` on successful submission of \ + a `ModelForm`. This has no effect on regular `Form` instances. + extra_props: Additional properties to add to the `html.form` element. + extra_transforms: A list of functions that transforms the newly generated VDOM. \ + The functions will be repeatedly called on each VDOM node. + form_template: The template to use for the form. If `None`, Django's default template is used. + thread_sensitive: Whether to run event callback functions in thread sensitive mode. \ + This mode only applies to sync functions, and is turned on by default due to Django \ + ORM limitations. + top_children: Additional elements to add to the top of the form. + bottom_children: Additional elements to add to the bottom of the form. + key: A key to uniquely identify this component which is unique amongst a component's \ + immediate siblings. + """ + + return _django_form( + form=form, + on_success=on_success, + on_error=on_error, + on_receive_data=on_receive_data, + on_change=on_change, + auto_save=auto_save, + extra_props=extra_props or {}, + extra_transforms=extra_transforms or [], + form_template=form_template, + thread_sensitive=thread_sensitive, + top_children=top_children, + bottom_children=bottom_children, + key=key, + ) + + def pyscript_component( *file_paths: str, initial: str | VdomDict | ComponentType = "", root: str = "root", -): +) -> ComponentType: """ Args: file_paths: File path to your client-side component. If multiple paths are \ @@ -146,7 +200,6 @@ def _view_to_component( args: Sequence | None, kwargs: dict | None, ): - """The actual component. Used to prevent pollution of acceptable kwargs keys.""" converted_view, set_converted_view = hooks.use_state(cast(Union[VdomDict, None], None)) _args: Sequence = args or () _kwargs: dict = kwargs or {} @@ -155,7 +208,7 @@ def _view_to_component( else: _request = HttpRequest() _request.method = "GET" - resolved_view: Callable = import_module(view) if isinstance(view, str) else view + resolved_view: Callable = import_module(view) if isinstance(view, str) else view # type: ignore # Render the view render within a hook @hooks.use_effect( @@ -164,7 +217,7 @@ def _view_to_component( json.dumps([_args, _kwargs], default=generate_obj_name), ] ) - async def async_render(): + async def _render_view(): """Render the view in an async hook to avoid blocking the main thread.""" # Render the view response = await render_view(resolved_view, _request, _args, _kwargs) @@ -187,12 +240,11 @@ def _view_to_iframe( extra_props: dict[str, Any] | None, args: Sequence, kwargs: dict, -) -> VdomDict: - """The actual component. Used to prevent pollution of acceptable kwargs keys.""" +): from reactpy_django.config import REACTPY_REGISTERED_IFRAME_VIEWS if hasattr(view, "view_class"): - view = view.view_class + view = view.view_class # type: ignore dotted_path = view if isinstance(view, str) else generate_obj_name(view) registered_view = REACTPY_REGISTERED_IFRAME_VIEWS.get(dotted_path) @@ -222,57 +274,9 @@ def _view_to_iframe( @component def _django_css(static_path: str): - return html.style(_cached_static_contents(static_path)) + return html.style(cached_static_file(static_path)) @component def _django_js(static_path: str): - return html.script(_cached_static_contents(static_path)) - - -def _cached_static_contents(static_path: str) -> str: - from reactpy_django.config import REACTPY_CACHE - - # Try to find the file within Django's static files - abs_path = find(static_path) - if not abs_path: - msg = f"Could not find static file {static_path} within Django's static files." - raise FileNotFoundError(msg) - - # Fetch the file from cache, if available - last_modified_time = os.stat(abs_path).st_mtime - cache_key = f"reactpy_django:static_contents:{static_path}" - file_contents: str | None = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time)) - if file_contents is None: - with open(abs_path, encoding="utf-8") as static_file: - file_contents = static_file.read() - caches[REACTPY_CACHE].delete(cache_key) - caches[REACTPY_CACHE].set(cache_key, file_contents, timeout=None, version=int(last_modified_time)) - - return file_contents - - -@component -def _pyscript_component( - *file_paths: str, - initial: str | VdomDict | ComponentType = "", - root: str = "root", -): - rendered, set_rendered = hooks.use_state(False) - uuid = uuid4().hex.replace("-", "") - initial = vdom_or_component_to_string(initial, uuid=uuid) - executor = render_pyscript_template(file_paths, uuid, root) - - if not rendered: - # FIXME: This is needed to properly re-render PyScript during a WebSocket - # disconnection / reconnection. There may be a better way to do this in the future. - set_rendered(True) - return None - - return html._( - html.div( - {"id": f"pyscript-{uuid}", "className": "pyscript", "data-uuid": uuid}, - initial, - ), - pyscript({"async": ""}, executor), - ) + return html.script(cached_static_file(static_path)) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 3f46c48b..cc3ca2fc 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -39,6 +39,11 @@ "REACTPY_SESSION_MAX_AGE", 259200, # Default to 3 days ) +REACTPY_AUTH_TOKEN_MAX_AGE: int = getattr( + settings, + "REACTPY_AUTH_TOKEN_MAX_AGE", + 30, # Default to 30 seconds +) REACTPY_CACHE: str = getattr( settings, "REACTPY_CACHE", @@ -121,8 +126,18 @@ "REACTPY_CLEAN_SESSIONS", True, ) +REACTPY_CLEAN_AUTH_TOKENS: bool = getattr( + settings, + "REACTPY_CLEAN_AUTH_TOKENS", + True, +) REACTPY_CLEAN_USER_DATA: bool = getattr( settings, "REACTPY_CLEAN_USER_DATA", True, ) +REACTPY_DEFAULT_FORM_TEMPLATE: str | None = getattr( + settings, + "REACTPY_DEFAULT_FORM_TEMPLATE", + None, +) diff --git a/src/reactpy_django/decorators.py b/src/reactpy_django/decorators.py index 804e10bb..6b3d220e 100644 --- a/src/reactpy_django/decorators.py +++ b/src/reactpy_django/decorators.py @@ -35,10 +35,10 @@ def _wrapper(*args, **kwargs): return _wrapper - return decorator + return decorator # type: ignore -@component +@component # type: ignore def _user_passes_test(component_constructor, fallback, test_func, *args, **kwargs): """Dedicated component for `user_passes_test` to allow us to always have access to hooks.""" user = use_user() diff --git a/src/reactpy_django/forms/__init__.py b/src/reactpy_django/forms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/reactpy_django/forms/components.py b/src/reactpy_django/forms/components.py new file mode 100644 index 00000000..9aa99497 --- /dev/null +++ b/src/reactpy_django/forms/components.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Union, cast +from uuid import uuid4 + +from django.forms import Form, ModelForm +from reactpy import component, hooks, html, utils +from reactpy.core.events import event +from reactpy.web import export, module_from_file + +from reactpy_django.forms.transforms import ( + convert_html_props_to_reactjs, + convert_textarea_children_to_prop, + infer_key_from_attributes, + intercept_anchor_links, + set_value_prop_on_select_element, + transform_value_prop_on_input_element, +) +from reactpy_django.forms.utils import convert_form_fields, validate_form_args +from reactpy_django.types import AsyncFormEvent, FormEventData, SyncFormEvent +from reactpy_django.utils import ensure_async + +if TYPE_CHECKING: + from collections.abc import Sequence + + from reactpy.core.types import VdomDict + +DjangoForm = export( + module_from_file("reactpy-django", file=Path(__file__).parent.parent / "static" / "reactpy_django" / "client.js"), + ("DjangoForm"), +) + + +@component +def _django_form( + form: type[Form | ModelForm], + on_success: AsyncFormEvent | SyncFormEvent | None, + on_error: AsyncFormEvent | SyncFormEvent | None, + on_receive_data: AsyncFormEvent | SyncFormEvent | None, + on_change: AsyncFormEvent | SyncFormEvent | None, + auto_save: bool, + extra_props: dict, + extra_transforms: Sequence[Callable[[VdomDict], Any]], + form_template: str | None, + thread_sensitive: bool, + top_children: Sequence, + bottom_children: Sequence, +): + from reactpy_django import config + + uuid = hooks.use_ref(uuid4().hex.replace("-", "")).current + top_children_count = hooks.use_ref(len(top_children)) + bottom_children_count = hooks.use_ref(len(bottom_children)) + submitted_data, set_submitted_data = hooks.use_state({} or None) + rendered_form, set_rendered_form = hooks.use_state(cast(Union[str, None], None)) + + # Initialize the form with the provided data + validate_form_args(top_children, top_children_count, bottom_children, bottom_children_count, form) + initialized_form = form(data=submitted_data) + form_event = FormEventData( + form=initialized_form, submitted_data=submitted_data or {}, set_submitted_data=set_submitted_data + ) + + # Validate and render the form + @hooks.use_effect(dependencies=[str(submitted_data)]) + async def render_form(): + """Forms must be rendered in an async loop to allow database fields to execute.""" + if submitted_data: + await ensure_async(initialized_form.full_clean, thread_sensitive=thread_sensitive)() + success = not initialized_form.errors.as_data() + if success and on_success: + await ensure_async(on_success, thread_sensitive=thread_sensitive)(form_event) + if not success and on_error: + await ensure_async(on_error, thread_sensitive=thread_sensitive)(form_event) + if success and auto_save and isinstance(initialized_form, ModelForm): + await ensure_async(initialized_form.save)() + set_submitted_data(None) + + set_rendered_form( + await ensure_async(initialized_form.render)(form_template or config.REACTPY_DEFAULT_FORM_TEMPLATE) + ) + + async def on_submit_callback(new_data: dict[str, Any]): + """Callback function provided directly to the client side listener. This is responsible for transmitting + the submitted form data to the server for processing.""" + convert_form_fields(new_data, initialized_form) + + if on_receive_data: + new_form_event = FormEventData( + form=initialized_form, submitted_data=new_data, set_submitted_data=set_submitted_data + ) + await ensure_async(on_receive_data, thread_sensitive=thread_sensitive)(new_form_event) + + if submitted_data != new_data: + set_submitted_data(new_data) + + async def _on_change(_event): + """Event that exist solely to allow the user to detect form changes.""" + if on_change: + await ensure_async(on_change, thread_sensitive=thread_sensitive)(form_event) + + if not rendered_form: + return None + + return html.form( + extra_props + | { + "id": f"reactpy-{uuid}", + # Intercept the form submission to prevent the browser from navigating + "onSubmit": event(lambda _: None, prevent_default=True), + "onChange": _on_change, + }, + DjangoForm({"onSubmitCallback": on_submit_callback, "formId": f"reactpy-{uuid}"}), + *top_children, + utils.html_to_vdom( + rendered_form, + convert_html_props_to_reactjs, + convert_textarea_children_to_prop, + set_value_prop_on_select_element, + transform_value_prop_on_input_element, + intercept_anchor_links, + infer_key_from_attributes, + *extra_transforms, + strict=False, + ), + *bottom_children, + ) diff --git a/src/reactpy_django/forms/transforms.py b/src/reactpy_django/forms/transforms.py new file mode 100644 index 00000000..1a757b77 --- /dev/null +++ b/src/reactpy_django/forms/transforms.py @@ -0,0 +1,488 @@ +# type: ignore +# TODO: Almost everything in this module should be moved to `reactpy.utils._mutate_vdom()`. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from reactpy.core.events import EventHandler, to_event_handler_function + +if TYPE_CHECKING: + from reactpy.core.types import VdomDict + + +def convert_html_props_to_reactjs(vdom_tree: VdomDict) -> VdomDict: + """Transformation that standardizes the prop names to be used in the component.""" + # On each node, replace the 'attributes' key names with the standardized names. + if "attributes" in vdom_tree: + vdom_tree["attributes"] = {_normalize_prop_name(k): v for k, v in vdom_tree["attributes"].items()} + + return vdom_tree + + +def convert_textarea_children_to_prop(vdom_tree: VdomDict) -> VdomDict: + """Transformation that converts the text content of a