improve product list sorting ui (allow move between categories, hide up/down arrows if drag-drop is available)

This commit is contained in:
Mira Weller
2024-06-11 18:15:52 +02:00
parent 4f9297e7d8
commit 46ec507442
7 changed files with 79 additions and 52 deletions

View File

@@ -32,7 +32,6 @@
<tr> <tr>
<th>{% trans "Product categories" %}</th> <th>{% trans "Product categories" %}</th>
<th class="action-col-2"></th> <th class="action-col-2"></th>
<th class="action-col-2"></th>
</tr> </tr>
</thead> </thead>
<tbody data-dnd-url="{% url "control:event.items.categories.reorder" organizer=request.event.organizer.slug event=request.event.slug %}"> <tbody data-dnd-url="{% url "control:event.items.categories.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
@@ -41,12 +40,10 @@
<td> <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> <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>
<td> <td class="text-right flip">
<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.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> <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> <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> <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>
<a href="{% url "control:event.items.categories.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ c.id }}" <a href="{% url "control:event.items.categories.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ c.id }}"
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip"> class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">

View File

@@ -56,8 +56,7 @@
<th>{% trans "Internal name" %}</th> <th>{% trans "Internal name" %}</th>
<th></th> <th></th>
<th></th> <th></th>
<th>{% trans "Products" %}</th> <th colspan="2">{% trans "Products" %}</th>
<th class="action-col-2"></th>
<th class="action-col-2"></th> <th class="action-col-2"></th>
</tr> </tr>
</thead> </thead>
@@ -102,9 +101,10 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</td> </td>
<td> <td {% if d.benefit_same_products %}colspan="2"{% endif %}>
{% if not d.benefit_same_products %}{% trans "Condition:" %}{% endif %}
{% if d.condition_all_products %} {% if d.condition_all_products %}
<em>{% trans "All" %}</em> <ul><li><em>{% trans "All" %}</em></li></ul>
{% else %} {% else %}
<ul> <ul>
{% for item in d.condition_limit_products.all %} {% for item in d.condition_limit_products.all %}
@@ -115,7 +115,19 @@
</ul> </ul>
{% endif %} {% endif %}
</td> </td>
<td> {% if not d.benefit_same_products %}
<td>
{% trans "Applies to:" %}
<ul>
{% for item in d.benefit_limit_products.all %}
<li>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a>
</li>
{% endfor %}
</ul>
</td>
{% endif %}
<td class="text-right flip">
<button formaction="{% url "control:event.items.discounts.up" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}" <button formaction="{% url "control:event.items.discounts.up" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-default btn-sm sortable-up" class="btn btn-default btn-sm sortable-up"
{% if forloop.counter0 == 0 and not page_obj.has_previous %} {% if forloop.counter0 == 0 and not page_obj.has_previous %}
@@ -125,8 +137,6 @@
{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}> {% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}>
<i class="fa fa-arrow-down"></i></button> <i class="fa fa-arrow-down"></i></button>
<span class="dnd-container"></span> <span class="dnd-container"></span>
</td>
<td class="text-right flip">
<a href="{% url "control:event.items.discounts.edit" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}" <a href="{% url "control:event.items.discounts.edit" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a> class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.discounts.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ d.id }}" <a href="{% url "control:event.items.discounts.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ d.id }}"

View File

