Control: Add drag and drop to sort categories and products (#2242)

* add drag and drop to categories

* add drag and drop to products

* add light grey background to dragged element

* add missing th, add sr-only desc of columns

* group up/down/move elements

* improve visualizing drag-area by dimming others

* change up/down-links to buttons in form-post

* limit sorting to POST requests

Co-authored-by: Raphael Michel <michel@rami.io>
This commit is contained in:
Richard Schreiber
2021-10-12 14:46:56 +02:00
committed by GitHub
parent dde4e12ce1
commit 8121167d5e
8 changed files with 249 additions and 112 deletions

View File

@@ -24,6 +24,8 @@
<a href="{% url "control:event.items.categories.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new category" %}
</a>
</p>
<form method="post">
{% csrf_token %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
@@ -33,15 +35,16 @@
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
<tbody data-dnd-url="{% url "control:event.items.categories.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
{% for c in categories %}
<tr>
<tr data-dnd-id="{{ c.id }}">
<td>
<strong><a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}">{{ c.internal_name|default:c.name }}</a></strong>
</td>
<td>
<a href="{% url "control:event.items.categories.up" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm {% if forloop.counter0 == 0 and is_paginated and not page_obj.has_previous %}disabled{% endif %}"><i class="fa fa-arrow-up"></i></a>
<a href="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm {% if forloop.revcounter0 == 0 and is_paginated and not page_obj.has_next %}disabled{% endif %}"><i class="fa fa-arrow-down"></i></a>
<button formaction="{% url "control:event.items.categories.up" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 and not page_obj.has_previous %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button formaction="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
<span class="dnd-container"></span>
</td>
<td class="text-right flip">
<a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
@@ -56,6 +59,7 @@
</tbody>
</table>
</div>
</form>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}

View File

@@ -26,6 +26,8 @@
<a href="{% url "control:event.items.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new product" %}</a>
</p>
<form method="post">
{% csrf_token %}
<div class="table-responsive">
<table class="table table-condensed table-hover">
<thead>
@@ -36,15 +38,16 @@
<th class="iconcol"></th>
<th class="iconcol"></th>
<th>{% trans "Category" %}</th>
<th class="action-col-2"></th>
<th class="action-col-2"></th>
<th class="action-col-2"><span class="sr-only">Move</span></th>
<th class="action-col-2"><span class="sr-only">Edit</span></th>
</tr>
</thead>
<tbody>
{% regroup items by category as cat_list %}
{% for c in cat_list %}
<tbody data-dnd-url="{% url "control:event.items.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
{% for i in c.list %}
<tr {% if not i.active %}class="row-muted"{% endif %}>
{% if forloop.counter0 == 0 and i.category %}<tr class="sortable-disabled"><th colspan="8" scope="colgroup" class="text-muted">{{ i.category.name }}</th></tr>{% endif %}
<tr data-dnd-id="{{ i.id }}" {% if not i.active %}class="row-muted"{% endif %}>
<td><strong>
{% if not i.active %}<strike>{% endif %}
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}">{{ i }}</a>
@@ -105,8 +108,9 @@
</td>
<td>{% if i.category %}{{ i.category.name }}{% endif %}</td>
<td>
<a href="{% url "control:event.items.up" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm {% if forloop.counter0 == 0 and is_paginated and not page_obj.has_previous %}disabled{% endif %}"><i class="fa fa-arrow-up"></i></a>
<a href="{% url "control:event.items.down" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm {% if forloop.revcounter0 == 0 and is_paginated and not page_obj.has_next %}disabled{% endif %}"><i class="fa fa-arrow-down"></i></a>
<button formaction="{% url "control:event.items.up" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button formaction="{% url "control:event.items.down" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
<span class="dnd-container"></span>
</td>
<td class="text-right flip col-actions">
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
@@ -115,10 +119,11 @@
</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</tbody>
</table>
</div>
</form>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}

View File

