Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Django form component #267

Merged
merged 50 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
168a727
Client side form handler
Archmonger Dec 3, 2024
98ba450
misc changelog bump
Archmonger Dec 3, 2024
14c9dde
Functional client code
Archmonger Dec 5, 2024
ebd87bf
First draft of form conversion
Archmonger Dec 5, 2024
164e3a3
format
Archmonger Dec 5, 2024
cf08add
Move code to forms module
Archmonger Dec 5, 2024
f0702d0
Squash some bugs with multi choice and boolean fields
Archmonger Dec 6, 2024
63e23d5
Remove auto submit from the base form
Archmonger Dec 6, 2024
66fa334
Create form test
Archmonger Dec 6, 2024
3863eb2
Add bootstrap form
Archmonger Dec 6, 2024
4565df0
add events for form component
Archmonger Dec 6, 2024
24cc64b
Add on_change event
Archmonger Dec 6, 2024
edca217
simplify ensure_input_elements_are_controlled
Archmonger Dec 6, 2024
8e2913f
Support model choice fields
Archmonger Dec 6, 2024
cc4fd22
Prep work for DB backed form
Archmonger Dec 6, 2024
f0b21c7
Full support for database backed forms
Archmonger Dec 6, 2024
851b113
Fix render loop bug
Archmonger Dec 6, 2024
6bf8c24
quick self review
Archmonger Dec 6, 2024
4077a06
Add changelog
Archmonger Dec 6, 2024
08034fa
Simplify transforms
Archmonger Dec 7, 2024
24ad84c
Add extra transforms arg
Archmonger Dec 7, 2024
9360e51
REACTPY_DEFAULT_FORM_TEMPLATE
Archmonger Dec 7, 2024
7371a45
better input transform
Archmonger Dec 7, 2024
8c9990f
var name cleanup for _find_selected_options
Archmonger Dec 7, 2024
a54f035
cleanup in transform_value_prop_on_input_element
Archmonger Dec 7, 2024
78fdbed
simplify convert_multiple_choice_fields
Archmonger Dec 7, 2024
c8c71c4
Remove unsupported fields comment
Archmonger Dec 7, 2024
282e542
Rename cancel btn to reset
Archmonger Dec 7, 2024
bd58a1b
Move extra props arg
Archmonger Dec 7, 2024
d446161
Set default attributes in transforms
Archmonger Dec 7, 2024
aee08da
Fix edge case where error is thrown on empty choice field
Archmonger Dec 7, 2024
9c28f97
First cut at docs
Archmonger Dec 8, 2024
88ba295
Refactoring related to new docs
Archmonger Dec 8, 2024
a44591f
use local bootstrap for tests
Archmonger Dec 8, 2024
16c04bc
Remove default values from test form
Archmonger Dec 8, 2024
d561c88
self review
Archmonger Dec 8, 2024
d9416d9
Add tests
Archmonger Dec 9, 2024
208ec17
Add readme
Archmonger Dec 9, 2024
602be6b
Try dynamically selecting options for file_path_field
Archmonger Dec 9, 2024
1d3d095
Update todo comments
Archmonger Dec 9, 2024
b6fb5c4
Increase sleep on async relational query test
Archmonger Dec 9, 2024
608acee
Add another check in query tests
Archmonger Dec 9, 2024
3f834ca
Fix default on admin middleware
Archmonger Dec 9, 2024
73967b4
New ensure_async util function
Archmonger Dec 9, 2024
e32fc67
fix bad import
Archmonger Dec 9, 2024
3ed84db
Add thread_sensitive arg to ensure_async func
Archmonger Dec 10, 2024
55daaa2
Add new tests for form events
Archmonger Dec 10, 2024
5eb5818
run formatter
Archmonger Dec 10, 2024
998041a
another simplification of middleware
Archmonger Dec 10, 2024
78d5f38
Fix bug where input elements were dismounted prematurely
Archmonger Dec 11, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
11 changes: 11 additions & 0 deletions docs/examples/html/django_form_bootstrap.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{% load django_bootstrap5 %}

<!-- Note: CSS/JS is loaded here only for demonstration purposes.
You should load this CSS/JS in your HTML <head> instead. -->
{% bootstrap_css %}
{% bootstrap_javascript %}

<!-- The actual form that is rendered by ReactPy -->
{% bootstrap_form form %}
{% bootstrap_button button_type="submit" content="OK" %}
{% bootstrap_button button_type="reset" content="Reset" %}
10 changes: 10 additions & 0 deletions docs/examples/python/django_form.py
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 9 additions & 0 deletions docs/examples/python/django_form_bootstrap.py
Original file line number Diff line number Diff line change
@@ -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")
5 changes: 5 additions & 0 deletions docs/examples/python/django_form_class.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django import forms


class MyForm(forms.Form):
username = forms.CharField(label="Username")
21 changes: 21 additions & 0 deletions docs/examples/python/django_form_on_success.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions docs/examples/python/example/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from django import forms


class MyForm(forms.Form): ...
1 change: 1 addition & 0 deletions docs/src/dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ linter
linters
linting
formatters
bootstrap_form
106 changes: 102 additions & 4 deletions docs/src/reference/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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?"

Expand Down Expand Up @@ -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"

<font size="4">**Parameters**</font>

| 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 <form>` 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` |

<font size="4">**Returns**</font>

| 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/).
Expand Down
4 changes: 2 additions & 2 deletions docs/src/reference/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down Expand Up @@ -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` |

<font size="4">**Returns**</font>
Expand Down
16 changes: 14 additions & 2 deletions docs/src/reference/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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"`
Expand Down Expand Up @@ -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.

---

Expand Down
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ extra-dependencies = [
"twisted",
"tblib",
"servestatic",
"django-bootstrap5",
]
matrix-name-format = "{variable}-{value}"

Expand Down Expand Up @@ -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 = [
Expand Down
63 changes: 63 additions & 0 deletions src/js/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -79,3 +94,51 @@ export function mountComponent(
// Start rendering the component
ReactDOM.render(<Layout client={client} />, 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;
}
5 changes: 5 additions & 0 deletions src/js/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@ export type ReactPyDjangoClientProps = {
prerenderElement: HTMLElement | null;
offlineElement: HTMLElement | null;
};

export interface DjangoFormProps {
onSubmitCallback: (data: Object) => void;
formId: string;
}
Loading
Loading