fix: MPS-24 Phase 2.8 — bulk tagger AJAX tag-focus + real drag-to-reorder
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 href=?focus=slug>, and the view loaded+rendered ALL products on
EVERY GET. On a 481-product catalog every tag click reloaded a
multi-MB page. The forms were AJAX; the dominant workflow was not.
2. Drag-to-reorder never existed: shop_tags.j2 shipped draggable=true +
a handle + help text, but tag_bulk.js had ZERO drag handlers.
Fix:
- shop.py:shop_tags — all_products loads only when focus_tag or
show_suggestions (bare GET is light). New AJAX branch: is_ajax +
?focus=slug -> JSON {focus, products:[{id,title,url,attached}]}.
- shop_tags.j2 — stable [data-focus-section] (always in DOM, hidden
until focused); ?focus= chips carry data-tag-focus-link. No-JS
unchanged (real navigation, server renders the section).
- tag_bulk.js — wireFocusLinks() intercepts chip clicks, fetchFocus()
+ renderFocus() swap the list in place, active-chip + history
pushState/popstate, real-navigation fallback. wireDragAndDrop()
HTML5 DnD -> persistOrder() POSTs action=set_order&tag_slugs=…
(view already supported it) + re-syncs up/down disabled states.
.tag-list-dragging CSS added.
- Tests: TestProductTagsSpa +4 (ajax focus json, unknown-slug null,
set_order persists positions, bare GET no catalog). 1128 passed.
Docs: mps-24.md Phase 2.8, architecture.md, design-system.md, CLAUDE.md.
Deferred: AJAX 'Suggest categories' link (occasional click, not hot path).
This commit is contained in:
parent
f591620424
commit
155f7ff66f
9 changed files with 439 additions and 10 deletions
11
CLAUDE.md
11
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=<slug>` 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
|
||||
|
|
|
|||
|
|
@ -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 `<details>` 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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=<slug>` 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 href="?focus=slug">` — 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=<slug>` → 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 <a href="?focus=">), 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 (
|
||||
'<li class="tag-product-row" data-product-row data-product-id="' +
|
||||
escapeHtml(p.id) + '">' +
|
||||
'<form method="POST" action="' + window.location.pathname +
|
||||
'" class="tag-product-toggle" data-tag-form="' + nextAction + '">' +
|
||||
'<input type="hidden" name="action" value="' + nextAction + '" />' +
|
||||
'<input type="hidden" name="tag_slug" value="' +
|
||||
escapeHtml(tagSlug) + '" />' +
|
||||
'<input type="hidden" name="product_id" value="' +
|
||||
escapeHtml(p.id) + '" />' +
|
||||
'<button type="submit" class="' + buttonClass + '">' +
|
||||
buttonText + "</button>" +
|
||||
"</form>" +
|
||||
'<a href="' + escapeHtml(p.url) +
|
||||
'" class="shop-theme-link-color" rel="nofollow">' +
|
||||
escapeHtml(p.title) + "</a>" +
|
||||
"</li>"
|
||||
);
|
||||
}
|
||||
|
||||
/* ----- 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) {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@
|
|||
<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 %}"
|
||||
data-tag-focus-link data-tag-slug="{{ tag.slug }}"
|
||||
rel="nofollow">{{ tag.name }}</a>
|
||||
<span class="tag-list-count">{{ tag.product_count }} product{% if tag.product_count != 1 %}s{% endif %}</span>
|
||||
<a href="{{ request.shop.absolute_url(request) }}/tag/{{ tag.slug }}"
|
||||
|
|
@ -137,11 +138,18 @@
|
|||
</ul>
|
||||
</section>
|
||||
|
||||
{% if focus_tag %}
|
||||
<section class="one-column well">
|
||||
<h2 class="type-title">Products in «{{ focus_tag.name }}»</h2>
|
||||
{# 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. #}
|
||||
<section class="one-column well" data-focus-section{% if not focus_tag %} hidden{% endif %}>
|
||||
<h2 class="type-title" data-focus-heading>
|
||||
{%- if focus_tag %}Products in «{{ focus_tag.name }}»{% endif -%}
|
||||
</h2>
|
||||
<p class="type-body-sm">Check a product to apply this tag; uncheck to remove it. Saves on click.</p>
|
||||
<ul class="tag-product-list">
|
||||
<ul class="tag-product-list" data-focus-list>
|
||||
{% if focus_tag %}
|
||||
{% for product in all_products %}
|
||||
<li class="tag-product-row" data-product-row data-product-id="{{ product.id }}">
|
||||
{% if focus_tag in product.tags|list %}
|
||||
|
|
@ -162,9 +170,9 @@
|
|||
<a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color" rel="nofollow">{{ product.title }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<script src="/static/js/tag_bulk.js" defer></script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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=<slug> 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
|
||||
|
|
|
|||
|
|
@ -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=<slug> 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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue