diff --git a/CLAUDE.md b/CLAUDE.md index e169088..7cbe2c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -474,7 +474,16 @@ Form section: `home-layout-settings` (`views/shop.py`, stopwords inputs. Routes (registered before `shop_slug` catch-all): -- `/s/{shop_id}/tags` — operator bulk tagger (`@shop_editor_required`) +- `/s/{shop_id}/tags` — operator bulk tagger (`@shop_editor_required`). + **Phase 2.8**: tag-focus is AJAX — `tag_bulk.js` intercepts a + `[data-tag-focus-link]` chip click and fetches `?focus=` with + `X-Requested-With`; the view returns JSON + `{focus, products:[{id,title,url,attached}]}` and the JS swaps the + `[data-focus-section]` list in place (no full reload — critical on + large catalogs; the view only loads `all_products` when + `focus_tag or show_suggestions`). Real HTML5 drag-to-reorder on the + tag rows POSTs `action=set_order&tag_slugs=…`. No-JS unchanged: the + `?focus=` link is a real navigation, server still renders the section. - `/s/{shop_id}/tag/{slug}` — public tag detail page (works without JS) Tag input on product edit: comma-separated `tags` field on diff --git a/docs/architecture.md b/docs/architecture.md index 8b84646..421bb80 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -222,6 +222,7 @@ mps_page_session (raw rows) | Facet nav on shop home (MPS-24 Phase 2.6b) | `_facet_nav.j2` macros + `home.j2` / `shop.j2` wrappers | Desktop sidebar + mobile `
` accordion when `shop.home_layout >= 1` | Opt-in via home_layout | | Mobile SERP rows under each lane (MPS-24 Phase 2.6b) | `home.j2` / `shop.j2` `.tag-lane-rows` markup + CSS visibility swap | Horizontal tiles ≥800px; vertical SERP rows with 6-sentence excerpts <800px | On for layout 2 | | Per-product SPA tag chips (MPS-24 Phase 2.7) | `product_tags` view (`/p/{id}/tags`) + `static/js/product_tags.js` + `views/__init__.py:is_ajax()` | Chip add/remove on product edit, AJAX persist, no full page reload; no-JS keeps the comma `tags` field on the main form | Always on (JS-enhanced; no-JS fallback) | +| Bulk tagger AJAX focus + DnD (MPS-24 Phase 2.8) | `shop.py:shop_tags` AJAX `?focus=` branch + `tag_bulk.js` `wireFocusLinks`/`wireDragAndDrop` | Tag chip click swaps product list in place (no reload); HTML5 drag-to-reorder POSTs `set_order`; bare GET no longer loads the whole catalog | Always on (JS-enhanced; no-JS = real navigation) | ## Ticket Index @@ -251,7 +252,7 @@ mps_page_session (raw rows) | [MPS-21](tickets/mps-21.md) | Make-an-Offer Mode | Complete | | [MPS-22](tickets/mps-22.md) | Kill-Switch Feature Flags — Karaoke + Torrent Off by Default | Complete | | [MPS-23](tickets/mps-23.md) | Consolidated Transactional Sender Identity + Shop Contact Email | Open | -| [MPS-24](tickets/mps-24.md) | Shop home page overhaul + product categorization (tags + chips + lanes + auto-suggest + per-product SPA tag chips) | In progress (Phases 1 + 2 + 2.6 + 2.7 landed) | +| [MPS-24](tickets/mps-24.md) | Shop home page overhaul + product categorization (tags + chips + lanes + auto-suggest + per-product SPA tag chips + bulk-tagger AJAX/DnD) | In progress (Phases 1 + 2 + 2.6 + 2.7 + 2.8 landed) | ## Related Docs diff --git a/docs/design-system.md b/docs/design-system.md index 40cb5b8..09a54fd 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -289,6 +289,9 @@ All components are documented with live examples at `/styleguide`. The styleguid | `.tag-chip-add` | `product_edit.j2` | Add-a-tag row, grid `1fr auto`, stacks to one column under 600px | | `.tag-chip-flash` | `product_edit.j2` | Toast region reusing `.tag-flash-toast` / `.tag-flash-{success,error,info}` | | `[data-product-tags]` / `[data-product-tags-url]` | `product_edit.j2` | JS hooks for `product_tags.js`: container + the `/p/{id}/tags` endpoint URL | +| `[data-focus-section]` / `[data-focus-heading]` / `[data-focus-list]` | `shop_tags.j2` (Phase 2.8) | Stable bulk-tagger focus container — always in the DOM, `hidden` until a tag is focused; `tag_bulk.js` swaps the product list in place instead of a full reload | +| `[data-tag-focus-link]` | `shop_tags.j2` (Phase 2.8) | Tag chip in the All-tags list; `tag_bulk.js` intercepts the click and fetches `?focus=` as JSON | +| `.tag-list-dragging` / `.tag-list-drop-target` | `shop_tags.j2` (Phase 2.8) | Dragged row (dimmed) + active drop position during HTML5 drag-to-reorder; `tag_bulk.js` POSTs `set_order` on drop | ## CSS Conventions diff --git a/docs/tickets/mps-24.md b/docs/tickets/mps-24.md index 1454a9a..3044871 100644 --- a/docs/tickets/mps-24.md +++ b/docs/tickets/mps-24.md @@ -319,6 +319,50 @@ picker is Phase 2). | `tests/test_models.py` | `TestTagSuggestPureFunctions` — 11 unit tests over tokenize / stem / cluster | | `tests/test_functional.py` | `test_suggest_clusters_renders_candidates`, `test_apply_suggestion_creates_tag_and_attaches_products`, `test_dismiss_suggestion_adds_to_stopwords`, `test_apply_suggestion_rejects_empty_input` | +### Phase 2.8 — bulk tagger: AJAX tag-focus + real drag-to-reorder + page-weight fix (shipped 2026-05-16) + +Operator (printableprompts.com, **481 products**) reported the bulk +tagger "still refreshing the whole screen" and "dragging tags doesn't +work" after 2.7. Two real defects the 2.7 static audit missed: + +1. **Tag-focus was a full-page navigation.** Clicking a tag chip is + `` — a real reload. On a 481-product catalog + the view loaded + rendered *every* product on *every* GET, so each + tag click reloaded a multi-MB page. The forms were AJAX; the + dominant workflow action (focus a tag → assign products) was not. +2. **Drag-to-reorder never existed.** `shop_tags.j2` shipped + `draggable="true"`, a ≡ handle, and "drag rows when JS is enabled" + help text, but `tag_bulk.js` had **zero** drag handlers — only the + ↑/↓ buttons worked. The affordance lied. + +Fix: + +- **View** (`shop.py:shop_tags`): `all_products` now loads only when + `focus_tag or show_suggestions` (bare GET is light). New AJAX branch: + `is_ajax + ?focus=` → JSON `{focus:{name,slug}, + products:[{id,title,url,attached}]}`. +- **Template** (`shop_tags.j2`): focus section is now a stable + `[data-focus-section]` (always in DOM, `hidden` until focused); + `?focus=` chips carry `data-tag-focus-link` + `data-tag-slug`. + No-JS unchanged: the link is a real navigation, server still renders + the section. +- **JS** (`tag_bulk.js`): `wireFocusLinks()` intercepts chip clicks → + `fetchFocus()` → `renderFocus()` swaps the product list in place, + updates the active chip, `history.pushState` (back/forward via + `popstate`), graceful real-navigation fallback. `wireDragAndDrop()` + implements HTML5 DnD on the tag rows → `persistOrder()` POSTs + `action=set_order&tag_slugs=…` (view already supported it) and + re-syncs ↑/↓ disabled states. `.tag-list-dragging` CSS added. + +Tests (`test_functional.py::TestProductTagsSpa`): +`test_ajax_focus_returns_product_list_json`, +`test_ajax_focus_unknown_slug_returns_null_focus`, +`test_ajax_set_order_persists_tag_positions`, +`test_bulk_tagger_bare_get_renders_without_products`. + +Deferred (occasional click, not the hot path): AJAX-ifying the +"Suggest categories" link — still a full navigation by design. + ### Phase 2.7 — per-product SPA tag chips on product edit (shipped 2026-05-16) Operator report: adding/removing a tag on the product edit page diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index cd97787..ee6f02b 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -1787,6 +1787,11 @@ 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%); } +li.tag-list-item.tag-list-dragging { + /* The row being dragged (MPS-24 Phase 2.8 — tag_bulk.js DnD). */ + opacity: 0.55; + cursor: grabbing; +} span.tag-list-handle { cursor: grab; diff --git a/make_post_sell/static/js/tag_bulk.js b/make_post_sell/static/js/tag_bulk.js index 3cef43b..4a338bc 100644 --- a/make_post_sell/static/js/tag_bulk.js +++ b/make_post_sell/static/js/tag_bulk.js @@ -29,6 +29,13 @@ // Event delegation isn't enough because we re-render rows; we // re-attach to any new forms after each mutation via attachAll(). document.addEventListener("submit", maybeIntercept, true); + + // MPS-24 Phase 2.8: the heavy full-page reload on this page was the + // tag-focus navigation (a plain ), not the forms. + // On a 481-product catalog every tag click reloaded the whole bulk + // tagger. Intercept it + wire real drag-to-reorder. + wireFocusLinks(); + wireDragAndDrop(); } function attachAll(root) { @@ -254,6 +261,221 @@ countEl.textContent = Math.max(0, current + delta); } + /* ----- AJAX tag focus (no full page reload) ----------------------- */ + + function wireFocusLinks() { + // Delegated so chips re-rendered after create/delete still work. + document.addEventListener("click", function (ev) { + const link = ev.target.closest("a[data-tag-focus-link]"); + if (!link) return; + // Respect new-tab / middle-click / modified clicks. + if ( + ev.button !== 0 || + ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey + ) { + return; + } + ev.preventDefault(); + fetchFocus(link.getAttribute("data-tag-slug")); + }); + + // Back/forward should restore (or clear) the focused tag. + window.addEventListener("popstate", function () { + const m = window.location.search.match(/[?&]focus=([^&]+)/); + if (m) { + fetchFocus(decodeURIComponent(m[1]), true); + } else { + renderFocus(null, { focus: null }); + } + }); + } + + async function fetchFocus(slug, skipPush) { + if (!slug) return; + const url = + window.location.pathname + "?focus=" + encodeURIComponent(slug); + const section = document.querySelector("[data-focus-section]"); + if (section) section.setAttribute("aria-busy", "true"); + try { + const res = await fetch(url, { + headers: { "X-Requested-With": "XMLHttpRequest" }, + credentials: "same-origin", + }); + if (!res.ok) throw new Error("server " + res.status); + const ct = res.headers.get("content-type") || ""; + if (ct.indexOf("application/json") === -1) { + throw new Error("non-JSON response"); + } + const data = await res.json(); + flashAll(data.messages); + renderFocus(slug, data); + if (!skipPush && window.history && window.history.pushState) { + window.history.pushState({ focus: slug }, "", url); + } + } catch (err) { + // Graceful fallback — a real navigation still works (no-JS path). + console.warn("tag_bulk: focus AJAX failed, navigating:", err); + window.location.href = url; + } finally { + if (section) section.removeAttribute("aria-busy"); + } + } + + function renderFocus(slug, data) { + const section = document.querySelector("[data-focus-section]"); + const heading = document.querySelector("[data-focus-heading]"); + const list = document.querySelector("[data-focus-list]"); + if (!section || !list) return; + + document + .querySelectorAll("a[data-tag-focus-link]") + .forEach(function (a) { + a.classList.toggle( + "tag-chip-active", + a.getAttribute("data-tag-slug") === slug + ); + }); + + if (!data || !data.focus) { + section.setAttribute("hidden", ""); + list.innerHTML = ""; + return; + } + + if (heading) { + heading.innerHTML = + "Products in «" + escapeHtml(data.focus.name) + "»"; + } + list.innerHTML = (data.products || []) + .map(function (p) { + return buildProductRowHTML(p, data.focus.slug); + }) + .join(""); + section.removeAttribute("hidden"); + attachAll(list); + section.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + // Mirrors the Jinja .tag-product-row markup + swapToggleForm()'s + // button markup so the existing onToggle()/swapToggleForm() path + // keeps working on AJAX-rendered rows. + function buildProductRowHTML(p, tagSlug) { + const attached = !!p.attached; + const nextAction = attached ? "detach" : "attach"; + const buttonClass = attached + ? "mps-button mps-button-small mps-button-green" + : "mps-button mps-button-small"; + const buttonText = attached ? "✓ Applied" : "Apply"; + return ( + '
  • ' + + '
    ' + + '' + + '' + + '' + + '" + + "
    " + + '
    ' + + escapeHtml(p.title) + "" + + "
  • " + ); + } + + /* ----- drag-to-reorder (the ≡ handle / draggable rows) ------------ */ + + function wireDragAndDrop() { + const list = document.querySelector("[data-tag-list]"); + if (!list) return; + let dragRow = null; + + list.addEventListener("dragstart", function (ev) { + const row = ev.target.closest("[data-tag-row]"); + if (!row) return; + dragRow = row; + row.classList.add("tag-list-dragging"); + ev.dataTransfer.effectAllowed = "move"; + // Firefox won't start a drag unless data is set. + try { + ev.dataTransfer.setData( + "text/plain", row.getAttribute("data-tag-slug") || "" + ); + } catch (e) {} + }); + + list.addEventListener("dragover", function (ev) { + if (!dragRow) return; + ev.preventDefault(); + ev.dataTransfer.dropEffect = "move"; + const over = ev.target.closest("[data-tag-row]"); + if (!over || over === dragRow) return; + const rect = over.getBoundingClientRect(); + const after = ev.clientY - rect.top > rect.height / 2; + clearDropTargets(list); + over.classList.add("tag-list-drop-target"); + if (after) { + if (over.nextElementSibling !== dragRow) { + list.insertBefore(dragRow, over.nextElementSibling); + } + } else if (over !== dragRow) { + list.insertBefore(dragRow, over); + } + }); + + list.addEventListener("drop", function (ev) { + if (dragRow) ev.preventDefault(); + }); + + list.addEventListener("dragend", function () { + if (!dragRow) return; + dragRow.classList.remove("tag-list-dragging"); + clearDropTargets(list); + dragRow = null; + persistOrder(list); + }); + } + + function clearDropTargets(list) { + list.querySelectorAll(".tag-list-drop-target").forEach(function (el) { + el.classList.remove("tag-list-drop-target"); + }); + } + + function persistOrder(list) { + const slugs = []; + list.querySelectorAll("[data-tag-row]").forEach(function (row) { + const s = row.getAttribute("data-tag-slug"); + if (s) slugs.push(s); + }); + if (!slugs.length) return; + const fd = new FormData(); + fd.append("action", "set_order"); + fd.append("tag_slugs", slugs.join(",")); + fetch(window.location.pathname, { + method: "POST", + body: fd, + headers: { "X-Requested-With": "XMLHttpRequest" }, + credentials: "same-origin", + }) + .then(function (res) { + if (!res.ok) throw new Error("server " + res.status); + return res.json(); + }) + .then(function (data) { + flashAll(data.messages); + syncReorderButtonStates(list); + }) + .catch(function (err) { + console.warn("tag_bulk: set_order failed, reloading:", err); + flashAll([["Could not save the new order — reloading.", "error"]]); + window.location.reload(); + }); + } + /* ----- flash / toast --------------------------------------------- */ function flashAll(messages) { diff --git a/make_post_sell/templates/shop_tags.j2 b/make_post_sell/templates/shop_tags.j2 index 31634c4..5412545 100644 --- a/make_post_sell/templates/shop_tags.j2 +++ b/make_post_sell/templates/shop_tags.j2 @@ -105,6 +105,7 @@ {{ tag.name }} {{ tag.product_count }} product{% if tag.product_count != 1 %}s{% endif %} -{% if focus_tag %} -
    -

    Products in «{{ focus_tag.name }}»

    +{# MPS-24 Phase 2.8: stable focus container — always in the DOM so + tag_bulk.js can swap the product list in place when a tag chip is + clicked, instead of a full-page reload of the whole bulk tagger + (catastrophic on a 481-product catalog). No-JS still works: the + ?focus= link is a real navigation and this block server-renders. #} +
    +

    + {%- if focus_tag %}Products in «{{ focus_tag.name }}»{% endif -%} +

    Check a product to apply this tag; uncheck to remove it. Saves on click.

    -
    -{% endif %} diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index eaffe98..47ca602 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -6892,6 +6892,106 @@ class TestProductTagsSpa(_AuthenticatedBase): self.assertEqual(res.json["status"], "ok") self.assertEqual(res.json["tag"]["slug"], "geometry") + # ── Phase 2.8: AJAX tag-focus + drag set_order + page weight ────── + + def test_ajax_focus_returns_product_list_json(self): + """Clicking a tag chip must NOT reload — the view answers + ?focus= AJAX with the product list as JSON so tag_bulk.js + swaps it in place. This is the fix for 'refreshing the whole + screen' on the 481-product shop.""" + shop_id, product_id = self._make_product() + # Create a tag and attach it to the product (proven AJAX paths). + self.testapp.post( + f"/s/{shop_id}/tags", + {"action": "create", "name": "Math"}, + headers={"X-Requested-With": "XMLHttpRequest"}, status=200, + ) + self.testapp.post( + f"/p/{product_id}/tags", + {"action": "add", "name": "Math"}, + headers={"X-Requested-With": "XMLHttpRequest"}, status=200, + ) + res = self.testapp.get( + f"/s/{shop_id}/tags?focus=math", + headers={"X-Requested-With": "XMLHttpRequest"}, + status=200, + ) + self.assertIn("application/json", res.content_type) + data = res.json + self.assertEqual(data["status"], "ok") + self.assertEqual(data["focus"]["slug"], "math") + self.assertTrue(len(data["products"]) >= 1) + mine = [p for p in data["products"] if p["id"] == str(product_id)] + self.assertEqual(len(mine), 1) + self.assertTrue(mine[0]["attached"]) + self.assertIn("title", mine[0]) + self.assertIn("url", mine[0]) + + def test_ajax_focus_unknown_slug_returns_null_focus(self): + shop_id, _ = self._make_product() + res = self.testapp.get( + f"/s/{shop_id}/tags?focus=nope-not-real", + headers={"X-Requested-With": "XMLHttpRequest"}, + status=200, + ) + self.assertIn("application/json", res.content_type) + self.assertIsNone(res.json["focus"]) + self.assertEqual(res.json["products"], []) + + def test_ajax_set_order_persists_tag_positions(self): + """Drag-to-reorder POSTs action=set_order&tag_slugs=a,b,c — + must return JSON and persist Tag.position.""" + from ..models.tag import get_tag_by_shop_and_slug + shop_id, _ = self._make_product() + for name in ("Alpha", "Bravo", "Charlie"): + self.testapp.post( + f"/s/{shop_id}/tags", + {"action": "create", "name": name}, + headers={"X-Requested-With": "XMLHttpRequest"}, status=200, + ) + res = self.testapp.post( + f"/s/{shop_id}/tags", + {"action": "set_order", "tag_slugs": "charlie,alpha,bravo"}, + headers={"X-Requested-With": "XMLHttpRequest"}, + status=200, + ) + self.assertIn("application/json", res.content_type) + self.assertEqual(res.json["status"], "ok") + self.assertEqual( + res.json["ordered_slugs"], ["charlie", "alpha", "bravo"] + ) + from ..models.shop import get_shop_by_id + self.dbsession.expire_all() + shop = get_shop_by_id(self.dbsession, shop_id) + pos = { + s: get_tag_by_shop_and_slug(self.dbsession, shop, s).position + for s in ("charlie", "alpha", "bravo") + } + self.assertEqual(pos["charlie"], 0) + self.assertEqual(pos["alpha"], 1) + self.assertEqual(pos["bravo"], 2) + + def test_bulk_tagger_bare_get_renders_without_products(self): + """Phase 2.8 perf: a bare GET (no focus / no suggestions) must + not load+render the whole catalog. Page renders, focus section + is present but hidden.""" + shop_id, _ = self._make_product() + # A tag so the All-tags list (and its focus link) renders. + self.testapp.post( + f"/s/{shop_id}/tags", + {"action": "create", "name": "Math"}, + headers={"X-Requested-With": "XMLHttpRequest"}, status=200, + ) + res = self.testapp.get(f"/s/{shop_id}/tags", status=200) + body = res.body.decode() + self.assertIn("data-focus-section", body) + self.assertIn("data-tag-focus-link", body) + # The focus section ships hidden until a tag is focused. + import re + m = re.search(r'data-focus-section[^>]*>', body) + self.assertIsNotNone(m) + self.assertIn("hidden", m.group(0)) + class TestAuctionCheckout(_AuthenticatedBase): """MPS-20: /a/{id}/checkout creates a cart linked to the auction so diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py index a7bf703..cc7991d 100644 --- a/make_post_sell/views/shop.py +++ b/make_post_sell/views/shop.py @@ -2427,15 +2427,52 @@ def shop_tags(request): if focus_slug else None ) - all_products = get_all_products_from_a_shop(shop) + show_suggestions = (request.params.get("show_suggestions") or "") == "1" + + # MPS-24 Phase 2.8: only load the (potentially huge) product catalog + # when a surface actually needs it. The bare tag list never renders + # products; on a 481-product shop, loading + rendering them on every + # GET is exactly the "refreshing the whole screen" the operator hit. + need_products = focus_tag is not None or show_suggestions + all_products = ( + get_all_products_from_a_shop(shop) if need_products else [] + ) + + # MPS-24 Phase 2.8: AJAX tag focus. tag_bulk.js intercepts a tag + # chip click and fetches ?focus= with X-Requested-With; we + # return just the product list as JSON so the JS swaps it in place + # instead of a full-page reload of the entire bulk tagger. + if _is_ajax(request) and focus_slug: + if focus_tag is None: + return _tag_ajax_response( + request, {"focus": None, "products": []} + ) + products_payload = [ + { + "id": p.uuid_str, + "title": p.title, + "url": p.absolute_url(request), + "attached": focus_tag in p.tags, + } + for p in all_products + ] + return _tag_ajax_response( + request, + { + "focus": { + "name": focus_tag.name, + "slug": focus_tag.slug, + }, + "products": products_payload, + }, + ) # MPS-24 Phase 2: compute candidate clusters on demand. We always # compute (cheap O(N × tokens) over the shop catalog), but the # template only renders the well when the operator clicks the # button (?show_suggestions=1). Power-user knobs `max_share` and # `top_n` accept URL overrides so the operator can tune without - # redeploying. - show_suggestions = (request.params.get("show_suggestions") or "") == "1" + # redeploying. (`show_suggestions` already resolved above.) suggestions = [] shop_vocab_filtered = 0 if show_suggestions: