feat: MPS-24 — operator-controlled tag order (no-JS + drag-and-drop ready)
The chip strip and lane sequence on the shop home page were locked to
tags_by_popularity (product_count desc, name asc). Fox wants shop
operators to set the order manually.
- New mps_tag.position column (Integer NOT NULL, server_default 0,
idempotent migration 7f2a91c4d810). Smaller = earlier on the home
chip strip / lanes / bulk-tagger list.
- tags_by_popularity now orders by (position asc, count desc, name
asc). Fresh shops still get the popularity ordering — every row
starts at position=0 so the next two clauses do the real work — but
once an operator reorders, position wins.
- Two new POST actions on /s/{shop_id}/tags:
action=reorder, tag_slug=<s>, direction=up|down — no-JS path,
swaps with the adjacent tag (positions normalised to dense
0..N-1 first so a swap is always meaningful).
action=set_order, tag_slugs=a,b,c,… — single-POST
"commit the whole order", for drag-and-drop. Any slug missing
from the explicit list tails the order; we never silently drop
a tag from the rendering.
- shop_tags.j2 grew up/down arrow forms per row (disabled on the
first/last) plus a drag handle marked js-only. The grid template was
bumped to seven columns (handle | chip | count | view | up | down |
delete).
- tag_bulk.js gains onReorder: the form is intercepted via the
existing maybeIntercept submit-capture; on a successful AJAX reorder
we swap the row in the DOM and re-disable the up/down on whichever
row is now first/last — no full page reload.
Tests (TestHomeLayoutAndTags): up, down, edge-no-op, set_order
drag-and-drop, and a render check confirming the new order surfaces on
the home chip strip. 28 in class pass (5 new).
This commit is contained in:
parent
dc79d13358
commit
18a8566023
7 changed files with 330 additions and 6 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import uuid
|
||||
|
||||
from sqlalchemy import Column, BigInteger, Unicode, UniqueConstraint, func
|
||||
from sqlalchemy import Column, BigInteger, Integer, Unicode, UniqueConstraint, func
|
||||
|
||||
from slugify import slugify
|
||||
|
||||
|
|
@ -28,6 +28,11 @@ class Tag(RBase, Base):
|
|||
name = Column(Unicode(64), nullable=False)
|
||||
slug = Column(Unicode(80), nullable=False)
|
||||
created_timestamp = Column(BigInteger, nullable=False)
|
||||
# Shop-operator-controlled display position for chip strips and lane
|
||||
# sections on the home page (smaller = earlier). Defaults to 0; ties
|
||||
# break on product_count desc then name asc. Reordered via the bulk
|
||||
# tagger up/down buttons (no-JS) or drag-and-drop (with JS).
|
||||
position = Column(Integer, nullable=False, server_default="0", default=0)
|
||||
|
||||
shop = relationship(
|
||||
argument="Shop", uselist=False, lazy="joined", back_populates="tags"
|
||||
|
|
@ -92,8 +97,14 @@ def get_or_create_tag(dbsession, shop, name):
|
|||
|
||||
def tags_by_popularity(dbsession, shop, limit=None):
|
||||
"""
|
||||
Return tags scoped to a shop, ordered by product count desc then name asc.
|
||||
Limited to `limit` rows if provided.
|
||||
Return tags scoped to a shop in display order: ``position`` first
|
||||
(shop-operator manual ordering — smaller = earlier; defaults to 0
|
||||
for every tag so the next two clauses do the real work on a fresh
|
||||
shop), then product count desc, then name asc. Limited to ``limit``
|
||||
rows if provided.
|
||||
|
||||
Name is kept for back-compat: every existing caller wants the order
|
||||
that surfaces on the home chip strip / lanes, which is exactly this.
|
||||
"""
|
||||
from .product_tag import ProductTag
|
||||
q = (
|
||||
|
|
@ -101,7 +112,11 @@ def tags_by_popularity(dbsession, shop, limit=None):
|
|||
.outerjoin(ProductTag, ProductTag.tag_id == Tag.id)
|
||||
.filter(Tag.shop_id == shop.id)
|
||||
.group_by(Tag.id)
|
||||
.order_by(func.count(ProductTag.id).desc(), Tag.name.asc())
|
||||
.order_by(
|
||||
Tag.position.asc(),
|
||||
func.count(ProductTag.id).desc(),
|
||||
Tag.name.asc(),
|
||||
)
|
||||
)
|
||||
if limit:
|
||||
q = q.limit(limit)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
"""MPS-24: add mps_tag.position for operator-controlled tag order
|
||||
|
||||
Revision ID: 7f2a91c4d810
|
||||
Revises: c792642911e2
|
||||
Create Date: 2026-05-15 13:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7f2a91c4d810'
|
||||
down_revision = 'c792642911e2'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table, column):
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
|
||||
return any(row[1] == column for row in result.fetchall())
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Add mps_tag.position (NOT NULL, default 0).
|
||||
|
||||
Smaller = earlier on the chip strip / lane sections. Existing
|
||||
tags all start at 0; tags_by_popularity() then falls through to
|
||||
product_count desc + name asc, so the on-disk order doesn't change
|
||||
for shops that don't manually reorder. Server-side default keeps
|
||||
NOT NULL safe for SQLite back-fill of existing rows."""
|
||||
if not _column_exists("mps_tag", "position"):
|
||||
op.add_column(
|
||||
"mps_tag",
|
||||
sa.Column(
|
||||
"position",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _column_exists("mps_tag", "position"):
|
||||
with op.batch_alter_table("mps_tag") as batch:
|
||||
batch.drop_column("position")
|
||||
|
|
@ -1523,13 +1523,32 @@ ul.tag-list {
|
|||
|
||||
li.tag-list-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
/* handle | chip | count | view | up | down | delete */
|
||||
grid-template-columns: auto auto 1fr auto auto auto auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3, 12px);
|
||||
padding: var(--space-2, 8px);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
li.tag-list-item.tag-list-drop-target {
|
||||
/* Active drop position during drag-and-drop reordering. */
|
||||
background: color-mix(in srgb, var(--color-green, #a3c765) 14%, var(--surface-base, #fff) 86%);
|
||||
}
|
||||
|
||||
span.tag-list-handle {
|
||||
cursor: grab;
|
||||
color: var(--text-muted, #777);
|
||||
font-size: var(--text-md, 1.1rem);
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
form.tag-list-reorder { margin: 0; }
|
||||
form.tag-list-reorder button[disabled] {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
span.tag-list-count {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
|
|
|
|||
|
|
@ -82,11 +82,45 @@
|
|||
case "delete": return onDelete(form, data);
|
||||
case "attach": return onToggle(form, data, true);
|
||||
case "detach": return onToggle(form, data, false);
|
||||
case "reorder": return onReorder(form, data);
|
||||
case "apply_suggestion": return onApplySuggestion(form, data);
|
||||
case "dismiss_suggestion": return onDismissSuggestion(form, data);
|
||||
}
|
||||
}
|
||||
|
||||
/* Up/down row swap — keeps the DOM in sync with the position swap the
|
||||
* server just performed, so the user sees the new order without a full
|
||||
* page reload. Also re-disables the ↑ on the first row and the
|
||||
* ↓ on the last row after the swap. */
|
||||
function onReorder(form, data) {
|
||||
if (!data || !data.moved) return;
|
||||
const list = document.querySelector("[data-tag-list]");
|
||||
const row = form.closest("[data-tag-row]");
|
||||
if (!list || !row) return;
|
||||
const direction = data.direction;
|
||||
if (direction === "up") {
|
||||
const prev = row.previousElementSibling;
|
||||
if (prev) list.insertBefore(row, prev);
|
||||
} else if (direction === "down") {
|
||||
const next = row.nextElementSibling;
|
||||
if (next) list.insertBefore(next, row);
|
||||
}
|
||||
syncReorderButtonStates(list);
|
||||
}
|
||||
|
||||
function syncReorderButtonStates(list) {
|
||||
const rows = list.querySelectorAll("[data-tag-row]");
|
||||
rows.forEach(function (row, i) {
|
||||
row.querySelectorAll("form.tag-list-reorder").forEach(function (f) {
|
||||
const dir = f.querySelector('input[name=direction]');
|
||||
const btn = f.querySelector("button[type=submit]");
|
||||
if (!dir || !btn) return;
|
||||
const isUp = dir.value === "up";
|
||||
btn.disabled = isUp ? i === 0 : i === rows.length - 1;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ----- handlers --------------------------------------------------- */
|
||||
|
||||
function onCreate(form, data) {
|
||||
|
|
|
|||
|
|
@ -98,9 +98,11 @@
|
|||
{% if not tags %}
|
||||
<p data-tag-empty>No tags yet. Create one above, or open a product and add a tag inline.</p>
|
||||
{% endif %}
|
||||
<p class="type-body-sm">Order controls the chip strip + lane sequence on the shop home page. Use the ↑ / ↓ buttons (or drag rows when JS is enabled) to reorder.</p>
|
||||
<ul class="tag-list" data-tag-list{% if not tags %} hidden{% endif %}>
|
||||
{% for tag in tags %}
|
||||
<li class="tag-list-item" data-tag-row data-tag-slug="{{ tag.slug }}">
|
||||
<li class="tag-list-item" data-tag-row data-tag-slug="{{ tag.slug }}" draggable="true">
|
||||
<span class="tag-list-handle js-only" data-tag-handle aria-hidden="true" title="Drag to reorder">≡</span>
|
||||
<a href="?focus={{ tag.slug }}"
|
||||
class="tag-chip{% if focus_tag and focus_tag.id == tag.id %} tag-chip-active{% endif %}"
|
||||
rel="nofollow">{{ tag.name }}</a>
|
||||
|
|
@ -108,6 +110,22 @@
|
|||
<a href="{{ request.shop.absolute_url(request) }}/tag/{{ tag.slug }}"
|
||||
class="shop-theme-link-color tag-list-view"
|
||||
rel="nofollow">view →</a>
|
||||
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-list-reorder" data-tag-form="reorder">
|
||||
<input type="hidden" name="action" value="reorder" />
|
||||
<input type="hidden" name="tag_slug" value="{{ tag.slug }}" />
|
||||
<input type="hidden" name="direction" value="up" />
|
||||
<button type="submit" class="mps-button mps-button-small"
|
||||
{% if loop.first %}disabled{% endif %}
|
||||
title="Move up">↑</button>
|
||||
</form>
|
||||
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-list-reorder" data-tag-form="reorder">
|
||||
<input type="hidden" name="action" value="reorder" />
|
||||
<input type="hidden" name="tag_slug" value="{{ tag.slug }}" />
|
||||
<input type="hidden" name="direction" value="down" />
|
||||
<button type="submit" class="mps-button mps-button-small"
|
||||
{% if loop.last %}disabled{% endif %}
|
||||
title="Move down">↓</button>
|
||||
</form>
|
||||
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-list-delete" data-tag-form="delete">
|
||||
<input type="hidden" name="action" value="delete" />
|
||||
<input type="hidden" name="tag_slug" value="{{ tag.slug }}" />
|
||||
|
|
|
|||
|
|
@ -8858,3 +8858,121 @@ class TestHomeLayoutAndTags(_AuthenticatedBase):
|
|||
self.dbsession.expire(shop)
|
||||
self.assertFalse(shop.show_price_history)
|
||||
|
||||
def _make_three_tags(self, shop_name="reorder-shop"):
|
||||
"""Returns (shop, ['math', 'art', 'science']) — three tags in
|
||||
creation order so we have something stable to swap around."""
|
||||
shop, _product = self._make_shop_with_product(shop_name)
|
||||
for name in ("Math", "Art", "Science"):
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/tags",
|
||||
{"action": "create", "name": name},
|
||||
)
|
||||
return shop
|
||||
|
||||
def test_reorder_tag_up_swaps_position(self):
|
||||
"""?action=reorder&tag_slug=…&direction=up swaps the tag with
|
||||
its predecessor in display order (position ASC → product_count
|
||||
DESC → name ASC). Fresh shop: all positions 0, ties break on
|
||||
name asc, so creation order doesn't matter — order is alphabetic
|
||||
before any reorder. Moving 'science' up swaps it with 'math'."""
|
||||
from ..models.tag import tags_by_popularity
|
||||
|
||||
shop = self._make_three_tags()
|
||||
# Default order: art, math, science (alphabetic on tie-break).
|
||||
order_before = [t.slug for t in tags_by_popularity(self.dbsession, shop)]
|
||||
self.assertEqual(order_before, ["art", "math", "science"])
|
||||
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/tags",
|
||||
{
|
||||
"action": "reorder",
|
||||
"tag_slug": "science",
|
||||
"direction": "up",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
self.dbsession.expire_all()
|
||||
order_after = [t.slug for t in tags_by_popularity(self.dbsession, shop)]
|
||||
self.assertEqual(order_after, ["art", "science", "math"])
|
||||
|
||||
def test_reorder_tag_down_swaps_position(self):
|
||||
from ..models.tag import tags_by_popularity
|
||||
shop = self._make_three_tags("reorder-down-shop")
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/tags",
|
||||
{
|
||||
"action": "reorder",
|
||||
"tag_slug": "art",
|
||||
"direction": "down",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
self.dbsession.expire_all()
|
||||
order = [t.slug for t in tags_by_popularity(self.dbsession, shop)]
|
||||
self.assertEqual(order, ["math", "art", "science"])
|
||||
|
||||
def test_reorder_first_up_and_last_down_are_no_ops(self):
|
||||
from ..models.tag import tags_by_popularity
|
||||
shop = self._make_three_tags("reorder-edge-shop")
|
||||
# 'art' is already first — moving it up does nothing.
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/tags",
|
||||
{"action": "reorder", "tag_slug": "art", "direction": "up"},
|
||||
status=302,
|
||||
)
|
||||
# 'science' is already last — moving it down does nothing.
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/tags",
|
||||
{"action": "reorder", "tag_slug": "science", "direction": "down"},
|
||||
status=302,
|
||||
)
|
||||
self.dbsession.expire_all()
|
||||
order = [t.slug for t in tags_by_popularity(self.dbsession, shop)]
|
||||
self.assertEqual(order, ["art", "math", "science"])
|
||||
|
||||
def test_set_order_drag_and_drop(self):
|
||||
"""?action=set_order&tag_slugs=… commits the full new order in
|
||||
one POST. Missing slugs (if any) tail the explicit list."""
|
||||
from ..models.tag import tags_by_popularity
|
||||
shop = self._make_three_tags("setorder-shop")
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/tags",
|
||||
{
|
||||
"action": "set_order",
|
||||
"tag_slugs": "science,math,art",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
self.dbsession.expire_all()
|
||||
order = [t.slug for t in tags_by_popularity(self.dbsession, shop)]
|
||||
self.assertEqual(order, ["science", "math", "art"])
|
||||
|
||||
def test_reorder_persists_into_home_chip_strip(self):
|
||||
"""The chip strip on shop home renders in tags_by_popularity order
|
||||
— after a reorder, the new order is what shoppers see."""
|
||||
shop = self._make_three_tags("reorder-home-shop")
|
||||
# Enable chip layout so home renders the chip strip.
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "home-layout-settings",
|
||||
"home_layout": "1",
|
||||
"submit": "Save Home Layout",
|
||||
},
|
||||
)
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/tags",
|
||||
{
|
||||
"action": "set_order",
|
||||
"tag_slugs": "science,art,math",
|
||||
},
|
||||
)
|
||||
body = self.testapp.get(f"/s/{shop.id}/{shop.slug}").body.decode()
|
||||
# Slugs appear on the chip strip in the new order. Use the
|
||||
# data-tag-slug attribute (unambiguous) and compare positions.
|
||||
idx_science = body.find('data-tag-slug="science"')
|
||||
idx_art = body.find('data-tag-slug="art"')
|
||||
idx_math = body.find('data-tag-slug="math"')
|
||||
self.assertLess(idx_science, idx_art)
|
||||
self.assertLess(idx_art, idx_math)
|
||||
|
||||
|
|
|
|||
|
|
@ -2047,6 +2047,78 @@ def shop_tags(request):
|
|||
)
|
||||
return HTTPFound(f"/s/{shop.id}/tags")
|
||||
|
||||
if action in ("reorder", "set_order"):
|
||||
# MPS-24: shop-operator reorders the chip strip / lanes order.
|
||||
# No-JS path: ?action=reorder&tag_slug=<slug>&direction=up|down.
|
||||
# JS path: ?action=set_order&tag_slugs=slug-a,slug-b,slug-c (the
|
||||
# whole new order in one POST, set by tag_bulk.js drag-and-drop).
|
||||
#
|
||||
# We normalise positions to a dense 0..N-1 first so a swap is
|
||||
# always meaningful (every tag ships with position=0 by default).
|
||||
all_tags = tags_by_popularity(request.dbsession, shop)
|
||||
for idx, t in enumerate(all_tags):
|
||||
if t.position != idx:
|
||||
t.position = idx
|
||||
request.dbsession.add(t)
|
||||
|
||||
if action == "reorder":
|
||||
tag_slug = (request.params.get("tag_slug") or "").strip().lower()
|
||||
direction = (request.params.get("direction") or "").strip().lower()
|
||||
target_idx = next(
|
||||
(i for i, t in enumerate(all_tags) if t.slug == tag_slug),
|
||||
None,
|
||||
)
|
||||
moved = False
|
||||
if target_idx is not None:
|
||||
swap_idx = None
|
||||
if direction == "up" and target_idx > 0:
|
||||
swap_idx = target_idx - 1
|
||||
elif direction == "down" and target_idx < len(all_tags) - 1:
|
||||
swap_idx = target_idx + 1
|
||||
if swap_idx is not None:
|
||||
a, b = all_tags[target_idx], all_tags[swap_idx]
|
||||
a.position, b.position = b.position, a.position
|
||||
request.dbsession.add(a)
|
||||
request.dbsession.add(b)
|
||||
moved = True
|
||||
request.session.flash(
|
||||
(
|
||||
f"Moved '{a.name}' {direction}.",
|
||||
"success",
|
||||
)
|
||||
)
|
||||
if _is_ajax(request):
|
||||
return _tag_ajax_response(
|
||||
request,
|
||||
{"tag_slug": tag_slug, "direction": direction, "moved": moved},
|
||||
)
|
||||
return HTTPFound(f"/s/{shop.id}/tags")
|
||||
|
||||
# action == "set_order"
|
||||
slugs_raw = request.params.get("tag_slugs") or ""
|
||||
ordered_slugs = [s.strip().lower() for s in slugs_raw.split(",") if s.strip()]
|
||||
slug_to_tag = {t.slug: t for t in all_tags}
|
||||
next_pos = 0
|
||||
for slug in ordered_slugs:
|
||||
tag = slug_to_tag.pop(slug, None)
|
||||
if tag is None:
|
||||
continue
|
||||
tag.position = next_pos
|
||||
request.dbsession.add(tag)
|
||||
next_pos += 1
|
||||
# Any tags the client didn't include go to the end in their
|
||||
# current order — we never silently drop tags from the ordering.
|
||||
for tag in slug_to_tag.values():
|
||||
tag.position = next_pos
|
||||
request.dbsession.add(tag)
|
||||
next_pos += 1
|
||||
request.session.flash(("Tag order saved.", "success"))
|
||||
if _is_ajax(request):
|
||||
return _tag_ajax_response(
|
||||
request, {"ordered_slugs": ordered_slugs}
|
||||
)
|
||||
return HTTPFound(f"/s/{shop.id}/tags")
|
||||
|
||||
if action == "delete":
|
||||
tag_slug = (request.params.get("tag_slug") or "").strip().lower()
|
||||
tag = get_tag_by_shop_and_slug(request.dbsession, shop, tag_slug)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue