Skip to content

Commit

Permalink
Support for inline forms for action buttons
Browse files Browse the repository at this point in the history
  • Loading branch information
Wiktor Latanowicz committed Nov 18, 2022
1 parent a616d37 commit a7ad92a
Show file tree
Hide file tree
Showing 9 changed files with 212 additions and 28 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<!--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.
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,21 +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 %}
<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>
{% if not tool.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.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,21 +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 %}
<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>
{% if not tool.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.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 %}
34 changes: 34 additions & 0 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 Down Expand Up @@ -134,3 +135,36 @@ def test_redirect_back_from_secondary_admin(self):

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)
13 changes: 13 additions & 0 deletions django_object_actions/tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ def test_tool_func_gets_executed(self):
c = Choice.objects.get(pk=1)
self.assertEqual(c.votes, votes + 1)

def test_tool_func_gets_executed_with_form_params(self):
c = Choice.objects.get(pk=1)
votes = c.votes
response = self.client.post(
reverse("admin:polls_choice_actions", args=(1, "change_votes")),
data={"change_by": "10"},
)
self.assertEqual(response.status_code, 302)
url = reverse("admin:polls_choice_change", args=(1,))
self.assertTrue(response["location"].endswith(url))
c = Choice.objects.get(pk=1)
self.assertEqual(c.votes, votes + 10)

def test_tool_can_return_httpresponse(self):
# we know this url works because of fixtures
url = reverse("admin:polls_choice_actions", args=(2, "edit_poll"))
Expand Down
18 changes: 17 additions & 1 deletion django_object_actions/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.contrib import messages
from django.contrib.admin.utils import unquote
from django.db.models.query import QuerySet
from django.forms import Form
from django.http import Http404, HttpResponseRedirect
from django.http.response import HttpResponseBase
from django.views.generic import View
Expand Down Expand Up @@ -159,6 +160,7 @@ def _get_tool_dict(self, tool_name):
label=getattr(tool, "label", tool_name.replace("_", " ").capitalize()),
standard_attrs=standard_attrs,
custom_attrs=custom_attrs,
form=self._get_form(tool),
)

def _get_button_attrs(self, tool):
Expand Down Expand Up @@ -192,6 +194,12 @@ def _get_button_attrs(self, tool):
custom_attrs[k] = v
return standard_attrs, custom_attrs

def _get_form(self, tool):
form = getattr(tool, "form", None)
if callable(form) and not isinstance(form, Form):
form = form()
return form


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


def action(
function=None, *, permissions=None, description=None, label=None, attrs=None
function=None,
*,
permissions=None,
description=None,
label=None,
attrs=None,
form=None
):
"""
Conveniently add attributes to an action function::
Expand Down Expand Up @@ -347,6 +361,8 @@ def decorator(func):
func.label = label
if attrs is not None:
func.attrs = attrs
if form is not None:
func.form = form
return func

if function is None:
Expand Down
27 changes: 26 additions & 1 deletion example_project/polls/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
)

from .models import Choice, Poll, Comment, RelatedData
from django import forms


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


class ChangeVotesForm(forms.Form):
change_by = forms.IntegerField(initial=1)


class ChoiceAdmin(DjangoObjectActions, admin.ModelAdmin):
Expand All @@ -19,6 +28,12 @@ class ChoiceAdmin(DjangoObjectActions, admin.ModelAdmin):
# Actions
#########

@action(form=ChangeVotesForm)
def change_votes(self, request, obj):
change_by = int(request.POST["change_by"])
obj.votes += change_by
obj.save()

@action(
description="+1",
label="vote++",
Expand All @@ -45,6 +60,12 @@ def decrement_vote(self, request, obj):
def delete_all(self, request, queryset):
self.message_user(request, "just kidding!")

@action(form=ResetAllForm())
def reset_all(self, request, queryset):
self.message_user(
request, f"resetting all to {request.POST['new_value']}. just kidding!"
)

@action(description="0")
def reset_vote(self, request, obj):
obj.votes = 0
Expand All @@ -60,11 +81,15 @@ def raise_key_error(self, request, obj):
change_actions = (
"increment_vote",
"decrement_vote",
"change_votes",
"reset_vote",
"edit_poll",
"raise_key_error",
)
changelist_actions = ("delete_all",)
changelist_actions = (
"delete_all",
"reset_all",
)


admin.site.register(Choice, ChoiceAdmin)
Expand Down

0 comments on commit a7ad92a

Please sign in to comment.