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