Skip to content

Commit a7ad92a

Browse files
author
Wiktor Latanowicz
committed
Support for inline forms for action buttons
1 parent a616d37 commit a7ad92a

File tree

9 files changed

+212
-28
lines changed

9 files changed

+212
-28
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
<!--next-version-placeholder-->
44
### Feature
55
* Drop support for GET method. All action are now invoked with POST method.
6+
* Add option to include inline forms with actions.
67

78
### Breaking
89
* 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.

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,25 @@ increment_vote.attrs = {
157157
}
158158
```
159159

160+
## Adding inline forms
161+
162+
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.
163+
164+
```python
165+
from django import forms
166+
167+
class ResetAllForm(forms.Form):
168+
new_value = forms.IntegerField(initial=0)
169+
170+
def reset_all(self, request, queryset):
171+
new_value = int(request.POST["new_value"])
172+
queryset.update(value=new_value)
173+
reset_all.form = ResetAllForm()
174+
```
175+
176+
Each action with form assigned is rendered in it's own, separate row.
177+
178+
160179
### Programmatically Disabling Actions
161180

162181
You can programmatically disable registered actions by defining your own
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
ul.object-tools.django-object-actions {
2+
margin-top: 0;
3+
padding-top: 16px;
4+
position: relative;
5+
top: -24px;
6+
}
Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,56 @@
11
{% extends "admin/change_form.html" %}
22
{% load add_preserved_filters from admin_urls %}
3+
{% load static %}
4+
5+
{% block extrastyle %}
6+
{{ block.super }}
7+
<link rel="stylesheet" href="{% static 'django_object_actions/css/style.css' %}">
8+
{% endblock %}
39

410
{% block object-tools-items %}
511
{% for tool in objectactions %}
6-
<li class="objectaction-item" data-tool-name="{{ tool.name }}">
7-
{% url tools_view_name pk=object_id tool=tool.name as action_url %}
8-
<form name="{{ tool.name }}__form" method="post" action="{% add_preserved_filters action_url %}">
9-
{% csrf_token %}
10-
<a href="javascript:window.document.forms['{{ tool.name }}__form'].submit()" title="{{ tool.standard_attrs.title }}"
11-
{% for k, v in tool.custom_attrs.items %}
12-
{{ k }}="{{ v }}"
13-
{% endfor %}
14-
class="{{ tool.standard_attrs.class }}">
15-
{{ tool.label|capfirst }}
16-
</a>
17-
</form>
18-
</li>
12+
{% if not tool.form %}
13+
<li class="objectaction-item" data-tool-name="{{ tool.name }}">
14+
{% url tools_view_name pk=object_id tool=tool.name as action_url %}
15+
<form name="{{ tool.name }}__form" method="post" action="{% add_preserved_filters action_url %}">
16+
{% csrf_token %}
17+
<a href="javascript:window.document.forms['{{ tool.name }}__form'].submit()" title="{{ tool.standard_attrs.title }}"
18+
{% for k, v in tool.custom_attrs.items %}
19+
{{ k }}="{{ v }}"
20+
{% endfor %}
21+
class="{{ tool.standard_attrs.class }}">
22+
{{ tool.label|capfirst }}
23+
</a>
24+
</form>
25+
</li>
26+
{% endif %}
1927
{% endfor %}
2028
{{ block.super }}
2129
{% endblock %}
30+
31+
{% block object-tools %}
32+
{{ block.super }}
33+
{% for tool in objectactions %}
34+
{% if tool.form %}
35+
{% url tools_view_name pk=object_id tool=tool.name as action_url %}
36+
<div class="clear">
37+
<form name="{{ tool.name }}__form" method="post" action="{% add_preserved_filters action_url %}">
38+
{% csrf_token %}
39+
<ul class="object-tools django-object-actions">
40+
{{ tool.form.as_ul }}
41+
<li class="objectaction-item" data-tool-name="{{ tool.name }}">
42+
<a href="javascript:window.document.forms['{{ tool.name }}__form'].submit()" title="{{ tool.standard_attrs.title }}"
43+
{% for k, v in tool.custom_attrs.items %}
44+
{{ k }}="{{ v }}"
45+
{% endfor %}
46+
class="{{ tool.standard_attrs.class }}">
47+
{{ tool.label|capfirst }}
48+
</a>
49+
</li>
50+
</ul>
51+
</form>
52+
</div>
53+
{% endif %}
54+
{% endfor %}
55+
<div class="clear"></div>
56+
{% endblock %}
Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,56 @@
11
{% extends "admin/change_list.html" %}
22
{% load add_preserved_filters from admin_urls %}
3+
{% load static %}
4+
5+
{% block extrastyle %}
6+
{{ block.super }}
7+
<link rel="stylesheet" href="{% static 'django_object_actions/css/style.css' %}">
8+
{% endblock %}
39

410
{% block object-tools-items %}
511
{% for tool in objectactions %}
6-
<li class="objectaction-item" data-tool-name="{{ tool.name }}">
7-
{% url tools_view_name tool=tool.name as action_url %}
8-
<form name="{{ tool.name }}__form" method="post" action="{% add_preserved_filters action_url %}">
9-
{% csrf_token %}
10-
<a href="javascript:window.document.forms['{{ tool.name }}__form'].submit()" title="{{ tool.standard_attrs.title }}"
11-
{% for k, v in tool.custom_attrs.items %}
12-
{{ k }}="{{ v }}"
13-
{% endfor %}
14-
class="{{ tool.standard_attrs.class }}">
15-
{{ tool.label|capfirst }}
16-
</a>
17-
</form>
18-
</li>
12+
{% if not tool.form %}
13+
<li class="objectaction-item" data-tool-name="{{ tool.name }}">
14+
{% url tools_view_name tool=tool.name as action_url %}
15+
<form name="{{ tool.name }}__form" method="post" action="{% add_preserved_filters action_url %}">
16+
{% csrf_token %}
17+
<a href="javascript:window.document.forms['{{ tool.name }}__form'].submit()" title="{{ tool.standard_attrs.title }}"
18+
{% for k, v in tool.custom_attrs.items %}
19+
{{ k }}="{{ v }}"
20+
{% endfor %}
21+
class="{{ tool.standard_attrs.class }}">
22+
{{ tool.label|capfirst }}
23+
</a>
24+
</form>
25+
</li>
26+
{% endif %}
1927
{% endfor %}
2028
{{ block.super }}
2129
{% endblock %}
30+
31+
{% block object-tools %}
32+
{{ block.super }}
33+
{% for tool in objectactions %}
34+
{% if tool.form %}
35+
{% url tools_view_name tool=tool.name as action_url %}
36+
<div class="clear">
37+
<form name="{{ tool.name }}__form" method="post" action="{% add_preserved_filters action_url %}">
38+
{% csrf_token %}
39+
<ul class="object-tools django-object-actions">
40+
{{ tool.form.as_ul }}
41+
<li class="objectaction-item" data-tool-name="{{ tool.name }}">
42+
<a href="javascript:window.document.forms['{{ tool.name }}__form'].submit()" title="{{ tool.standard_attrs.title }}"
43+
{% for k, v in tool.custom_attrs.items %}
44+
{{ k }}="{{ v }}"
45+
{% endfor %}
46+
class="{{ tool.standard_attrs.class }}">
47+
{{ tool.label|capfirst }}
48+
</a>
49+
</li>
50+
</ul>
51+
</form>
52+
</div>
53+
{% endif %}
54+
{% endfor %}
55+
<div class="clear"></div>
56+
{% endblock %}

django_object_actions/tests/test_admin.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from .tests import LoggedInTestCase
1010
from example_project.polls.factories import (
11+
ChoiceFactory,
1112
CommentFactory,
1213
PollFactory,
1314
RelatedDataFactory,
@@ -134,3 +135,36 @@ def test_redirect_back_from_secondary_admin(self):
134135

135136
response = self.client.post(action_url)
136137
self.assertRedirects(response, admin_change_url)
138+
139+
140+
class FormTests(LoggedInTestCase):
141+
def test_form_is_rendered_in_change_view(self):
142+
choice = ChoiceFactory()
143+
admin_change_url = reverse("admin:polls_choice_change", args=(choice.pk,))
144+
145+
response = self.client.get(admin_change_url)
146+
147+
# form is in the admin
148+
action_url_lookup = 'action="/admin/polls/choice/1/actions/change_votes/"'
149+
self.assertIn(action_url_lookup, response.rendered_content)
150+
form_lookup = '<form name="change_votes__form"'
151+
self.assertIn(form_lookup, response.rendered_content)
152+
153+
# form has input
154+
input_lookup = 'name="change_by"'
155+
self.assertIn(input_lookup, response.rendered_content)
156+
157+
def test_form_is_rendered_in_changelist(self):
158+
admin_change_url = reverse("admin:polls_choice_changelist")
159+
160+
response = self.client.get(admin_change_url)
161+
162+
# form is in the admin
163+
action_url_lookup = 'action="/admin/polls/choice/actions/reset_all/"'
164+
self.assertIn(action_url_lookup, response.rendered_content)
165+
form_lookup = '<form name="reset_all__form"'
166+
self.assertIn(form_lookup, response.rendered_content)
167+
168+
# form has input
169+
input_lookup = 'name="new_value"'
170+
self.assertIn(input_lookup, response.rendered_content)

django_object_actions/tests/tests.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ def test_tool_func_gets_executed(self):
3131
c = Choice.objects.get(pk=1)
3232
self.assertEqual(c.votes, votes + 1)
3333

34+
def test_tool_func_gets_executed_with_form_params(self):
35+
c = Choice.objects.get(pk=1)
36+
votes = c.votes
37+
response = self.client.post(
38+
reverse("admin:polls_choice_actions", args=(1, "change_votes")),
39+
data={"change_by": "10"},
40+
)
41+
self.assertEqual(response.status_code, 302)
42+
url = reverse("admin:polls_choice_change", args=(1,))
43+
self.assertTrue(response["location"].endswith(url))
44+
c = Choice.objects.get(pk=1)
45+
self.assertEqual(c.votes, votes + 10)
46+
3447
def test_tool_can_return_httpresponse(self):
3548
# we know this url works because of fixtures
3649
url = reverse("admin:polls_choice_actions", args=(2, "edit_poll"))

django_object_actions/utils.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.contrib import messages
55
from django.contrib.admin.utils import unquote
66
from django.db.models.query import QuerySet
7+
from django.forms import Form
78
from django.http import Http404, HttpResponseRedirect
89
from django.http.response import HttpResponseBase
910
from django.views.generic import View
@@ -159,6 +160,7 @@ def _get_tool_dict(self, tool_name):
159160
label=getattr(tool, "label", tool_name.replace("_", " ").capitalize()),
160161
standard_attrs=standard_attrs,
161162
custom_attrs=custom_attrs,
163+
form=self._get_form(tool),
162164
)
163165

164166
def _get_button_attrs(self, tool):
@@ -192,6 +194,12 @@ def _get_button_attrs(self, tool):
192194
custom_attrs[k] = v
193195
return standard_attrs, custom_attrs
194196

197+
def _get_form(self, tool):
198+
form = getattr(tool, "form", None)
199+
if callable(form) and not isinstance(form, Form):
200+
form = form()
201+
return form
202+
195203

196204
class DjangoObjectActions(BaseDjangoObjectActions):
197205
change_form_template = "django_object_actions/change_form.html"
@@ -311,7 +319,13 @@ def decorated_function(self, request, queryset):
311319

312320

313321
def action(
314-
function=None, *, permissions=None, description=None, label=None, attrs=None
322+
function=None,
323+
*,
324+
permissions=None,
325+
description=None,
326+
label=None,
327+
attrs=None,
328+
form=None
315329
):
316330
"""
317331
Conveniently add attributes to an action function::
@@ -347,6 +361,8 @@ def decorator(func):
347361
func.label = label
348362
if attrs is not None:
349363
func.attrs = attrs
364+
if form is not None:
365+
func.form = form
350366
return func
351367

352368
if function is None:

example_project/polls/admin.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111
)
1212

1313
from .models import Choice, Poll, Comment, RelatedData
14+
from django import forms
15+
16+
17+
class ResetAllForm(forms.Form):
18+
new_value = forms.IntegerField(initial=0)
19+
20+
21+
class ChangeVotesForm(forms.Form):
22+
change_by = forms.IntegerField(initial=1)
1423

1524

1625
class ChoiceAdmin(DjangoObjectActions, admin.ModelAdmin):
@@ -19,6 +28,12 @@ class ChoiceAdmin(DjangoObjectActions, admin.ModelAdmin):
1928
# Actions
2029
#########
2130

31+
@action(form=ChangeVotesForm)
32+
def change_votes(self, request, obj):
33+
change_by = int(request.POST["change_by"])
34+
obj.votes += change_by
35+
obj.save()
36+
2237
@action(
2338
description="+1",
2439
label="vote++",
@@ -45,6 +60,12 @@ def decrement_vote(self, request, obj):
4560
def delete_all(self, request, queryset):
4661
self.message_user(request, "just kidding!")
4762

63+
@action(form=ResetAllForm())
64+
def reset_all(self, request, queryset):
65+
self.message_user(
66+
request, f"resetting all to {request.POST['new_value']}. just kidding!"
67+
)
68+
4869
@action(description="0")
4970
def reset_vote(self, request, obj):
5071
obj.votes = 0
@@ -60,11 +81,15 @@ def raise_key_error(self, request, obj):
6081
change_actions = (
6182
"increment_vote",
6283
"decrement_vote",
84+
"change_votes",
6385
"reset_vote",
6486
"edit_poll",
6587
"raise_key_error",
6688
)
67-
changelist_actions = ("delete_all",)
89+
changelist_actions = (
90+
"delete_all",
91+
"reset_all",
92+
)
6893

6994

7095
admin.site.register(Choice, ChoiceAdmin)

0 commit comments

Comments
 (0)