Skip to content

Django form component #267

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

Merged
merged 50 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Add tests
  • Loading branch information
Archmonger committed Dec 9, 2024
commit d9416d94b86a7349712b422609a5fbbf773763cd
2 changes: 1 addition & 1 deletion tests/test_app/forms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
urlpatterns = [
path("form/", views.form),
path("form/bootstrap/", views.bootstrap_form),
path("form/database/", views.databased_backed_form),
path("form/model/", views.model_form),
]
4 changes: 2 additions & 2 deletions tests/test_app/forms/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ def bootstrap_form(request):
return render(request, "bootstrap_form.html", {})


def databased_backed_form(request):
return render(request, "database_backed_form.html", {})
def model_form(request):
return render(request, "model_form.html", {})
6 changes: 6 additions & 0 deletions tests/test_app/templates/admin/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{% extends "admin/login.html" %}

{% block content %}
{{ block.super }}
<script>document.getElementById("id_username").value = "admin"; document.getElementById("id_password").value = "password";</script>
{% endblock %}
2 changes: 1 addition & 1 deletion tests/test_app/templates/bootstrap_form.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% load static %} {% load reactpy %}
{% load static %} {% load reactpy %} {% load django_bootstrap5 %}
<!DOCTYPE html>
<html lang="en">

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
</head>

<body>
<h1>ReactPy Database Form Test Page</h1>
<h1>ReactPy Model Form Test Page</h1>
<hr>
{% component "test_app.forms.components.database_backed_form" %}
<hr>
Expand Down
224 changes: 222 additions & 2 deletions tests/test_app/tests/test_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
from time import sleep

import pytest
from playwright.sync_api import TimeoutError
from playwright.sync_api import TimeoutError, expect

from reactpy_django.models import ComponentSession
from reactpy_django.utils import strtobool

from .utils import GITHUB_ACTIONS, PlaywrightTestCase
from .utils import GITHUB_ACTIONS, PlaywrightTestCase, navigate_to_page

CLICK_DELAY = 250 if strtobool(GITHUB_ACTIONS) else 25 # Delay in miliseconds.

Expand Down Expand Up @@ -579,3 +579,223 @@ def test_offline_components(self):
self._server_process.join()
self.page.wait_for_selector("div:not([hidden]) > #offline")
assert self.page.query_selector("div[hidden] > #online") is not None


class FormTests(PlaywrightTestCase):
def test_basic_form(self):
navigate_to_page(self, "/form/")

try:
from test_app.models import TodoItem

os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"

if TodoItem.objects.count() == 0:
TodoItem(done=False, text="First").save()
TodoItem(done=True, text="Second").save()
TodoItem(done=False, text="Third").save()
finally:
os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE")

self.page.wait_for_selector("form")
self.page.wait_for_selector("#id_boolean_field")
self.page.wait_for_selector("#id_char_field")
self.page.wait_for_selector("#id_choice_field")
self.page.wait_for_selector("#id_date_field")
self.page.wait_for_selector("#id_date_time_field")
self.page.wait_for_selector("#id_decimal_field")
self.page.wait_for_selector("#id_duration_field")
self.page.wait_for_selector("#id_email_field")
self.page.wait_for_selector("#id_file_path_field")
self.page.wait_for_selector("#id_float_field")
self.page.wait_for_selector("#id_generic_ip_address_field")
self.page.wait_for_selector("#id_integer_field")
self.page.wait_for_selector("#id_float_field")
self.page.wait_for_selector("#id_json_field")
self.page.wait_for_selector("#id_multiple_choice_field")
self.page.wait_for_selector("#id_null_boolean_field")
self.page.wait_for_selector("#id_regex_field")
self.page.wait_for_selector("#id_slug_field")
self.page.wait_for_selector("#id_time_field")
self.page.wait_for_selector("#id_typed_choice_field")
self.page.wait_for_selector("#id_typed_multiple_choice_field")
self.page.wait_for_selector("#id_url_field")
self.page.wait_for_selector("#id_uuid_field")
self.page.wait_for_selector("#id_combo_field")
self.page.wait_for_selector("#id_password_field")
self.page.wait_for_selector("#id_model_choice_field")
self.page.wait_for_selector("#id_model_multiple_choice_field")

sleep(1)
self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY)
self.page.wait_for_selector(".errorlist")

# Submitting an empty form should result in 22 error elements.
# The number of errors may change if/when new test form elements are created.
assert len(self.page.query_selector_all(".errorlist")) == 22

# Fill out the form
self.page.wait_for_selector("#id_boolean_field").click(delay=CLICK_DELAY)
expect(self.page.locator("#id_boolean_field")).to_be_checked()