@@ -239,6 +239,7 @@ urlpatterns = [
re_path(r'^items/(?P<item>\d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'),
re_path(r'^items/(?P<item>\d+)/up$', item.item_move_up, name='event.items.up'),
re_path(r'^items/(?P<item>\d+)/down$', item.item_move_down, name='event.items.down'),
re_path(r'^items/reorder$', item.reorder_items, name='event.items.reorder'),
re_path(r'^items/(?P<item>\d+)/delete$', item.ItemDelete.as_view(), name='event.items.delete'),
re_path(r'^items/typeahead/meta/$', typeahead.item_meta_values, name='event.items.meta.typeahead'),
re_path(r'^items/select2$', typeahead.items_select2, name='event.items.select2'),
@@ -250,6 +251,7 @@ urlpatterns = [
re_path(r'^categories/(?P<category>\d+)/up$', item.category_move_up, name='event.items.categories.up'),
re_path(r'^categories/(?P<category>\d+)/down$', item.category_move_down,
name='event.items.categories.down'),
re_path(r'^categories/reorder$', item.reorder_categories, name='event.items.categories.reorder'),
re_path(r'^categories/(?P<category>\d+)/$', item.CategoryUpdate.as_view(),
name='event.items.categories.edit'),
re_path(r'^categories/add$', item.CategoryCreate.as_view(), name='event.items.categories.add'),

View File

@@ -53,6 +53,7 @@ from django.urls import resolve, reverse
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _
from django.views.decorators.http import require_http_methods
from django.views.generic import ListView
from django.views.generic.detail import DetailView, SingleObjectMixin
from django.views.generic.edit import DeleteView
@@ -138,6 +139,7 @@ def item_move(request, item, up=True):
@event_permission_required("can_change_items")
@require_http_methods(["POST"])
def item_move_up(request, organizer, event, item):
item_move(request, item, up=True)
return redirect('control:event.items',
@@ -146,6 +148,7 @@ def item_move_up(request, organizer, event, item):
@event_permission_required("can_change_items")
@require_http_methods(["POST"])
def item_move_down(request, organizer, event, item):
item_move(request, item, up=False)
return redirect('control:event.items',
@@ -153,6 +156,38 @@ def item_move_down(request, organizer, event, item):
event=request.event.slug)
@transaction.atomic
@event_permission_required("can_change_items")
@require_http_methods(["POST"])
def reorder_items(request, organizer, event):
try:
ids = json.loads(request.body.decode('utf-8'))['ids']
except (JSONDecodeError, KeyError, ValueError):
return HttpResponseBadRequest("expected JSON: {ids:[]}")
input_items = list(request.event.items.filter(id__in=[i for i in ids if i.isdigit()]))
if len(input_items) != len(ids):
raise Http404(_("Some of the provided item ids are invalid."))
item_categories = {i.category_id for i in input_items}
if len(item_categories) > 1:
raise Http404(_("You cannot reorder items spanning different categories."))
# get first and only category
item_category = next(iter(item_categories))
if len(input_items) != request.event.items.filter(category=item_category).count():
raise Http404(_("Not all items have been selected."))
for i in input_items:
pos = ids.index(str(i.pk))
if pos != i.position: # Save unneccessary UPDATE queries
i.position = pos
i.save(update_fields=['position'])
return HttpResponse()
class CategoryDelete(EventPermissionRequiredMixin, DeleteView):
model = ItemCategory
form_class = CategoryForm
@@ -307,6 +342,7 @@ def category_move(request, category, up=True):
@event_permission_required("can_change_items")
@require_http_methods(["POST"])
def category_move_up(request, organizer, event, category):
category_move(request, category, up=True)
return redirect('control:event.items.categories',
@@ -315,6 +351,7 @@ def category_move_up(request, organizer, event, category):
@event_permission_required("can_change_items")
@require_http_methods(["POST"])
def category_move_down(request, organizer, event, category):
category_move(request, category, up=False)
return redirect('control:event.items.categories',
@@ -322,6 +359,32 @@ def category_move_down(request, organizer, event, category):
event=request.event.slug)
@transaction.atomic
@event_permission_required("can_change_items")
@require_http_methods(["POST"])
def reorder_categories(request, organizer, event):
try:
ids = json.loads(request.body.decode('utf-8'))['ids']
except (JSONDecodeError, KeyError, ValueError):
return HttpResponseBadRequest("expected JSON: {ids:[]}")
input_categories = list(request.event.categories.filter(id__in=[i for i in ids if i.isdigit()]))
if len(input_categories) != len(ids):
raise Http404(_("Some of the provided category ids are invalid."))
if len(input_categories) != request.event.categories.count():
raise Http404(_("Not all categories have been selected."))
for c in input_categories:
pos = ids.index(str(c.pk))
if pos != c.position: # Save unneccessary UPDATE queries
c.position = pos
c.save(update_fields=['position'])
return HttpResponse()
FakeQuestion = namedtuple(
'FakeQuestion', 'id question position required'
)
@@ -423,18 +486,21 @@ class QuestionList(ListView):
@transaction.atomic
@event_permission_required("can_change_items")
@require_http_methods(["POST"])
def reorder_questions(request, organizer, event):
try:
ids = json.loads(request.body.decode('utf-8'))['ids']
except (JSONDecodeError, KeyError, ValueError):
return HttpResponseBadRequest("expected JSON: {ids:[]}")
input_questions = request.event.questions.filter(id__in=[i for i in ids if i.isdigit()])
# filter system_questions - normal questions are int/digit, system_questions strings
custom_question_ids = [i for i in ids if i.isdigit()]
input_questions = list(request.event.questions.filter(id__in=custom_question_ids))
if input_questions.count() != len([i for i in ids if i.isdigit()]):
if len(input_questions) != len(custom_question_ids):
raise Http404(_("Some of the provided question ids are invalid."))
if input_questions.count() != request.event.questions.count():
if len(input_questions) != request.event.questions.count():
raise Http404(_("Not all questions have been selected."))
for q in input_questions:

View File

@@ -5,11 +5,40 @@ $(function () {
url = container.data("dnd-url"),
handle = $('<span class="btn btn-default btn-sm dnd-sort-handle"><i class="fa fa-arrows"></i></span>');
console.log(container, container.find(".dnd-container"));
container.find(".dnd-container").append(handle);
if (container.find("[data-dnd-id]").length < 2) {
handle.addClass("disabled");
return;
}
Sortable.create(container.get(0), {
filter: ".sortable-disabled",
handle: ".dnd-sort-handle",
onMove: function (evt) {
return evt.related.className.indexOf('sortable-disabled') === -1;
},
onStart: function (evt) {
container.addClass("sortable-dragarea");
container.parent().addClass("sortable-sorting");
},
onEnd: function (evt) {
container.removeClass("sortable-dragarea");
container.parent().removeClass("sortable-sorting");
var disabledUp = container.find(".sortable-up:disabled"),
firstUp = container.find(">tr[data-dnd-id] .sortable-up").first();
if (disabledUp.length && disabledUp.get(0) !== firstUp.get(0)) {
disabledUp.prop("disabled", false);
firstUp.prop("disabled", true);
}
var disabledDown = container.find(".sortable-down:disabled"),
lastDown = container.find(">tr[data-dnd-id] .sortable-down").last();
if (disabledDown.length && disabledDown.get(0) !== lastDown.get(0)) {
disabledDown.prop("disabled", false);
lastDown.prop("disabled", true);
}
},
onSort: function (evt){
var container = $(evt.to),
ids = container.find("[data-dnd-id]").toArray().map(function (e) { return e.dataset.dndId; });

View File

@@ -706,6 +706,20 @@ h1 .label {
}
}
.sortable-chosen {
background-color: $table-bg-hover;
}
tbody[data-dnd-url] {
transition: opacity 1s;
}
.sortable-sorting tbody:not(.sortable-dragarea) {
opacity: .4;
}
tbody th {
background: $table-bg-hover;
}
.withoutjs {
display: none !important;
}