From 168a72795219d24113f5d57a5c142fab0a7896ba Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 3 Dec 2024 04:08:28 -0800
Subject: [PATCH 01/50] Client side form handler
---
src/js/src/index.tsx | 46 ++++++++++++++++++++++++++++++++++++++++++++
src/js/src/types.ts | 5 +++++
2 files changed, 51 insertions(+)
diff --git a/src/js/src/index.tsx b/src/js/src/index.tsx
index 51a387f3..bde9fac8 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,34 @@ 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);
+ console.log(Object.fromEntries(formData));
+ onSubmitCallback(Object.fromEntries(formData));
+ };
+
+ // 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;
+}
From 98ba450e9dcb78a4d354b8eed4152752b630841b Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 3 Dec 2024 04:08:49 -0800
Subject: [PATCH 02/50] misc changelog bump
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8c848e4..e9228ff0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,7 +25,7 @@ Don't forget to remove deprecated code on each major release!
### Fixed
-- Fixed regression in v5.1.0 where components would sometimes not output debug messages when `settings.py:DEBUG` is enabled.
+- Fixed bregression from the previous release where components would sometimes not output debug messages when `settings.py:DEBUG` is enabled.
### Changed
From 14c9ddebd021a80f8482d42a215063b83c527e91 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 4 Dec 2024 22:32:51 -0800
Subject: [PATCH 03/50] Functional client code
---
src/js/src/index.tsx | 21 +++++++++++++++++++--
1 file changed, 19 insertions(+), 2 deletions(-)
diff --git a/src/js/src/index.tsx b/src/js/src/index.tsx
index bde9fac8..742ca79f 100644
--- a/src/js/src/index.tsx
+++ b/src/js/src/index.tsx
@@ -106,8 +106,25 @@ export function DjangoForm({
const onSubmitEvent = (event) => {
event.preventDefault();
const formData = new FormData(form);
- console.log(Object.fromEntries(formData));
- onSubmitCallback(Object.fromEntries(formData));
+
+ // 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
From ebd87bf954e9d92bd509cd307aa8b4a48879b66b Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 5 Dec 2024 14:10:45 -0800
Subject: [PATCH 04/50] First draft of form conversion
---
src/reactpy_django/components.py | 136 +++++++-
src/reactpy_django/transforms.py | 515 +++++++++++++++++++++++++++++++
tests/test_app/components.py | 10 +-
tests/test_app/forms.py | 38 +++
4 files changed, 696 insertions(+), 3 deletions(-)
create mode 100644 src/reactpy_django/transforms.py
create mode 100644 tests/test_app/forms.py
diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py
index d9ed0e6a..1c0f0592 100644
--- a/src/reactpy_django/components.py
+++ b/src/reactpy_django/components.py
@@ -2,19 +2,28 @@
import json
import os
-from typing import TYPE_CHECKING, Any, Callable, Union, cast
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Callable, Type, 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.forms import BooleanField, ChoiceField, Form, MultipleChoiceField
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.web import export, module_from_file
from reactpy_django.exceptions import ViewNotRegisteredError
from reactpy_django.html import pyscript
+from reactpy_django.transforms import (
+ convert_option_props,
+ convert_textarea_children_to_prop,
+ ensure_controlled_inputs,
+ standardize_prop_names,
+)
from reactpy_django.utils import (
generate_obj_name,
import_module,
@@ -28,6 +37,11 @@
from django.views import View
+DjangoForm = export(
+ module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "reactpy_django" / "client.js"),
+ ("DjangoForm"),
+)
+
def view_to_component(
view: Callable | View | str,
@@ -114,6 +128,25 @@ 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],
+ *,
+ top_children: Sequence = (),
+ bottom_children: Sequence = (),
+ auto_submit: bool = False,
+ auto_submit_wait: int = 3,
+ key: Key | None = None,
+):
+ return _django_form(
+ form=form,
+ top_children=top_children,
+ bottom_children=bottom_children,
+ auto_submit=auto_submit,
+ auto_submit_wait=auto_submit_wait,
+ key=key,
+ )
+
+
def pyscript_component(
*file_paths: str,
initial: str | VdomDict | ComponentType = "",
@@ -230,6 +263,102 @@ def _django_js(static_path: str):
return html.script(_cached_static_contents(static_path))
+@component
+def _django_form(
+ form: Type[Form], top_children: Sequence, bottom_children: Sequence, auto_submit: bool, auto_submit_wait: int
+):
+ # TODO: Implement form restoration on page reload. Probably want to create a new setting called
+ # form_restoration_method that can be set to "URL", "CLIENT_STORAGE", "SERVER_SESSION", or None.
+ # Or maybe just recommend pre-rendering to have the browser handle it.
+ # Be clear that URL mode will limit you to one form per page.
+ # TODO: Test this with django-bootstrap forms and see how errors behave
+ # TODO: Test this with django-colorfield and django-ace
+ # TODO: Add pre-submit and post-submit hooks
+ # TODO: Add auto-save option for database-backed forms
+ 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)
+
+ uuid = uuid_ref.current
+
+ # Don't allow the count of top and bottom children to change
+ if len(top_children) != top_children_count.current or len(bottom_children) != bottom_children_count.current:
+ raise ValueError("Dynamically changing the number of top or bottom children is not allowed.")
+
+ # Try to initialize the form with the provided data
+ try:
+ initialized_form = form(data=submitted_data)
+ except Exception as e:
+ if not isinstance(form, type(Form)):
+ raise ValueError(
+ "The provided form must be an uninitialized Django Form. "
+ "Do NOT initialize your form by calling it (ex. `MyForm()`)."
+ ) from e
+ raise e
+
+ # Run the form validation, if data was provided
+ if submitted_data:
+ initialized_form.full_clean()
+
+ def on_submit_callback(new_data: dict[str, Any]):
+ choice_field_map = {
+ field_name: {choice_value: choice_key for choice_key, choice_value in field.choices}
+ for field_name, field in initialized_form.fields.items()
+ if isinstance(field, ChoiceField)
+ }
+ multi_choice_fields = {
+ field_name
+ for field_name, field in initialized_form.fields.items()
+ if isinstance(field, MultipleChoiceField)
+ }
+ boolean_fields = {
+ field_name for field_name, field in initialized_form.fields.items() if isinstance(field, BooleanField)
+ }
+
+ # Choice fields submit their values as text, but Django choice keys are not always equal to their values.
+ # Due to this, we need to convert the text into keys that Django would be happy with
+ for choice_field_name, choice_map in choice_field_map.items():
+ if choice_field_name in new_data:
+ submitted_value = new_data[choice_field_name]
+ if isinstance(submitted_value, list):
+ new_data[choice_field_name] = [
+ choice_map.get(submitted_value_item, submitted_value_item)
+ for submitted_value_item in submitted_value
+ ]
+ elif choice_field_name in multi_choice_fields:
+ new_data[choice_field_name] = [choice_map.get(submitted_value, submitted_value)]
+ else:
+ new_data[choice_field_name] = choice_map.get(submitted_value, submitted_value)
+
+ # Convert boolean field text into actual booleans
+ for boolean_field_name in boolean_fields:
+ new_data[boolean_field_name] = boolean_field_name in new_data
+
+ # TODO: ReactPy's use_state hook really should be de-duplicating this by itself. Needs upstream fix.
+ if submitted_data != new_data:
+ set_submitted_data(new_data)
+
+ async def on_change(event): ...
+
+ rendered_form = utils.html_to_vdom(
+ initialized_form.render(),
+ standardize_prop_names,
+ convert_textarea_children_to_prop,
+ convert_option_props,
+ ensure_controlled_inputs(on_change),
+ strict=False,
+ )
+
+ return html.form(
+ {"id": f"reactpy-{uuid}"},
+ DjangoForm({"onSubmitCallback": on_submit_callback, "formId": f"reactpy-{uuid}"}),
+ *top_children,
+ html.div({"key": uuid4().hex}, rendered_form),
+ *bottom_children,
+ )
+
+
def _cached_static_contents(static_path: str) -> str:
from reactpy_django.config import REACTPY_CACHE
@@ -238,6 +367,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 +390,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/transforms.py b/src/reactpy_django/transforms.py
new file mode 100644
index 00000000..f53498a8
--- /dev/null
+++ b/src/reactpy_django/transforms.py
@@ -0,0 +1,515 @@
+from __future__ import annotations
+
+from typing import Callable
+
+from reactpy.core.events import EventHandler, to_event_handler_function
+from reactpy.core.types import VdomDict
+
+# TODO: Move all this logic to `reactpy.utils._mutate_vdom()` and remove this file.
+
+UNSUPPORTED_PROPS = {"children", "ref", "aria-*", "data-*"}
+
+
+def standardize_prop_names(vdom_tree: VdomDict) -> VdomDict:
+ """Transformation that standardizes the prop names to be used in the component."""
+
+ if not isinstance(vdom_tree, dict):
+ return vdom_tree
+
+ # 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()}
+
+ for child in vdom_tree.get("children", []):
+ standardize_prop_names(child)
+
+ return vdom_tree
+
+
+def convert_textarea_children_to_prop(vdom_tree: VdomDict) -> VdomDict:
+ """Transformation that converts the text content of a