self.page.locator("#id_char_field").type("test", delay=CLICK_DELAY)
self.page.locator("#id_choice_field").select_option("2")
self.page.locator("#id_date_field").type("2021-01-01", delay=CLICK_DELAY)
self.page.locator("#id_date_time_field").type("2021-01-01 01:01:00", delay=CLICK_DELAY)
self.page.locator("#id_decimal_field").type("0.123", delay=CLICK_DELAY)
self.page.locator("#id_duration_field").type("1", delay=CLICK_DELAY)
self.page.locator("#id_email_field").type("test@example.com", delay=CLICK_DELAY)
self.page.locator("#id_file_path_field").select_option("manage.py")
self.page.locator("#id_float_field").type("1.2345", delay=CLICK_DELAY)
self.page.locator("#id_generic_ip_address_field").type("127.0.0.1", delay=CLICK_DELAY)
self.page.locator("#id_integer_field").type("123", delay=CLICK_DELAY)
self.page.locator("#id_json_field").clear()
self.page.locator("#id_json_field").type('{"key": "value"}', delay=CLICK_DELAY)
self.page.locator("#id_multiple_choice_field").select_option(["2", "3"])
self.page.locator("#id_null_boolean_field").select_option("false")
self.page.locator("#id_regex_field").type("12", delay=CLICK_DELAY)
self.page.locator("#id_slug_field").type("my-slug-text", delay=CLICK_DELAY)
self.page.locator("#id_time_field").type("01:01:00", delay=CLICK_DELAY)
self.page.locator("#id_typed_choice_field").select_option("2")
self.page.locator("#id_typed_multiple_choice_field").select_option(["1", "2"])
self.page.locator("#id_url_field").type("http://example.com", delay=CLICK_DELAY)
self.page.locator("#id_uuid_field").type("550e8400-e29b-41d4-a716-446655440000", delay=CLICK_DELAY)
self.page.locator("#id_combo_field").type("test@example.com", delay=CLICK_DELAY)
self.page.locator("#id_password_field").type("password", delay=CLICK_DELAY)

model_choice_field_options = self.page.query_selector_all("#id_model_multiple_choice_field option")
model_choice_field_values: list[str] = [option.get_attribute("value") for option in model_choice_field_options]
self.page.locator("#id_model_choice_field").select_option(model_choice_field_values[0])
self.page.locator("#id_model_multiple_choice_field").select_option([
model_choice_field_values[1],
model_choice_field_values[2],
])

self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY)

# Wait for one of the error messages to disappear (indicating that the form has been re-rendered)
expect(self.page.locator(".errorlist").all()[0]).not_to_be_attached()
# Make sure no errors remain
assert len(self.page.query_selector_all(".errorlist")) == 0

def test_bootstrap_form(self):
navigate_to_page(self, "/form/bootstrap/")

try:
from test_app.models import TodoItem

os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"

if TodoItem.objects.count() == 0:
TodoItem(done=False, text="First").save()
TodoItem(done=True, text="Second").save()
TodoItem(done=False, text="Third").save()
finally:
os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE")

self.page.wait_for_selector("form")
self.page.wait_for_selector("#id_boolean_field")
self.page.wait_for_selector("#id_char_field")
self.page.wait_for_selector("#id_choice_field")
self.page.wait_for_selector("#id_date_field")
self.page.wait_for_selector("#id_date_time_field")
self.page.wait_for_selector("#id_decimal_field")
self.page.wait_for_selector("#id_duration_field")
self.page.wait_for_selector("#id_email_field")
self.page.wait_for_selector("#id_file_path_field")
self.page.wait_for_selector("#id_float_field")
self.page.wait_for_selector("#id_generic_ip_address_field")
self.page.wait_for_selector("#id_integer_field")
self.page.wait_for_selector("#id_float_field")
self.page.wait_for_selector("#id_json_field")
self.page.wait_for_selector("#id_multiple_choice_field")
self.page.wait_for_selector("#id_null_boolean_field")
self.page.wait_for_selector("#id_regex_field")
self.page.wait_for_selector("#id_slug_field")
self.page.wait_for_selector("#id_time_field")
self.page.wait_for_selector("#id_typed_choice_field")
self.page.wait_for_selector("#id_typed_multiple_choice_field")
self.page.wait_for_selector("#id_url_field")
self.page.wait_for_selector("#id_uuid_field")
self.page.wait_for_selector("#id_combo_field")
self.page.wait_for_selector("#id_password_field")
self.page.wait_for_selector("#id_model_choice_field")
self.page.wait_for_selector("#id_model_multiple_choice_field")

