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

New feature: inline forms #147

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Changelog

<!--next-version-placeholder-->
### Feature
* Drop support for GET method. All action are now invoked with POST method.
* Add option to include inline forms with actions.

### Breaking
* When dealing with a secondary form in action, you cannot simply check the http method to determine if the form should be rendered or processed. You need to check for specific form inputs in POST payload.

## v4.1.0 (2022-11-14)
### Feature
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,25 @@ increment_vote.attrs = {
}
```

## Adding inline forms

You can add parameters to the action button by adding Django [Form](https://docs.djangoproject.com/en/4.1/ref/forms/api/#django.forms.Form) object to it. Parameter values can be read form request's `POST` property.

```python
from django import forms

class ResetAllForm(forms.Form):
new_value = forms.IntegerField(initial=0)

def reset_all(self, request, queryset):
new_value = int(request.POST["new_value"])
queryset.update(value=new_value)
reset_all.form = ResetAllForm()
```

Each action with form assigned is rendered in it's own, separate row.


### Programmatically Disabling Actions

You can programmatically disable registered actions by defining your own
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
ul.object-tools.django-object-actions {
margin-top: 0;
padding-top: 16px;
position: relative;
top: -24px;
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,56 @@
{% extends "admin/change_form.html" %}
{% load add_preserved_filters from admin_urls %}
{% load static %}

{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'django_object_actions/css/style.css' %}">
{% endblock %}

{% block object-tools-items %}
{% for tool in objectactions %}
<li class="objectaction-item" data-tool-name="{{ tool.name }}">
{% url tools_view_name pk=object_id tool=tool.name as action_url %}
<a href="{% add_preserved_filters action_url %}" title="{{ tool.standard_attrs.title }}"
{% for k, v in tool.custom_attrs.items %}
{{ k }}="{{ v }}"
{% endfor %}
class="{{ tool.standard_attrs.class }}">
{{ tool.label|capfirst }}
</a>
</li>
{% if not tool.has_inline_form %}
<li class="objectaction-item" data-tool-name="{{ tool.name }}">
{% url tools_view_name pk=object_id tool=tool.name as action_url %}
<form name="{{ tool.name }}__form" method="post" action="{% add_preserved_filters action_url %}">
{% csrf_token %}
<a href="javascript:window.document.forms['{{ tool.name }}__form'].submit()" title="{{ tool.standard_attrs.title }}"
{% for k, v in tool.custom_attrs.items %}
{{ k }}="{{ v }}"
{% endfor %}
class="{{ tool.standard_attrs.class }}">
{{ tool.label|capfirst }}
</a>
</form>
</li>
{% endif %}
{% endfor %}
{{ block.super }}
{% endblock %}

{% block object-tools %}
{{ block.super }}
{% for tool in objectactions %}
{% if tool.has_inline_form %}
{% url tools_view_name pk=object_id tool=tool.name as action_url %}
<div class="clear">
<form name="{{ tool.name }}__form" method="post" action="{% add_preserved_filters action_url %}">
{% csrf_token %}
<ul class="object-tools django-object-actions">
{{ tool.form.as_ul }}
<li class="objectaction-item" data-tool-name="{{ tool.name }}">
<a href="javascript:window.document.forms['{{ tool.name }}__form'].submit()" title="{{ tool.standard_attrs.title }}"
{% for k, v in tool.custom_attrs.items %}
{{ k }}="{{ v }}"
{% endfor %}
class="{{ tool.standard_attrs.class }}">
{{ tool.label|capfirst }}
</a>
</li>
</ul>
</form>
</div>
{% endif %}
{% endfor %}
<div class="clear"></div>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -1,18 +1,56 @@
{% extends "admin/change_list.html" %}
{% load add_preserved_filters from admin_urls %}
{% load static %}

{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'django_object_actions/css/style.css' %}">
{% endblock %}

{% block object-tools-items %}
{% for tool in objectactions %}
<li class="objectaction-item" data-tool-name="{{ tool.name }}">
{% url tools_view_name tool=tool.name as action_url %}
<a href="{% add_preserved_filters action_url %}" title="{{ tool.standard_attrs.title }}"
{% for k, v in tool.custom_attrs.items %}
{{ k }}="{{ v }}"
{% endfor %}
class="{{ tool.standard_attrs.class }}">
{{ tool.label|capfirst }}
</a>
</li>
{% if not tool.has_inline_form %}
<li class="objectaction-item" data-tool-name="{{ tool.name }}">
{% url tools_view_name tool=tool.name as action_url %}
<form name="{{ tool.name }}__form" method="post" action="{% add_preserved_filters action_url %}">
{% csrf_token %}
<a href="javascript:window.document.forms['{{ tool.name }}__form'].submit()" title="{{ tool.standard_attrs.title }}"
{% for k, v in tool.custom_attrs.items %}
{{ k }}="{{ v }}"
{% endfor %}
class="{{ tool.standard_attrs.class }}">
{{ tool.label|capfirst }}
</a>
</form>
</li>
{% endif %}
{% endfor %}
{{ block.super }}
{% endblock %}

{% block object-tools %}
{{ block.super }}
{% for tool in objectactions %}
{% if tool.has_inline_form %}
{% url tools_view_name tool=tool.name as action_url %}
<div class="clear">
<form name="{{ tool.name }}__form" method="post" action="{% add_preserved_filters action_url %}">
{% csrf_token %}
<ul class="object-tools django-object-actions">
{{ tool.form.as_ul }}
<li class="objectaction-item" data-tool-name="{{ tool.name }}">
<a href="javascript:window.document.forms['{{ tool.name }}__form'].submit()" title="{{ tool.standard_attrs.title }}"
{% for k, v in tool.custom_attrs.items %}
{{ k }}="{{ v }}"
{% endfor %}
class="{{ tool.standard_attrs.class }}">
{{ tool.label|capfirst }}
</a>
</li>
</ul>
</form>
</div>
{% endif %}
{% endfor %}
<div class="clear"></div>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% extends "admin/base_site.html" %}
{% load add_preserved_filters from admin_urls %}

{% block content %}
<div id="content-main">
<form name="{{ tool.name }}__form" method="post" action="{% add_preserved_filters action_url %}">
{% csrf_token %}
{{ form.as_table }}
<div>
<input type="submit" value="Submit" name="modal_submit">
<input type="submit" value="Cancel" name="modal_cancel">
</div>
</form>
</div>
{% endblock %}
64 changes: 54 additions & 10 deletions django_object_actions/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from .tests import LoggedInTestCase
from example_project.polls.factories import (
ChoiceFactory,
CommentFactory,
PollFactory,
RelatedDataFactory,
Expand All @@ -21,25 +22,25 @@ def test_action_on_a_model_with_uuid_pk_works(self):
action_url = "/admin/polls/comment/{0}/actions/hodor/".format(comment.pk)
# sanity check that url has a uuid
self.assertIn("-", action_url)
response = self.client.get(action_url)
response = self.client.post(action_url)
self.assertRedirects(response, comment_url)

@patch("django_object_actions.utils.ChangeActionView.get")
@patch("django_object_actions.utils.ChangeActionView.post")
def test_action_on_a_model_with_arbitrary_pk_works(self, mock_view):
mock_view.return_value = HttpResponse()
action_url = "/admin/polls/comment/{0}/actions/hodor/".format(" i am a pk ")

self.client.get(action_url)
self.client.post(action_url)

self.assertTrue(mock_view.called)
self.assertEqual(mock_view.call_args[1]["pk"], " i am a pk ")

@patch("django_object_actions.utils.ChangeActionView.get")
@patch("django_object_actions.utils.ChangeActionView.post")
def test_action_on_a_model_with_slash_in_pk_works(self, mock_view):
mock_view.return_value = HttpResponse()
action_url = "/admin/polls/comment/{0}/actions/hodor/".format("pk/slash")

self.client.get(action_url)
self.client.post(action_url)

self.assertTrue(mock_view.called)
self.assertEqual(mock_view.call_args[1]["pk"], "pk/slash")
Expand All @@ -55,7 +56,7 @@ def test_action_on_a_model_with_complex_id(self):
quote(related_data.pk)
)

response = self.client.get(action_url)
response = self.client.post(action_url)
self.assertNotEqual(response.status_code, 404)
self.assertRedirects(response, related_data_url)

Expand All @@ -76,12 +77,12 @@ def test_changelist_template_context(self):

def test_changelist_action_view(self):
url = "/admin/polls/choice/actions/delete_all/"
response = self.client.get(url)
response = self.client.post(url)
self.assertRedirects(response, "/admin/polls/choice/")

def test_changelist_nonexistent_action(self):
url = "/admin/polls/choice/actions/xyzzy/"
response = self.client.get(url)
response = self.client.post(url)
self.assertEqual(response.status_code, 404)

def test_get_changelist_can_remove_action(self):
Expand All @@ -94,13 +95,23 @@ def test_get_changelist_can_remove_action(self):
response = self.client.get(admin_change_url)
self.assertIn(action_url, response.rendered_content)

response = self.client.get(action_url) # Click on the button
response = self.client.post(action_url) # Click on the button
self.assertRedirects(response, admin_change_url)

# button is not in the admin anymore
response = self.client.get(admin_change_url)
self.assertNotIn(action_url, response.rendered_content)

def test_changelist_get_method_action_view(self):
url = "/admin/polls/choice/actions/delete_all/"
response = self.client.get(url)
self.assertEqual(response.status_code, 405)

def test_changelist_get_method_nonexistent_action(self):
url = "/admin/polls/choice/actions/xyzzy/"
response = self.client.get(url)
self.assertEqual(response.status_code, 405)


class ChangeListTests(LoggedInTestCase):
def test_changelist_template_context(self):
Expand All @@ -122,5 +133,38 @@ def test_redirect_back_from_secondary_admin(self):
action_url = "/support/polls/poll/1/actions/question_mark/"
self.assertTrue(admin_change_url.startswith("/support/"))

response = self.client.get(action_url)
response = self.client.post(action_url)
self.assertRedirects(response, admin_change_url)


class FormTests(LoggedInTestCase):
def test_form_is_rendered_in_change_view(self):
choice = ChoiceFactory()
admin_change_url = reverse("admin:polls_choice_change", args=(choice.pk,))

response = self.client.get(admin_change_url)

# form is in the admin
action_url_lookup = 'action="/admin/polls/choice/1/actions/change_votes/"'
self.assertIn(action_url_lookup, response.rendered_content)
form_lookup = '<form name="change_votes__form"'
self.assertIn(form_lookup, response.rendered_content)

# form has input
input_lookup = 'name="change_by"'
self.assertIn(input_lookup, response.rendered_content)

def test_form_is_rendered_in_changelist(self):
admin_change_url = reverse("admin:polls_choice_changelist")

response = self.client.get(admin_change_url)

# form is in the admin
action_url_lookup = 'action="/admin/polls/choice/actions/reset_all/"'
self.assertIn(action_url_lookup, response.rendered_content)
form_lookup = '<form name="reset_all__form"'
self.assertIn(form_lookup, response.rendered_content)

# form has input
input_lookup = 'name="new_value"'
self.assertIn(input_lookup, response.rendered_content)
2 changes: 1 addition & 1 deletion django_object_actions/tests/test_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ def test_changelist_action_is_rendered(self):
response = self.client.get(reverse("admin:polls_choice_changelist"))
self.assertEqual(response.status_code, 200)
self.assertIn(
b'href="/admin/polls/choice/actions/delete_all/"', response.content
b'action="/admin/polls/choice/actions/delete_all/"', response.content
)
Loading