From a73c4ad937038357bc1eb0d6cd8155bda68dffa0 Mon Sep 17 00:00:00 2001 From: Mira Date: Tue, 25 Jun 2024 12:54:11 +0200 Subject: [PATCH] Improve List Sorting UI (#4215) Improve product list UI (allow move between categories, more useful columns and links) and hide "move up/down" arrows in lists by default if drag-drop is available --- src/pretix/base/models/items.py | 10 +++ .../pretixcontrol/items/categories.html | 13 ++-- .../pretixcontrol/items/discounts.html | 30 +++++--- .../templates/pretixcontrol/items/index.html | 63 ++++++++++------ .../pretixcontrol/organizers/properties.html | 6 +- src/pretix/control/urls.py | 2 +- src/pretix/control/views/item.py | 23 +++--- .../pretixcontrol/js/ui/dragndroplist.js | 72 +++++++++++++------ .../static/pretixcontrol/scss/main.scss | 12 ++++ src/tests/control/test_items.py | 18 +++++ src/tests/control/test_permissions.py | 2 +- 11 files changed, 176 insertions(+), 75 deletions(-) diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index a5b2b203b..7007f62c6 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -122,6 +122,16 @@ class ItemCategory(LoggedModel): return _('{category} (Add-On products)').format(category=str(name)) return str(name) + def get_category_type_display(self): + if self.is_addon: + return _('Add-On products') + else: + return None + + @property + def category_type(self): + return 'addon' if self.is_addon else 'normal' + def delete(self, *args, **kwargs): super().delete(*args, **kwargs) if self.event: diff --git a/src/pretix/control/templates/pretixcontrol/items/categories.html b/src/pretix/control/templates/pretixcontrol/items/categories.html index e51de4072..753f067a3 100644 --- a/src/pretix/control/templates/pretixcontrol/items/categories.html +++ b/src/pretix/control/templates/pretixcontrol/items/categories.html @@ -32,7 +32,6 @@ {% trans "Product categories" %} - @@ -41,18 +40,16 @@ {{ c.internal_name|default:c.name }} - - - - - - + + + + - + {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/items/discounts.html b/src/pretix/control/templates/pretixcontrol/items/discounts.html index b4622aeb9..6391c89eb 100644 --- a/src/pretix/control/templates/pretixcontrol/items/discounts.html +++ b/src/pretix/control/templates/pretixcontrol/items/discounts.html @@ -56,8 +56,7 @@ {% trans "Internal name" %} - {% trans "Products" %} - + {% trans "Products" %} @@ -102,9 +101,10 @@ {% endif %} {% endif %} - + + {% if not d.benefit_same_products %}{% trans "Condition:" %}{% endif %} {% if d.condition_all_products %} - {% trans "All" %} + {% else %} {% endif %} - + {% if not d.benefit_same_products %} + + {% trans "Applies to:" %} + + + {% endif %} + - - - + {% trans "Products" %}

{% blocktrans trimmed %} Below, you find a list of all available products. You can click on a product name to inspect and change - product details. You can also use the buttons on the right to change the order of products within a - give category. + product details. You can also use the buttons on the right to change the order of products or move + products to a different category. {% endblocktrans %}

{% if items|length == 0 %} @@ -29,7 +31,7 @@
{% csrf_token %}
- +
@@ -37,16 +39,24 @@ - - + - {% regroup items by category as cat_list %} - {% for c in cat_list %} - - {% for i in c.list %} - {% if forloop.counter0 == 0 and i.category %}{% endif %} + + {% for c, items in cat_list %} + {% if c %} + + + + {% endif %} + + {% for i in items %} + {% if forloop.counter0 == 0 and i.category %}{% endif %} - - {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/properties.html b/src/pretix/control/templates/pretixcontrol/organizers/properties.html index 321458229..7e787f66f 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/properties.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/properties.html @@ -51,9 +51,9 @@ {% endif %}
{% trans "Product name" %} {% trans "Category" %}Move{% trans "Default price" %} Edit
{{ i.category }}
+ {{ c.name }}{% if c.category_type != "normal" %} ({{ c.get_category_type_display }}){% endif %} + +
{% if not i.active %}{% endif %} @@ -92,15 +102,15 @@ {% if i.var_count %} - + {% endif %} {% if i.category.is_addon %} - {% elif i.require_bundling %} - {% elif i.hide_without_voucher %} {% endif %} {% if i.category %}{{ i.category.name }}{% endif %} - - - + + {% if i.free_price %} + + + {% endif %} + {{ i.default_price|money:request.event.currency }} + {% if i.original_price %}{{ i.original_price|money:request.event.currency }}{% endif %} + {% if i.tax_rule and i.default_price %} +
+ + {% blocktrans trimmed with rate=i.tax_rule.rate|floatformat:-2 taxname=i.tax_rule.name|default:s_taxes %} + incl. {{ rate }}% {{ taxname }} + {% endblocktrans %} + + {% endif %}
- + + + + - +
- - - + + + \d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'), re_path(r'^items/(?P\d+)/up$', item.item_move_up, name='event.items.up'), re_path(r'^items/(?P\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\d+)/$', item.reorder_items, name='event.items.reorder'), re_path(r'^items/(?P\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'), diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index 0b708310b..f64a4f13f 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -35,6 +35,7 @@ import json from collections import OrderedDict, namedtuple +from itertools import groupby from json.decoder import JSONDecodeError from django.contrib import messages @@ -113,6 +114,8 @@ class ItemList(ListView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) 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 @@ -169,7 +172,7 @@ def item_move_down(request, organizer, event, item): @transaction.atomic @event_permission_required("can_change_items") @require_http_methods(["POST"]) -def reorder_items(request, organizer, event): +def reorder_items(request, organizer, event, category): try: ids = json.loads(request.body.decode('utf-8'))['ids'] except (JSONDecodeError, KeyError, ValueError): @@ -180,23 +183,21 @@ def reorder_items(request, organizer, event): if len(input_items) != len(ids): raise Http404(_("Some of the provided object 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 objects have been selected.")) + if int(category): + target_category = request.event.categories.get(id=category) + else: + target_category = None for i in input_items: 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.save(update_fields=['position']) + i.category = target_category + i.save(update_fields=['position', 'category_id']) i.log_action( 'pretix.event.item.reordered', user=request.user, data={ 'position': i, + 'category': target_category and target_category.pk, } ) diff --git a/src/pretix/static/pretixcontrol/js/ui/dragndroplist.js b/src/pretix/static/pretixcontrol/js/ui/dragndroplist.js index 970374596..3c8249738 100644 --- a/src/pretix/static/pretixcontrol/js/ui/dragndroplist.js +++ b/src/pretix/static/pretixcontrol/js/ui/dragndroplist.js @@ -1,48 +1,78 @@ /*global $, Sortable*/ $(function () { - $("[data-dnd-url]").each(function(){ - var container = $(this), + const allContainers = $("[data-dnd-url]"); + function updateAllSortButtonStates() { + allContainers.each(function() { updateSortButtonState($(this)); }); + } + function updateSortButtonState(container) { + 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); + } + } + + let didSort = false, lastClick = 0; + allContainers.each(function(){ + const container = $(this), url = container.data("dnd-url"), handle = $(''); container.find(".dnd-container").append(handle); - if (container.find("[data-dnd-id]").length < 2) { + if (!sessionStorage.dndShowMoveButtons) { + container.find(".sortable-up, .sortable-down").addClass("sr-only").on("click", function () { + sessionStorage.dndShowMoveButtons = 'true'; + }); + } + if (container.find("[data-dnd-id]").length < 2 && !container.data("dnd-group")) { handle.addClass("disabled"); return; } - + function maybeShowSortButtons() { + if (Date.now() - lastClick < 3000) { + $("[data-dnd-url] .sortable-up, [data-dnd-url] .sortable-down").removeClass("sr-only"); + updateAllSortButtonStates(); + } + lastClick = Date.now(); + } + container.find(".dnd-sort-handle").on("mouseup", maybeShowSortButtons); + const group = container.data("dnd-group"); + const containers = group ? container.parent().find('[data-dnd-group="' + group + '"]') : container; Sortable.create(container.get(0), { filter: ".sortable-disabled", handle: ".dnd-sort-handle", + group: group, onMove: function (evt) { return evt.related.className.indexOf('sortable-disabled') === -1; }, onStart: function (evt) { - container.addClass("sortable-dragarea"); + containers.addClass("sortable-dragarea"); container.parent().addClass("sortable-sorting"); + didSort = false; }, onEnd: function (evt) { - container.removeClass("sortable-dragarea"); + containers.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); + if (!didSort) { + maybeShowSortButtons(); + } else { + $("[data-dnd-url] .sortable-up, [data-dnd-url] .sortable-down").addClass("sr-only"); + delete sessionStorage.dndShowMoveButtons; } }, onSort: function (evt){ - var container = $(evt.to), - ids = container.find("[data-dnd-id]").toArray().map(function (e) { return e.dataset.dndId; }); + if (evt.target !== evt.to) return; + didSort = true; + const ids = container.find("[data-dnd-id]").toArray().map(function (e) { return e.dataset.dndId; }); $.ajax( { 'type': 'POST', diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss index 8e587ccc6..cba73446a 100644 --- a/src/pretix/static/pretixcontrol/scss/main.scss +++ b/src/pretix/static/pretixcontrol/scss/main.scss @@ -841,9 +841,21 @@ h1 .label { tbody[data-dnd-url] { 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) { opacity: .4; } +.font-normal { + font-style: normal; + font-weight: normal; +} tbody th { background: $table-bg-hover; } diff --git a/src/tests/control/test_items.py b/src/tests/control/test_items.py index d33481a7b..b790a2866 100644 --- a/src/tests/control/test_items.py +++ b/src/tests/control/test_items.py @@ -402,6 +402,24 @@ class ItemsTest(ItemFormTest): self.item2.refresh_from_db() assert self.item1.position < self.item2.position + def test_reorder(self): + self.client.post('/control/event/%s/%s/items/reorder/0/' % (self.orga1.slug, self.event1.slug), { + 'ids': [str(self.item2.id), str(self.item1.id)], + }, content_type='application/json') + self.item1.refresh_from_db() + self.item2.refresh_from_db() + assert self.item1.position > self.item2.position + assert self.item1.category is None + assert self.item2.category is None + self.client.post('/control/event/%s/%s/items/reorder/%s/' % (self.orga1.slug, self.event1.slug, self.addoncat.id), { + 'ids': [str(self.item1.id), str(self.item2.id)], + }, content_type='application/json') + self.item1.refresh_from_db() + self.item2.refresh_from_db() + assert self.item1.position < self.item2.position + assert self.item1.category.id == self.addoncat.id + assert self.item2.category.id == self.addoncat.id + def test_create(self): self.client.post('/control/event/%s/%s/items/add' % (self.orga1.slug, self.event1.slug), { 'name_0': 'T-Shirt', diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index dbb392a8f..913b63c54 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -323,7 +323,7 @@ event_permission_urls = [ ("can_change_items", "items/add", 200, HTTP_GET), ("can_change_items", "items/1/up", 404, HTTP_POST), ("can_change_items", "items/1/down", 404, HTTP_POST), - ("can_change_items", "items/reorder", 400, HTTP_POST), + ("can_change_items", "items/reorder/2/", 400, HTTP_POST), ("can_change_items", "items/1/delete", 404, HTTP_GET), # ("can_change_items", "categories/", 200), # We don't have to create categories and similar objects