sleep(1)
self.page.wait_for_selector("button[type=submit]").click(delay=CLICK_DELAY)
self.page.wait_for_selector(".invalid-feedback")

# Submitting an empty form should result in 22 error elements.
# The number of errors may change if/when new test form elements are created.
assert len(self.page.query_selector_all(".invalid-feedback")) == 22

# Fill out the form
self.page.wait_for_selector("#id_boolean_field").click(delay=CLICK_DELAY)
expect(self.page.locator("#id_boolean_field")).to_be_checked()

self.page.locator("#id_char_field").type("test", delay=CLICK_DELAY)
self.page.locator("#id_choice_field").select_option("2")
self.page.locator("#id_date_field").type("2021-01-01", delay=CLICK_DELAY)
self.page.locator("#id_date_time_field").type("2021-01-01 01:01:00", delay=CLICK_DELAY)
self.page.locator("#id_decimal_field").type("0.123", delay=CLICK_DELAY)
self.page.locator("#id_duration_field").type("1", delay=CLICK_DELAY)
self.page.locator("#id_email_field").type("test@example.com", delay=CLICK_DELAY)
self.page.locator("#id_file_path_field").select_option("manage.py")
self.page.locator("#id_float_field").type("1.2345", delay=CLICK_DELAY)
self.page.locator("#id_generic_ip_address_field").type("127.0.0.1", delay=CLICK_DELAY)
self.page.locator("#id_integer_field").type("123", delay=CLICK_DELAY)
self.page.locator("#id_json_field").clear()
self.page.locator("#id_json_field").type('{"key": "value"}', delay=CLICK_DELAY)
self.page.locator("#id_multiple_choice_field").select_option(["2", "3"])
self.page.locator("#id_null_boolean_field").select_option("false")
self.page.locator("#id_regex_field").type("12", delay=CLICK_DELAY)
self.page.locator("#id_slug_field").type("my-slug-text", delay=CLICK_DELAY)
self.page.locator("#id_time_field").type("01:01:00", delay=CLICK_DELAY)
self.page.locator("#id_typed_choice_field").select_option("2")
self.page.locator("#id_typed_multiple_choice_field").select_option(["1", "2"])
self.page.locator("#id_url_field").type("http://example.com", delay=CLICK_DELAY)
self.page.locator("#id_uuid_field").type("550e8400-e29b-41d4-a716-446655440000", delay=CLICK_DELAY)
self.page.locator("#id_combo_field").type("test@example.com", delay=CLICK_DELAY)
self.page.locator("#id_password_field").type("password", delay=CLICK_DELAY)

model_choice_field_options = self.page.query_selector_all("#id_model_multiple_choice_field option")
model_choice_field_values: list[str] = [option.get_attribute("value") for option in model_choice_field_options]
self.page.locator("#id_model_choice_field").select_option(model_choice_field_values[0])
self.page.locator("#id_model_multiple_choice_field").select_option([
model_choice_field_values[1],
model_choice_field_values[2],
])

self.page.wait_for_selector("button[type=submit]").click(delay=CLICK_DELAY)

# Wait for one of the error messages to disappear (indicating that the form has been re-rendered)
expect(self.page.locator(".invalid-feedback").all()[0]).not_to_be_attached()
# Make sure no errors remain
assert len(self.page.query_selector_all(".invalid-feedback")) == 0

def test_model_form(self):
navigate_to_page(self, "/form/model/")

self.page.wait_for_selector("form")

sleep(1)
self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY)
self.page.wait_for_selector(".errorlist")

# Submitting an empty form should result in 1 error element.
assert len(self.page.query_selector_all(".errorlist")) == 1

# Fill out the form
self.page.locator("#id_text").type("test", delay=CLICK_DELAY)

self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY)

# Wait for the error message to disappear (indicating that the form has been re-rendered)
expect(self.page.locator(".errorlist").all()[0]).not_to_be_attached()

# Make sure no errors remain
assert len(self.page.query_selector_all(".errorlist")) == 0

# Make sure text field is empty
assert self.page.locator("#id_text").get_attribute("value") == ""
6 changes: 6 additions & 0 deletions tests/test_app/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,9 @@ def _post_teardown(self):
"""Handled manually in `tearDownClass` to prevent TransactionTestCase from doing
database flushing. This is needed to prevent a `SynchronousOnlyOperation` from
occurring due to a bug within `ChannelsLiveServerTestCase`."""


def navigate_to_page(self: PlaywrightTestCase, path: str):
"""Redirect the page's URL to the given link, if the page is not already there."""
if self.page.url != path:
self.page.goto(f"http://{self.host}:{self._port}/{path.lstrip('/')}")