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:
russell@unturf.com 2026-05-15 14:02:43 -04:00
parent dc79d13358
commit 18a8566023
No known key found for this signature in database
7 changed files with 330 additions and 6 deletions

View file

@ -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)

View file

@ -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")

View file

@ -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);

View file

@ -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 &uarr; on the first row and the
* &darr; 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) {

View file

@ -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 &uarr; / &darr; 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">&#8801;</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 &rarr;</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">&uarr;</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">&darr;</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 }}" />

View file

@ -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)

View file

@ -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)