@@ -1,7 +1,9 @@
{% extends "pretixcontrol/items/base.html" %} {% extends "pretixcontrol/items/base.html" %}
{% load i18n %} {% load i18n %}
{% load money %}
{% block title %}{% trans "Products" %}{% endblock %} {% block title %}{% trans "Products" %}{% endblock %}
{% block inside %} {% block inside %}
{% blocktrans asvar s_taxes %}taxes{% endblocktrans %}
<h1>{% trans "Products" %}</h1> <h1>{% trans "Products" %}</h1>
<p> <p>
{% blocktrans trimmed %} {% blocktrans trimmed %}
@@ -29,7 +31,7 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-condensed table-hover"> <table class="table table-condensed table-hover table-items">
<thead> <thead>
<tr> <tr>
<th>{% trans "Product name" %}</th> <th>{% trans "Product name" %}</th>
@@ -37,16 +39,23 @@
<th class="iconcol"></th> <th class="iconcol"></th>
<th class="iconcol"></th> <th class="iconcol"></th>
<th class="iconcol"></th> <th class="iconcol"></th>
<th>{% trans "Category" %}</th> <th class="text-right flip">{% trans "Default price" %}</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> <th class="action-col-2"><span class="sr-only">Edit</span></th>
</tr> </tr>
</thead> </thead>
{% regroup items by category as cat_list %}
{% for c in cat_list %} {% for c, items in cat_list %}
<tbody data-dnd-url="{% url "control:event.items.reorder" organizer=request.event.organizer.slug event=request.event.slug %}"> {% if c %}
{% for i in c.list %} <tbody>
{% if forloop.counter0 == 0 and i.category %}<tr class="sortable-disabled"><th colspan="8" scope="colgroup" class="text-muted">{{ i.category }}</th></tr>{% endif %} <tr class="sortable-disabled"><th colspan="9" scope="colgroup" class="text-muted">{{ c }}
<span class="pull-right"><a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}"><span class="fa fa-edit"></span></a></span>
</th></tr>
</tbody>
{% endif %}
<tbody data-dnd-url="{% url "control:event.items.reorder" organizer=request.event.organizer.slug event=request.event.slug category=c.id|default:0 %}"
data-dnd-group="items">
{% for i in items %}
{% if forloop.counter0 == 0 and i.category %}{% endif %}
<tr data-dnd-id="{{ i.id }}" {% if not i.active %}class="row-muted"{% endif %}> <tr data-dnd-id="{{ i.id }}" {% if not i.active %}class="row-muted"{% endif %}>
<td><strong> <td><strong>
{% if not i.active %}<strike>{% endif %} {% if not i.active %}<strike>{% endif %}
@@ -110,13 +119,26 @@
title="{% trans "Can only be bought using a voucher" %}"></span> title="{% trans "Can only be bought using a voucher" %}"></span>
{% endif %} {% endif %}
</td> </td>
<td>{% if i.category %}{{ i.category.name }}{% endif %}</td> <td class="text-right flip">
<td> {% if i.free_price %}
<span class="fa fa-edit fa-fw text-muted" data-toggle="tooltip" title="{% trans "Free price input" %}">
</span>
{% endif %}
{{ i.default_price|money:request.event.currency }}
{% if i.original_price %}<strike class="text-muted">{{ i.original_price|money:request.event.currency }}</strike>{% endif %}
{% if i.tax_rule and i.default_price %}
<br/>
<small class="text-muted">
{% blocktrans trimmed with rate=i.tax_rule.rate|floatformat:-2 taxname=i.tax_rule.name|default:s_taxes %}
incl. {{ rate }}% {{ taxname }}
{% endblocktrans %}
</small>
{% endif %}
</td>
<td class="text-right flip col-actions">
<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.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> <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> <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> <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>
<a href="{% url "control:event.items.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ i.id }}" class="btn btn-default btn-sm" title="{% trans "Clone" %}" data-toggle="tooltip"><i class="fa fa-copy"></i></a> <a href="{% url "control:event.items.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ i.id }}" class="btn btn-default btn-sm" title="{% trans "Clone" %}" data-toggle="tooltip"><i class="fa fa-copy"></i></a>
<a href="{% url "control:event.items.delete" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a> <a href="{% url "control:event.items.delete" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>

View File

@@ -293,7 +293,7 @@ urlpatterns = [
re_path(r'^items/(?P<item>\d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'), 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+)/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/(?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/reorder/(?P<category>\d+)/$', 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/(?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/typeahead/meta/$', typeahead.item_meta_values, name='event.items.meta.typeahead'),
re_path(r'^items/select2$', typeahead.items_select2, name='event.items.select2'), re_path(r'^items/select2$', typeahead.items_select2, name='event.items.select2'),

View File

@@ -35,6 +35,7 @@
import json import json
from collections import OrderedDict, namedtuple from collections import OrderedDict, namedtuple
from itertools import groupby
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
from django.contrib import messages from django.contrib import messages
@@ -113,6 +114,8 @@ class ItemList(ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
ctx['sales_channels'] = get_all_sales_channels() ctx['sales_channels'] = get_all_sales_channels()
items_by_category = {cat: list(items) for cat, items in groupby(ctx['items'], lambda item: item.category)}
ctx['cat_list'] = [(cat, items_by_category.get(cat, [])) for cat in [None, *self.request.event.categories.all()]]
return ctx return ctx
@@ -169,7 +172,7 @@ def item_move_down(request, organizer, event, item):
@transaction.atomic @transaction.atomic
@event_permission_required("can_change_items") @event_permission_required("can_change_items")
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def reorder_items(request, organizer, event): def reorder_items(request, organizer, event, category):
try: try:
ids = json.loads(request.body.decode('utf-8'))['ids'] ids = json.loads(request.body.decode('utf-8'))['ids']
except (JSONDecodeError, KeyError, ValueError): except (JSONDecodeError, KeyError, ValueError):
@@ -180,23 +183,21 @@ def reorder_items(request, organizer, event):
if len(input_items) != len(ids): if len(input_items) != len(ids):
raise Http404(_("Some of the provided object ids are invalid.")) raise Http404(_("Some of the provided object ids are invalid."))
item_categories = {i.category_id for i in input_items} if int(category):
if len(item_categories) > 1: target_category = request.event.categories.get(id=category)
raise Http404(_("You cannot reorder items spanning different categories.")) else:
target_category = None
# 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 objects have been selected."))
for i in input_items: for i in input_items:
pos = ids.index(str(i.pk)) pos = ids.index(str(i.pk))
if pos != i.position: # Save unneccessary UPDATE queries if pos != i.position or target_category != i.category: # Save unneccessary UPDATE queries
i.position = pos i.position = pos
i.save(update_fields=['position']) i.category = target_category
i.save(update_fields=['position', 'category_id'])
i.log_action( i.log_action(
'pretix.event.item.reordered', user=request.user, data={ 'pretix.event.item.reordered', user=request.user, data={
'position': i, 'position': i,
'category': target_category and target_category.pk,
} }
) )

View File

@@ -6,7 +6,8 @@ $(function () {
handle = $('<span class="btn btn-default btn-sm dnd-sort-handle"><i class="fa fa-arrows"></i></span>'); handle = $('<span class="btn btn-default btn-sm dnd-sort-handle"><i class="fa fa-arrows"></i></span>');
container.find(".dnd-container").append(handle); container.find(".dnd-container").append(handle);
if (container.find("[data-dnd-id]").length < 2) { container.find(".sortable-up, .sortable-down").hide();
if (container.find("[data-dnd-id]").length < 2 && !container.data("dnd-group")) {
handle.addClass("disabled"); handle.addClass("disabled");
return; return;
} }
@@ -14,6 +15,7 @@ $(function () {
Sortable.create(container.get(0), { Sortable.create(container.get(0), {
filter: ".sortable-disabled", filter: ".sortable-disabled",
handle: ".dnd-sort-handle", handle: ".dnd-sort-handle",
group: container.data("dnd-group"),
onMove: function (evt) { onMove: function (evt) {
return evt.related.className.indexOf('sortable-disabled') === -1; return evt.related.className.indexOf('sortable-disabled') === -1;
}, },
@@ -24,24 +26,11 @@ $(function () {
onEnd: function (evt) { onEnd: function (evt) {
container.removeClass("sortable-dragarea"); container.removeClass("sortable-dragarea");
container.parent().removeClass("sortable-sorting"); 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){ onSort: function (evt){
var container = $(evt.to), if (evt.target !== evt.to) return;
ids = container.find("[data-dnd-id]").toArray().map(function (e) { return e.dataset.dndId; });
var ids = container.find("[data-dnd-id]").toArray().map(function (e) { return e.dataset.dndId; });
$.ajax( $.ajax(
{ {

View File

@@ -841,6 +841,14 @@ h1 .label {
tbody[data-dnd-url] { tbody[data-dnd-url] {
transition: opacity 1s; transition: opacity 1s;
} }
.table-items tbody + tbody {
border-top-width: 1px;
}
.table-items tbody[data-dnd-url]:after {
content:'';
display: table-row;
height: 0.5em;
}
.sortable-sorting tbody:not(.sortable-dragarea) { .sortable-sorting tbody:not(.sortable-dragarea) {
opacity: .4; opacity: .4;
} }