Skip to content

Commit 0059464

Browse files
authored
feat: sortable changelist (#1463)
1 parent 52d5769 commit 0059464

File tree

9 files changed

+161
-66
lines changed

9 files changed

+161
-66
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
title: Sortable changelist
3+
order: 10
4+
description: Enable sortable changelists in Django Unfold for drag-and-drop record reordering directly in the admin list view.
5+
---
6+
7+
# Sortable changelist
8+
9+
Django Unfold supports sortable changelists, enabling intuitive drag-and-drop reordering of records directly within the changelist (list view) of the admin interface.
10+
11+
## Requirements
12+
13+
To enable sortable functionality on the changelist page:
14+
15+
- Your model must include a dedicated ordering field. This field should be a `PositiveIntegerField` with `db_index=True` for optimal database performance.
16+
- The ordering field must be available in the database and included in your model's `Meta.ordering` or set via queryset ordering.
17+
- Enable `ordering_field` on your `ModelAdmin` and set `hide_ordering_field=True` if you want to hide the field from the list display.
18+
19+
## Limitations
20+
21+
- Sorting is only possible among records displayed on the current page. Records cannot be reordered across multiple pages due to pagination constraints.
22+
- Bulk editing and reordering should be completed and saved before navigating to another page to avoid losing changes.
23+
24+
## Example Configuration
25+
26+
27+
```python
28+
# models.py
29+
30+
from django.db import models
31+
from django.utils.translation import gettext_lazy as _
32+
33+
class MyModel(models.Model):
34+
weight = models.PositiveIntegerField(_("weight"), default=0, db_index=True)
35+
36+
class Meta:
37+
ordering = ["weight"]
38+
```
39+
40+
```python
41+
# admin.py
42+
43+
from django.contrib import admin
44+
from unfold.admin import ModelAdmin
45+
from .models import MyModel
46+
47+
@admin.register(MyModel)
48+
class SomeAdmin(ModelAdmin):
49+
ordering_field = "weight"
50+
hide_ordering_field = True # default: False
51+
```

src/unfold/admin.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ class ModelAdmin(BaseModelAdminMixin, ActionModelAdminMixin, BaseModelAdmin):
4747
action_form = ActionForm
4848
custom_urls = ()
4949
add_fieldsets = ()
50+
ordering_field = None
51+
hide_ordering_field = False
5052
list_horizontal_scrollbar_top = False
5153
list_filter_submit = False
5254
list_filter_sheet = True
@@ -86,8 +88,22 @@ def changelist_view(
8688
self, request: HttpRequest, extra_context: Optional[dict[str, str]] = None
8789
) -> TemplateResponse:
8890
self.request = request
91+
92+
if self.ordering_field and self.ordering_field not in self.list_editable:
93+
list_editable = list(getattr(self, "list_editable", []))
94+
list_editable.append(self.ordering_field)
95+
self.list_editable = list_editable
96+
8997
return super().changelist_view(request, extra_context)
9098

99+
def get_list_display(self, request: HttpRequest) -> list[str]:
100+
list_display = super().get_list_display(request)
101+
102+
if self.ordering_field and self.ordering_field not in list_display:
103+
list_display.append(self.ordering_field)
104+
105+
return list_display
106+
91107
def get_fieldsets(self, request: HttpRequest, obj=None) -> FieldsetsType:
92108
if not obj and self.add_fieldsets:
93109
return self.add_fieldsets

src/unfold/static/unfold/css/styles.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/unfold/static/unfold/js/app.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ const sortRecords = (e) => {
1717
const orderingField = e.from.dataset.orderingField;
1818

1919
const weightInputs = Array.from(
20-
e.from.querySelectorAll(`.has_original input[name$=-${orderingField}]`)
20+
e.from.querySelectorAll(
21+
`.has_original input[name$=-${orderingField}], td.field-${orderingField} input[name$=-${orderingField}]`
22+
)
2123
);
2224

2325
weightInputs.forEach((input, index) => {

src/unfold/templates/admin/change_list_results.html

Lines changed: 10 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -8,69 +8,11 @@
88

99
{% if results %}
1010
<div class="{% if cl.search_fields or cl.has_filters %}lg:rounded-b-default{% else %}lg:rounded-default{% endif %} -mx-1 px-1 overflow-x-auto lg:border lg:border-base-200 lg:mx-0 lg:px-0 lg:shadow-xs lg:dark:border-base-800 lg:bg-white lg:dark:bg-base-900 {% if cl.model_admin.list_horizontal_scrollbar_top %}simplebar-horizontal-scrollbar-top{% endif %}" data-simplebar data-simplebar-auto-hide="false">
11-
<table id="result_list" class="block border-base-200 border-spacing-none border-separate w-full lg:table">
12-
<thead>
13-
<tr>
14-
{% if cl.model_admin.list_sections|length > 0 %}
15-
<th></th>
16-
{% endif %}
17-
18-
{% for header in result_headers %}
19-
<th class="align-middle font-semibold py-2 text-left text-font-important-light dark:text-font-important-dark whitespace-nowrap {{ header.class_attrib }} {% if "action-toggle" in header.text and forloop.counter == 1 %}lg:px-3 lg:w-10{% else %}hidden px-3 lg:table-cell{% endif %}" scope="col">
20-
<div class="flex items-center">
21-
<div class="text">
22-
{% if header.sortable %}
23-
<a href="{{ header.url_primary }}">
24-
{{ header.text|capfirst }}
25-
</a>
26-
{% else %}
27-
{% if "action-toggle" in header.text and forloop.counter == 1 %}
28-
<label class="flex flex-row items-center gap-2">
29-
{{ header.text|capfirst }}
30-
31-
<span class="block font-normal lg:hidden">
32-
{% trans "Select all rows"%}
33-
</span>
34-
</label>
35-
{% else %}
36-
<span>
37-
{{ header.text|capfirst }}
38-
</span>
39-
{% endif %}
40-
{% endif %}
41-
</div>
42-
43-
{% if header.sortable %}
44-
{% if header.sort_priority > 0 %}
45-
<div class="sortoptions flex items-center ml-2">
46-
<a href="{{ header.url_toggle }}" class="flex items-center leading-none text-base-400 hover:text-base-500 dark:text-base-500 dark:hover:text-base-400 toggle {% if header.ascending %}ascending{% else %}descending{% endif %}" title="{% translate "Toggle sorting" %}">
47-
{% if header.ascending %}
48-
<span class="block material-symbols-outlined">arrow_circle_down</span>
49-
{% else %}
50-
<span class="block material-symbols-outlined">arrow_circle_up</span>
51-
{% endif %}
52-
</a>
53-
54-
<a class="sortremove flex items-center leading-none ml-1 text-base-400 dark:text-base-500 transition-all hover:text-red-700 dark:hover:text-red-500" href="{{ header.url_remove }}" title="{% translate "Remove from sorting" %}">
55-
<span class="block material-symbols-outlined">cancel</span>
56-
</a>
57-
</div>
58-
59-
{% if num_sorted_fields > 1 %}
60-
<span class="sortpriority font-medium ml-2 text-xs" title="{% blocktranslate with priority_number=header.sort_priority %}Sorting priority: {{ priority_number }}{% endblocktranslate %}">
61-
{{ header.sort_priority }}
62-
</span>
63-
{% endif %}
64-
{% endif %}
65-
{% endif %}
66-
</div>
67-
</th>
68-
{% endfor %}
69-
</tr>
70-
</thead>
11+
<table id="result_list" class="block border-base-200 border-spacing-none border-separate w-full lg:table" {% if cl.model_admin.ordering_field %}x-sort.ghost x-on:end="sortRecords"{% endif %} data-ordering-field="{{ cl.model_admin.ordering_field }}">
12+
{% include 'unfold/helpers/change_list_headers.html' %}
7113

7214
{% for result in results %}
73-
<tbody class="block relative lg:table-row-group lg:hover:shadow-raised lg:dark:hover:shadow-raised-dark lg:hover:z-20 {% cycle '' 'bg-base-50 dark:bg-white/[.02]' %}" x-data="{rowOpen: false}">
15+
<tbody class="block relative lg:table-row-group lg:hover:shadow-raised lg:dark:hover:shadow-raised-dark lg:hover:z-20 {% cycle '' 'bg-base-50 dark:bg-white/[.02]' %}" x-data="{rowOpen: false}" {% if cl.model_admin.ordering_field %}x-sort:item{% endif %}>
7416
{% if result.form and result.form.non_field_errors %}
7517
<tr>
7618
<td class="text-left" colspan="{{ result|length }}">
@@ -80,6 +22,12 @@
8022
{% endif %}
8123

8224
<tr class="block border border-base-200 mb-3 relative rounded-default shadow-xs lg:table-row lg:border-none lg:mb-0 lg:rounded-none lg:shadow-none dark:border-base-800">
25+
{% if cl.model_admin.ordering_field %}
26+
<td class="align-middle cursor-move flex border-b border-base-200 font-normal px-2.5 py-2 relative text-left before:font-semibold before:text-font-important-light before:block before:capitalize before:content-[attr(data-label)] before:mr-auto lg:before:hidden lg:border-b-0 lg:border-t lg:pl-3 lg:pr-0 lg:py-3 lg:table-cell dark:border-base-800 dark:lg:border-base-800 dark:before:text-font-important-dark lg:w-px" x-sort:handle>
27+
<span class="material-symbols-outlined align-middle cursor-move">drag_indicator</span>
28+
</td>
29+
{% endif %}
30+
8331
{% if cl.model_admin.list_sections|length > 0 %}
8432
<td class="align-middle cursor-pointer flex border-b border-base-200 font-normal px-2.5 py-2 relative text-left before:font-semibold before:text-font-important-light before:block before:capitalize before:content-[attr(data-label)] before:mr-auto lg:before:hidden lg:border-b-0 lg:border-t lg:pl-3 lg:pr-0 lg:py-3 lg:table-cell dark:border-base-800 dark:lg:border-base-800 dark:before:text-font-important-dark lg:w-px"
8533
data-label="{% trans "Expand row" %}"
@@ -100,7 +48,7 @@
10048

10149
{% if cl.model_admin.list_sections|length > 0 %}
10250
<tr class="block mb-3 relative z-30 lg:table-row" x-show="rowOpen">
103-
<td colspan="{{ result|length|add:2 }}" class="border bg-base-200/10 block border-base-200 relative rounded-default p-3 lg:shadow-inner lg:border-0 lg:border-t lg:rounded-none lg:table-cell dark:border-base-800">
51+
<td colspan="100%" class="border bg-base-200/10 block border-base-200 relative rounded-default p-3 lg:shadow-inner lg:border-0 lg:border-t lg:rounded-none lg:table-cell dark:border-base-800">
10452
<div class="absolute bg-primary-600 h-full hidden left-0 top-0 w-0.5 lg:block"></div>
10553

10654
<div class="grid gap-3 {{ cl.model_admin.list_sections_classes }}">

src/unfold/templates/admin/edit_inline/stacked.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ <h3 class="border-b border-base-200 flex font-medium items-center gap-2 px-3 py-
3434

3535
{% if inline_admin_formset.opts.ordering_field %}
3636
{% if inline_admin_form.original %}
37-
<span class="material-symbols-outlined cursor-pointer" x-sort:handle>drag_indicator</span>
37+
<span class="material-symbols-outlined cursor-move" x-sort:handle>drag_indicator</span>
3838
{% else %}
3939
<span class="-mr-2" x-sort:handle></span>
4040
{% endif %}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{% load i18n %}
2+
3+
<thead>
4+
<tr>
5+
{% if cl.model_admin.ordering_field %}
6+
<th></th>
7+
{% endif %}
8+
9+
{% if cl.model_admin.list_sections|length > 0 %}
10+
<th></th>
11+
{% endif %}
12+
13+
{% for header in result_headers %}
14+
<th class="align-middle font-semibold py-2 text-left text-font-important-light dark:text-font-important-dark whitespace-nowrap {{ header.class_attrib }} {% if "action-toggle" in header.text and forloop.counter == 1 %}lg:px-3 lg:w-10{% else %}hidden px-3 lg:table-cell{% endif %}" scope="col">
15+
<div class="flex items-center">
16+
<div class="text">
17+
{% if header.sortable %}
18+
<a href="{{ header.url_primary }}">
19+
{{ header.text|capfirst }}
20+
</a>
21+
{% else %}
22+
{% if "action-toggle" in header.text and forloop.counter == 1 %}
23+
<label class="flex flex-row items-center gap-2">
24+
{{ header.text|capfirst }}
25+
26+
<span class="block font-normal lg:hidden">
27+
{% trans "Select all rows"%}
28+
</span>
29+
</label>
30+
{% else %}
31+
<span>
32+
{{ header.text|capfirst }}
33+
</span>
34+
{% endif %}
35+
{% endif %}
36+
</div>
37+
38+
{% if header.sortable %}
39+
{% if header.sort_priority > 0 %}
40+
<div class="sortoptions flex items-center ml-2">
41+
<a href="{{ header.url_toggle }}" class="flex items-center leading-none text-base-400 hover:text-base-500 dark:text-base-500 dark:hover:text-base-400 toggle {% if header.ascending %}ascending{% else %}descending{% endif %}" title="{% translate "Toggle sorting" %}">
42+
{% if header.ascending %}
43+
<span class="block material-symbols-outlined">arrow_circle_down</span>
44+
{% else %}
45+
<span class="block material-symbols-outlined">arrow_circle_up</span>
46+
{% endif %}
47+
</a>
48+
49+
<a class="sortremove flex items-center leading-none ml-1 text-base-400 dark:text-base-500 transition-all hover:text-red-700 dark:hover:text-red-500" href="{{ header.url_remove }}" title="{% translate "Remove from sorting" %}">
50+
<span class="block material-symbols-outlined">cancel</span>
51+
</a>
52+
</div>
53+
54+
{% if num_sorted_fields > 1 %}
55+
<span class="sortpriority font-medium ml-2 text-xs" title="{% blocktranslate with priority_number=header.sort_priority %}Sorting priority: {{ priority_number }}{% endblocktranslate %}">
56+
{{ header.sort_priority }}
57+
</span>
58+
{% endif %}
59+
{% endif %}
60+
{% endif %}
61+
</div>
62+
</th>
63+
{% endfor %}
64+
</tr>
65+
</thead>

src/unfold/templates/unfold/helpers/edit_inline/tabular_field.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{% if forloop.parentloop.counter == 1 and forloop.counter == 1 %}
33
{% if inline_admin_formset.opts.ordering_field %}
44
{% if inline_admin_form.original %}
5-
<span class="material-symbols-outlined cursor-pointer" x-sort:handle>drag_indicator</span>
5+
<span class="material-symbols-outlined cursor-move" x-sort:handle>drag_indicator</span>
66
{% else %}
77
<span class="-mr-3" x-sort:handle></span>
88
{% endif %}

src/unfold/templatetags/unfold_list.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ def result_headers(cl):
9999
Generate the list column headers.
100100
"""
101101
ordering_field_columns = cl.get_ordering_field_columns()
102+
ordering_field = getattr(cl.model_admin, "ordering_field", None)
103+
hide_ordering_field = getattr(cl.model_admin, "hide_ordering_field", False)
104+
102105
for i, field_name in enumerate(cl.list_display):
103106
text, attr = label_for_field(
104107
field_name, cl.model, model_admin=cl.model_admin, return_attr=True
@@ -145,6 +148,10 @@ def result_headers(cl):
145148
order_type = ""
146149
new_order_type = "asc"
147150
sort_priority = 0
151+
152+
if ordering_field and field_name == ordering_field and hide_ordering_field:
153+
th_classes.append("!hidden")
154+
148155
# Is it currently being sorted on?
149156
is_sorted = i in ordering_field_columns
150157
if is_sorted:
@@ -209,6 +216,9 @@ def link_in_col(is_first: bool, field_name: str, cl: ChangeList) -> bool:
209216

210217
for field_index, field_name in enumerate(cl.list_display):
211218
empty_value_display = cl.model_admin.get_empty_value_display()
219+
ordering_field = getattr(cl.model_admin, "ordering_field", None)
220+
hide_ordering_field = getattr(cl.model_admin, "hide_ordering_field", False)
221+
212222
row_classes = [
213223
f"field-{_coerce_field_name(field_name, field_index)}",
214224
*ROW_CLASSES,
@@ -321,6 +331,9 @@ def link_in_col(is_first: bool, field_name: str, cl: ChangeList) -> bool:
321331
if bf.errors:
322332
row_classes += ["group", "errors"]
323333

334+
if ordering_field and field_name == ordering_field and hide_ordering_field:
335+
row_classes.append("!hidden")
336+
324337
row_class = mark_safe(f' class="{" ".join(row_classes)}"')
325338

326339
if field_index != 0:

0 commit comments

Comments
 (0)