diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c848e4..f819750b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,13 +19,15 @@ Don't forget to remove deprecated code on each major release! ## [Unreleased] -- Nothing (yet)! +### Added + +- Automatically convert Django forms to ReactPy forms via the new `reactpy_django.components.django_form` component! ## [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 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/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/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..5826a7b0 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** diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md index 6b1c78c4..94c9d8b6 100644 --- a/docs/src/reference/settings.md +++ b/docs/src/reference/settings.md @@ -34,7 +34,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,6 +48,18 @@ 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). + +--- + ### `#!python REACTPY_AUTH_BACKEND` **Default:** `#!python "django.contrib.auth.backends.ModelBackend"` @@ -131,7 +143,7 @@ This setting is incompatible with [`daphne`](https://github.com/django/daphne). 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). -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 of the parent component would have been sufficient. --- diff --git a/pyproject.toml b/pyproject.toml index 6366a2f5..c25929bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,7 @@ extra-dependencies = [ "twisted", "tblib", "servestatic", + "django-bootstrap5", ] matrix-name-format = "{variable}-{value}" @@ -140,7 +141,12 @@ 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", +] [tool.hatch.envs.django.scripts] runserver = [ diff --git a/src/js/src/index.tsx b/src/js/src/index.tsx index 51a387f3..742ca79f 100644 --- a/src/js/src/index.tsx +++ b/src/js/src/index.tsx @@ -2,6 +2,21 @@ import { ReactPyDjangoClient } from "./client"; import React from "react"; import ReactDOM from "react-dom"; import { Layout } from "@reactpy/client/src/components"; +import { DjangoFormProps } from "./types"; + +/** + * 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 mountComponent( mountElement: HTMLElement, @@ -79,3 +94,51 @@ export function mountComponent( // Start rendering the component ReactDOM.render(, client.mountElement); } + +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; +} diff --git a/src/js/src/types.ts b/src/js/src/types.ts index eea8a866..79b06375 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -18,3 +18,8 @@ export type ReactPyDjangoClientProps = { prerenderElement: HTMLElement | null; offlineElement: HTMLElement | null; }; + +export interface DjangoFormProps { + onSubmitCallback: (data: Object) => void; + formId: string; +} diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index d9ed0e6a..7e821c1c 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -1,3 +1,5 @@ +"""This file contains Django related components. Most of these components utilize wrappers to fix type hints.""" + from __future__ import annotations import json @@ -14,6 +16,7 @@ from reactpy.types import ComponentType, Key, VdomDict from reactpy_django.exceptions import ViewNotRegisteredError +from reactpy_django.forms.components import _django_form from reactpy_django.html import pyscript from reactpy_django.utils import ( generate_obj_name, @@ -26,8 +29,11 @@ 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 + def view_to_component( view: Callable | View | str, @@ -114,6 +120,64 @@ 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, +): + """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 = "", @@ -238,6 +302,8 @@ def _cached_static_contents(static_path: str) -> str: if not abs_path: msg = f"Could not find static file {static_path} within Django's static files." raise FileNotFoundError(msg) + if isinstance(abs_path, (list, tuple)): + abs_path = abs_path[0] # Fetch the file from cache, if available last_modified_time = os.stat(abs_path).st_mtime @@ -259,7 +325,8 @@ def _pyscript_component( root: str = "root", ): rendered, set_rendered = hooks.use_state(False) - uuid = uuid4().hex.replace("-", "") + uuid_ref = hooks.use_ref(uuid4().hex.replace("-", "")) + uuid = uuid_ref.current initial = vdom_or_component_to_string(initial, uuid=uuid) executor = render_pyscript_template(file_paths, uuid, root) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 3f46c48b..f4434c4f 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -126,3 +126,8 @@ "REACTPY_CLEAN_USER_DATA", True, ) +REACTPY_DEFAULT_FORM_TEMPLATE: str | None = getattr( + settings, + "REACTPY_DEFAULT_FORM_TEMPLATE", + None, +) 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..d19c0bbb --- /dev/null +++ b/src/reactpy_django/forms/components.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Union, cast +from uuid import uuid4 + +from channels.db import database_sync_to_async +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_boolean_fields, convert_multiple_choice_fields +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_ref = hooks.use_ref(uuid4().hex.replace("-", "")) + 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)) + uuid = uuid_ref.current + + # Validate the provided arguments + if len(top_children) != top_children_count.current or len(bottom_children) != bottom_children_count.current: + msg = "Dynamically changing the number of top or bottom children is not allowed." + raise ValueError(msg) + if not isinstance(form, (type(Form), type(ModelForm))): + msg = ( + "The provided form must be an uninitialized Django Form. " + "Do NOT initialize your form by calling it (ex. `MyForm()`)." + ) + raise TypeError(msg) + + # Initialize the form with the provided data + 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 database_sync_to_async(initialized_form.full_clean)() + 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_multiple_choice_fields(new_data, initialized_form) + convert_boolean_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..2d527209 --- /dev/null +++ b/src/reactpy_django/forms/transforms.py @@ -0,0 +1,486 @@ +# 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