feat: MPS-24 Phase 2.4 — SPA bulk tagger + Netflix-style lanes

Two operator-workflow improvements that compound. With Phase 1+2+2.3
shipped the categorisation works; with this phase iterating on tag
suggestions feels like one fluid screen instead of a stack of POST/
redirect cycles, and the lanes home actually looks like categorised
shelves.

SPA progressive enhancement on /s/{shop_id}/tags:

- Every form (create / delete / attach / detach / apply_suggestion /
  dismiss_suggestion) still POSTs and 302-redirects without JS — the
  no-JS user path is unchanged. With JS, static/js/tag_bulk.js
  intercepts submits, POSTs via fetch with X-Requested-With:
  XMLHttpRequest, and the server returns JSON describing what
  changed. Capability-driven per CLAUDE.md.
- New _is_ajax() + _tag_ajax_response() helpers in views/shop.py
  pop the Pyramid flash queue into the JSON payload so JS can render
  toasts (.tag-flash / .tag-flash-toast / .tag-flash-{success,error,
  info}). Falls back to a full form submit if fetch() errors.
- Template gained data-tag-form="<action>" attributes for delegation
  and data-tag-row / data-suggest-row / data-product-row hooks for
  DOM mutation. Re-attach pass on inserted rows.
- 6 new functional tests cover each AJAX action plus the no-JS
  fallback (POST without the header still 302-redirects).

Netflix-style horizontal-scroll lanes:

- .tag-lane-grid is now display: grid + grid-auto-flow: column +
  grid-auto-columns: minmax(160px, 200px) + overflow-x: auto +
  scroll-snap-type: x mandatory. Each lane is visually bounded as
  a category, tiles snap on swipe.
- Tiles drop the .serp class (the auto-fit grid was fighting the
  new horizontal flow) but keep .serp-item for hover styles.
- Thumbnails inside lane tiles use width:auto + max-width:100% +
  max-height:200px per CLAUDE.md media-sizing rule.
- Mobile (≤ 800px): tiles narrow to 140-160px, swipe-friendly.
- /styleguide updated with a 5-tile lane example so future operators
  see the new pattern.

1088 tests passing.
This commit is contained in:
russell@unturf.com 2026-05-15 12:55:36 -04:00
parent 660b577d32
commit 7c227ab467
No known key found for this signature in database
9 changed files with 628 additions and 32 deletions

View file

@ -319,6 +319,35 @@ 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.4 — SPA bulk tagger + Netflix-style lanes (shipped 2026-05-15)
Two improvements that compound for the operator workflow:
- **SPA progressive enhancement on `/s/{shop_id}/tags`**. Each form
(create / delete / attach / detach / apply_suggestion /
dismiss_suggestion) still POSTs and 302-redirects without JS, but
with JS, `static/js/tag_bulk.js` intercepts the submit, sends
`X-Requested-With: XMLHttpRequest`, and the server returns JSON
describing what changed. JS mutates the DOM in place — no full
reload while the operator iterates on suggestions, applies a
cluster, deletes a tag they don't like, repeats. Flash messages
render as toasts via the new `.tag-flash` region. Falls back to
full submit if `fetch()` errors.
- **Netflix-style horizontal-scroll lanes**. `.tag-lane-grid` is now
a horizontal-scrolling row of fixed-width tiles
(`grid-auto-flow: column; grid-auto-columns: minmax(160px, 200px);
overflow-x: auto; scroll-snap-type: x mandatory`). Each lane is
visually bounded as a category, tiles snap on swipe, mobile-friendly.
Tiles drop the `.serp` class (the old auto-fit grid layout was
fighting the new horizontal flow) but keep `.serp-item` for hover
styles. Thumbnails: `width: auto; max-width: 100%; max-height:
200px` per CLAUDE.md media-sizing rule.
- **Companion `serp-thumbnail` fix** (commit `e284f88`): `img.serp-thumbnail`
gained `width: auto; max-width: 100%; height: auto`. On
printableprompts the 1080×1080 natural thumbnails were forcing
grid cells wider than the column template, collapsing
`auto-fit, minmax(160px, 1fr)` to a one-column-per-viewport layout.
### Phase 2.3 — multi-bigram supersession + apostrophe labels + top_n 100 (shipped 2026-05-15)
Phase 2.2 surfaced real categories but left residue: `Color` (119),

View file

@ -1320,6 +1320,12 @@ div.serp-item {
padding: var(--space-1, 4px);
grid-column: span 1;
grid-row: span 1;
/* Grid items default to min-width: auto a wide img inside would
push the cell past minmax(160px, 1fr) and collapse the auto-fit
grid to one full-viewport column. Force min-width: 0 so the
child img's max-width: 100% actually clamps. Same gotcha as the
lane tiles below. */
min-width: 0;
/* The new CSS animations are off the hook. */
transition: background-color 800ms ease;
@ -1424,7 +1430,8 @@ a.tag-chip-active:hover {
background: var(--dark-button-bg-hover, #3a4658);
}
/* Sectioned-lanes layout — one lane per top tag. */
/* Sectioned-lanes layout one lane per top tag. Netflix-style
horizontal row with scroll-snap. */
section.tag-lane {
margin: 0 0 var(--space-6, 24px) 0;
}
@ -1450,6 +1457,61 @@ a.tag-lane-more:hover {
text-decoration: underline;
}
div.tag-lane-grid {
/* Horizontal scrolling row of fixed-width tiles. grid-auto-flow:
column lays children in a single row left-to-right; overflow-x
auto provides the scroll. scroll-snap-type x mandatory makes
swipe snap to tile edges. Grid-only per CLAUDE.md (no flexbox). */
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(160px, 200px);
gap: var(--space-2, 8px);
overflow-x: auto;
overflow-y: hidden;
padding: var(--space-1, 4px) 0 var(--space-2, 8px) 0;
scroll-snap-type: x mandatory;
scrollbar-width: thin;
}
div.tag-lane-tile {
/* Tiles sit inside the horizontal scroll row. The default
.serp-item rules (hover, transition, border-radius) still
apply since we kept that class on the tile element. */
scroll-snap-align: start;
/* Make the whole tile occupy its grid track. */
width: 100%;
/* CRITICAL: grid items default to min-width: auto, which lets a
large img inside push the grid track past grid-auto-columns'
max (200px). Forcing min-width: 0 makes the track respect the
cap without this, a 1500×1500 marketing thumbnail (e.g.
printableprompts "Color With Kindness") explodes its tile
across the full viewport. Same fix on min-width: 0 for the img
belt-and-suspenders so max-width: 100% can actually clamp. */
min-width: 0;
}
div.tag-lane-tile img.serp-thumbnail {
/* Per CLAUDE.md CSS Media Sizing: width auto + max-width 100% +
max-height. Never combine width: 100% with max-height
portrait thumbs leave a little whitespace, which is acceptable.
min-width: 0 lets max-width: 100% actually clamp (see the tile
rule above for the grid-item min-content gotcha). */
width: auto;
max-width: 100%;
max-height: 200px;
min-width: 0;
}
@media (max-width: 800px) {
/* Mobile: narrower tiles + breathing room on the swipe edge */
div.tag-lane-grid {
grid-auto-columns: minmax(140px, 160px);
padding-right: var(--space-4, 16px);
}
div.tag-lane-tile img.serp-thumbnail {
max-height: 160px;
}
}
/* Tag editor bulk list */
ul.tag-list {
list-style: none;

View file

@ -0,0 +1,274 @@
/* MPS-24 Phase 2.4: Single-page progressive enhancement for /s/{id}/tags.
*
* Capability-driven per CLAUDE.md: every form on the bulk tagger is a
* real <form method="POST"> that 302-redirects without JS. With JS, we
* intercept the submit, POST via fetch with X-Requested-With, and
* mutate the DOM in place. Server returns JSON describing what changed.
*
* No-JS users get the existing full-page-reload flow.
*
* Forms we handle (data-tag-form="<action>"):
* create append new tag to the All Tags list
* delete remove tag row from the All Tags list
* attach / detach toggle the per-product Apply / Applied form
* apply_suggestion remove suggestion row + add tag to All Tags
* dismiss_suggestion remove suggestion row
*/
(function () {
"use strict";
function init() {
const forms = document.querySelectorAll("form[data-tag-form]");
if (forms.length === 0) {
return;
}
forms.forEach(attach);
// 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);
}
function attachAll(root) {
(root || document).querySelectorAll("form[data-tag-form]").forEach(attach);
}
function attach(form) {
if (form.__tagBulkBound) return;
form.__tagBulkBound = true;
}
function maybeIntercept(ev) {
const form = ev.target;
if (!(form instanceof HTMLFormElement)) return;
if (!form.hasAttribute("data-tag-form")) return;
// Confirm dialog is on the delete button via inline onclick; if it
// returned false the submit never fires, so we don't need to repeat
// the check here.
ev.preventDefault();
submitForm(form);
}
async function submitForm(form) {
const action = form.getAttribute("data-tag-form");
const fd = new FormData(form);
// Disable submit button so a rapid double-click doesn't queue
// duplicate POSTs.
const submit = form.querySelector("button[type=submit]");
if (submit) submit.disabled = true;
try {
const res = await fetch(form.action, {
method: "POST",
body: fd,
headers: { "X-Requested-With": "XMLHttpRequest" },
credentials: "same-origin",
});
if (!res.ok) throw new Error("server " + res.status);
const data = await res.json();
flashAll(data.messages);
dispatch(action, form, data);
} catch (err) {
// Fall back to a normal form submit so the user isn't stranded.
console.warn("tag_bulk: AJAX failed, falling back to full submit:", err);
form.submit();
} finally {
if (submit) submit.disabled = false;
}
}
function dispatch(action, form, data) {
switch (action) {
case "create": return onCreate(form, data);
case "delete": return onDelete(form, data);
case "attach": return onToggle(form, data, true);
case "detach": return onToggle(form, data, false);
case "apply_suggestion": return onApplySuggestion(form, data);
case "dismiss_suggestion": return onDismissSuggestion(form, data);
}
}
/* ----- handlers --------------------------------------------------- */
function onCreate(form, data) {
if (!data.tag) return; // server flagged an error; flash already shown
appendTagRow(data.tag);
incTagCount(1);
const name = form.querySelector("input[name=name]");
if (name) name.value = "";
}
function onDelete(form) {
const slug = form.querySelector("input[name=tag_slug]");
if (!slug) return;
const row = document.querySelector(
"[data-tag-row][data-tag-slug=\"" + cssEscape(slug.value) + "\"]"
);
if (row) row.remove();
incTagCount(-1);
// Remove the tag from the chip strip on the product detail page if open?
// Out of scope — /s/{id}/tags is its own page.
}
function onToggle(form, data, attached) {
// Server may report attached=null if the tag/product weren't found
if (data.attached === null || data.attached === undefined) return;
const productId = form.querySelector("input[name=product_id]").value;
const tagSlug = form.querySelector("input[name=tag_slug]").value;
const row = document.querySelector(
"[data-product-row][data-product-id=\"" + cssEscape(productId) + "\"]"
);
if (!row) return;
swapToggleForm(row, tagSlug, productId, data.attached);
}
function onApplySuggestion(form, data) {
if (data.status === "error" || !data.tag) return;
// Remove the candidate row
const label = form.querySelector("input[name=label]").value;
removeSuggestRow(label);
// Append the new tag (with product_count from server) to All Tags
appendTagRow(data.tag);
incTagCount(1);
}
function onDismissSuggestion(form, data) {
const label = form.querySelector("input[name=label]").value;
removeSuggestRow(label);
}
/* ----- DOM helpers ----------------------------------------------- */
function appendTagRow(tag) {
const list = document.querySelector("[data-tag-list]");
if (!list) return;
if (list.hasAttribute("hidden")) list.removeAttribute("hidden");
const empty = document.querySelector("[data-tag-empty]");
if (empty) empty.remove();
// Don't duplicate if the operator re-applied the same tag
if (
document.querySelector(
"[data-tag-row][data-tag-slug=\"" + cssEscape(tag.slug) + "\"]"
)
) {
return;
}
const shopBase = window.location.pathname.replace(/\/tags\/?$/, "");
const li = document.createElement("li");
li.className = "tag-list-item";
li.setAttribute("data-tag-row", "");
li.setAttribute("data-tag-slug", tag.slug);
li.innerHTML =
'<a href="?focus=' + escapeHtml(tag.slug) + '" class="tag-chip" rel="nofollow">' + escapeHtml(tag.name) + '</a>' +
'<span class="tag-list-count">' + tag.product_count +
' product' + (tag.product_count === 1 ? '' : 's') + '</span>' +
'<a href="' + shopBase + '/tag/' + escapeHtml(tag.slug) + '" class="shop-theme-link-color tag-list-view" rel="nofollow">view &rarr;</a>' +
'<form method="POST" action="' + window.location.pathname + '" class="tag-list-delete" data-tag-form="delete">' +
'<input type="hidden" name="action" value="delete" />' +
'<input type="hidden" name="tag_slug" value="' + escapeHtml(tag.slug) + '" />' +
'<button type="submit" class="mps-button mps-button-small mps-button-red" ' +
'onclick="return confirm(\'Delete tag ' + escapeJs(tag.name) + '?\');">Delete</button>' +
'</form>';
list.appendChild(li);
attachAll(li);
}
function removeSuggestRow(label) {
const row = document.querySelector(
"[data-suggest-row][data-suggest-label=\"" + cssEscape(label) + "\"]"
);
if (row) row.remove();
// Update the heading count
const countEl = document.querySelector("[data-suggest-count]");
const list = document.querySelector("[data-suggest-list]");
if (countEl && list) {
countEl.textContent = list.querySelectorAll("[data-suggest-row]").length;
}
}
function swapToggleForm(row, tagSlug, productId, attached) {
// Replace the existing toggle form with the opposite-state version.
const existing = row.querySelector("form.tag-product-toggle");
if (!existing) return;
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";
const form = document.createElement("form");
form.method = "POST";
form.action = window.location.pathname;
form.className = "tag-product-toggle";
form.setAttribute("data-tag-form", nextAction);
form.innerHTML =
'<input type="hidden" name="action" value="' + nextAction + '" />' +
'<input type="hidden" name="tag_slug" value="' + escapeHtml(tagSlug) + '" />' +
'<input type="hidden" name="product_id" value="' + escapeHtml(productId) + '" />' +
'<button type="submit" class="' + buttonClass + '">' + buttonText + '</button>';
existing.replaceWith(form);
attachAll(form);
}
function incTagCount(delta) {
const countEl = document.querySelector("[data-tag-count]");
if (!countEl) return;
const current = parseInt(countEl.textContent, 10) || 0;
countEl.textContent = Math.max(0, current + delta);
}
/* ----- flash / toast --------------------------------------------- */
function flashAll(messages) {
if (!messages || messages.length === 0) return;
const region = document.querySelector("[data-tag-flash]");
if (!region) return;
messages.forEach((entry) => {
// Pyramid flash entries can be ("msg", "level") tuples or plain
// strings; both come through as either ["msg","level"] or "msg"
// in the JSON serialisation.
let text = entry;
let level = "info";
if (Array.isArray(entry) && entry.length >= 1) {
text = entry[0];
level = entry[1] || "info";
}
const toast = document.createElement("div");
toast.className = "tag-flash-toast tag-flash-" + level;
toast.textContent = text;
region.appendChild(toast);
// Auto-dismiss after 5s
setTimeout(() => {
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 400);
}, 5000);
});
}
/* ----- tiny helpers ---------------------------------------------- */
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escapeJs(s) {
return String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
function cssEscape(s) {
if (window.CSS && window.CSS.escape) return window.CSS.escape(s);
return String(s).replace(/(["\\])/g, "\\$1");
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

View file

@ -103,10 +103,10 @@
<h2 class="type-title tag-lane-title">{{ lane.tag.name }}</h2>
<a href="?tag={{ lane.tag.slug }}" class="tag-lane-more shop-theme-link-color" rel="nofollow">See all &rarr;</a>
</header>
<div class="serp tag-lane-grid">
<div class="tag-lane-grid" role="list">
{% for product in lane.products %}
{% if product.is_ready %}
<div class="serp-item">
<div class="tag-lane-tile serp-item" role="listitem">
{% if "thumbnail1" in product.extensions %}
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />

View file

@ -33,10 +33,10 @@
<h2 class="type-title tag-lane-title">{{ lane.tag.name }}</h2>
<a href="?tag={{ lane.tag.slug }}" class="tag-lane-more shop-theme-link-color" rel="nofollow">See all &rarr;</a>
</header>
<div class="serp tag-lane-grid">
<div class="tag-lane-grid" role="list">
{% for product in lane.products %}
{% if product.is_ready %}
<div class="serp-item">
<div class="tag-lane-tile serp-item" role="listitem">
{% if "thumbnail1" in product.extensions %}
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />

View file

@ -10,9 +10,14 @@
</p>
</section>
{# MPS-24 Phase 2.4: SPA flash region — JS injects toast messages here
instead of triggering a full page reload. No-JS clients see Pyramid's
own flash via base.j2 after the 302 redirect. #}
<div class="tag-flash" data-tag-flash aria-live="polite"></div>
<section class="one-column well">
<h2 class="type-title">Create a tag</h2>
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-create-form">
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-create-form" data-tag-form="create">
<input type="hidden" name="action" value="create" />
<label>
<span>Name</span>
@ -50,10 +55,10 @@
</p>
{% endif %}
{% if suggestions %}
<h3 class="type-title tag-suggest-heading">Candidate categories ({{ suggestions|length }})</h3>
<ul class="tag-suggest-list">
<h3 class="type-title tag-suggest-heading" data-suggest-heading>Candidate categories (<span data-suggest-count>{{ suggestions|length }}</span>)</h3>
<ul class="tag-suggest-list" data-suggest-list>
{% for s in suggestions %}
<li class="tag-suggest-item">
<li class="tag-suggest-item" data-suggest-row data-suggest-label="{{ s.label }}">
<div class="tag-suggest-label">
<b>{{ s.label }}</b>
<span class="tag-list-count">{{ s.product_ids|length }} product{% if s.product_ids|length != 1 %}s{% endif %}</span>
@ -64,7 +69,7 @@
{% endfor %}
</div>
<div class="tag-suggest-actions">
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-suggest-form">
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-suggest-form" data-tag-form="apply_suggestion">
<input type="hidden" name="action" value="apply_suggestion" />
<input type="hidden" name="label" value="{{ s.label }}" />
<input type="hidden" name="product_ids" value="{% for pid in s.product_ids %}{{ pid }}{% if not loop.last %},{% endif %}{% endfor %}" />
@ -72,7 +77,7 @@
Apply &laquo;{{ s.label }}&raquo; to {{ s.product_ids|length }}
</button>
</form>
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-suggest-form">
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-suggest-form" data-tag-form="dismiss_suggestion">
<input type="hidden" name="action" value="dismiss_suggestion" />
<input type="hidden" name="label" value="{{ s.label }}" />
<button type="submit" class="mps-button mps-button-small">Dismiss</button>
@ -88,14 +93,14 @@
{% endif %}
</section>
<section class="one-column">
<h2 class="type-title">All tags ({{ tags|length }})</h2>
<section class="one-column" data-tag-list-section>
<h2 class="type-title" data-tag-heading>All tags (<span data-tag-count>{{ tags|length }}</span>)</h2>
{% if not tags %}
<p>No tags yet. Create one above, or open a product and add a tag inline.</p>
{% else %}
<ul class="tag-list">
<p data-tag-empty>No tags yet. Create one above, or open a product and add a tag inline.</p>
{% endif %}
<ul class="tag-list" data-tag-list{% if not tags %} hidden{% endif %}>
{% for tag in tags %}
<li class="tag-list-item">
<li class="tag-list-item" data-tag-row data-tag-slug="{{ tag.slug }}">
<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>
@ -103,7 +108,7 @@
<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-delete">
<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 }}" />
<button type="submit" class="mps-button mps-button-small mps-button-red"
@ -112,7 +117,6 @@
</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% if focus_tag %}
@ -121,16 +125,16 @@
<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">
{% for product in all_products %}
<li class="tag-product-row">
<li class="tag-product-row" data-product-row data-product-id="{{ product.id }}">
{% if focus_tag in product.tags|list %}
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-product-toggle">
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-product-toggle" data-tag-form="detach">
<input type="hidden" name="action" value="detach" />
<input type="hidden" name="tag_slug" value="{{ focus_tag.slug }}" />
<input type="hidden" name="product_id" value="{{ product.id }}" />
<button type="submit" class="mps-button mps-button-small mps-button-green">&#10003; Applied</button>
</form>
{% else %}
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-product-toggle">
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-product-toggle" data-tag-form="attach">
<input type="hidden" name="action" value="attach" />
<input type="hidden" name="tag_slug" value="{{ focus_tag.slug }}" />
<input type="hidden" name="product_id" value="{{ product.id }}" />
@ -144,4 +148,6 @@
</section>
{% endif %}
<script src="/static/js/tag_bulk.js" defer></script>
{%- endblock -%}

View file

@ -872,30 +872,44 @@ Click filters the grid in place via static/js/tag_filter.js (no reload).</div>
</div>
<div class="sg-subsection" id="tag-lanes">
<div class="sg-label">Sectioned lanes (MPS-24, layout 2)</div>
<div class="sg-label">Sectioned lanes — Netflix-style horizontal row (MPS-24, layout 2)</div>
<div class="sg-demo">
<section class="tag-lane" style="max-width: 500px;">
<section class="tag-lane" style="max-width: 720px;">
<header class="tag-lane-header">
<h2 class="type-title tag-lane-title">Math</h2>
<a href="#" class="tag-lane-more">See all &rarr;</a>
</header>
<div class="serp tag-lane-grid">
<div class="serp-item">
<div style="background: var(--input-border, #ddd); border-radius: 4px; height: 80px;"></div>
<div class="tag-lane-grid" role="list">
<div class="tag-lane-tile serp-item" role="listitem">
<div style="background: var(--input-border, #ddd); border-radius: 4px; height: 110px;"></div>
<b><a href="#">Addition to 10</a></b><br/><a href="#">$3</a>
</div>
<div class="serp-item">
<div style="background: var(--input-border, #ddd); border-radius: 4px; height: 80px;"></div>
<div class="tag-lane-tile serp-item" role="listitem">
<div style="background: var(--input-border, #ddd); border-radius: 4px; height: 110px;"></div>
<b><a href="#">Counting to 100</a></b><br/><a href="#">$4</a>
</div>
<div class="tag-lane-tile serp-item" role="listitem">
<div style="background: var(--input-border, #ddd); border-radius: 4px; height: 110px;"></div>
<b><a href="#">Subtraction</a></b><br/><a href="#">$3</a>
</div>
<div class="tag-lane-tile serp-item" role="listitem">
<div style="background: var(--input-border, #ddd); border-radius: 4px; height: 110px;"></div>
<b><a href="#">Teen Numbers</a></b><br/><a href="#">$3</a>
</div>
<div class="tag-lane-tile serp-item" role="listitem">
<div style="background: var(--input-border, #ddd); border-radius: 4px; height: 110px;"></div>
<b><a href="#">Place Value</a></b><br/><a href="#">$4</a>
</div>
</div>
</section>
</div>
<div class="sg-code">.tag-lane — section wrapping a single category
.tag-lane-header — grid 1fr auto, title on left, "See all" on right
.tag-lane-grid — .serp grid with capped product count
.tag-lane-grid — horizontal-scroll row (grid-auto-flow: column)
.tag-lane-tile — fixed-width tile, scroll-snap-align: start
One lane per top tag (capped by shop.home_layout_tag_limit).
Cap products per lane via shop.home_layout_per_lane_limit.</div>
Cap products per lane via shop.home_layout_per_lane_limit.
Mobile: tiles narrow to 140-160px, swipe-friendly.</div>
</div>
<div class="sg-subsection">

View file

@ -8678,3 +8678,137 @@ class TestHomeLayoutAndTags(_AuthenticatedBase):
p = get_product_by_id(self.dbsession, pid)
self.assertIsNotNone(p)
self.assertEqual(list(p.tags), [])
# --- MPS-24 Phase 2.4: SPA progressive enhancement ----------------
def test_ajax_create_returns_json(self):
shop = self._create_shop_helper(
user_creds=self.user1_creds,
shop_params={**self.shop1_params, "name": "ajax-create-shop"},
)
res = self.testapp.post(
f"/s/{shop.id}/tags",
{"action": "create", "name": "Math"},
headers={"X-Requested-With": "XMLHttpRequest"},
)
self.assertEqual(res.status_int, 200)
self.assertEqual(res.content_type, "application/json")
data = res.json
self.assertEqual(data["status"], "ok")
self.assertIn("tag", data)
self.assertEqual(data["tag"]["slug"], "math")
self.assertEqual(data["tag"]["product_count"], 0)
self.assertTrue(any("Math" in str(m) for m in data["messages"]))
def test_ajax_delete_returns_json(self):
shop = self._create_shop_helper(
user_creds=self.user1_creds,
shop_params={**self.shop1_params, "name": "ajax-delete-shop"},
)
# Create then delete via AJAX
self.testapp.post(
f"/s/{shop.id}/tags",
{"action": "create", "name": "Holiday"},
headers={"X-Requested-With": "XMLHttpRequest"},
)
res = self.testapp.post(
f"/s/{shop.id}/tags",
{"action": "delete", "tag_slug": "holiday"},
headers={"X-Requested-With": "XMLHttpRequest"},
)
self.assertEqual(res.status_int, 200)
data = res.json
self.assertEqual(data["status"], "ok")
self.assertEqual(data["deleted_slug"], "holiday")
def test_ajax_attach_detach_returns_json(self):
shop, products = self._make_shop_with_products(
"ajax-attach-shop", [("Alpha", "Body.")]
)
# Create tag
self.testapp.post(
f"/s/{shop.id}/tags",
{"action": "create", "name": "Tag1"},
)
product_id = str(products[0].id)
# Attach via AJAX
res = self.testapp.post(
f"/s/{shop.id}/tags",
{
"action": "attach",
"tag_slug": "tag1",
"product_id": product_id,
},
headers={"X-Requested-With": "XMLHttpRequest"},
)
self.assertEqual(res.status_int, 200)
data = res.json
self.assertTrue(data["attached"])
self.assertEqual(data["tag_slug"], "tag1")
self.assertEqual(data["product_id"], product_id)
# Detach via AJAX
res = self.testapp.post(
f"/s/{shop.id}/tags",
{
"action": "detach",
"tag_slug": "tag1",
"product_id": product_id,
},
headers={"X-Requested-With": "XMLHttpRequest"},
)
self.assertEqual(res.status_int, 200)
data = res.json
self.assertFalse(data["attached"])
def test_ajax_apply_suggestion_returns_json(self):
shop, products = self._make_shop_with_products(
"ajax-apply-shop",
[("Alpha", "Body."), ("Beta", "Body.")],
)
product_ids = ",".join(str(p.id) for p in products)
res = self.testapp.post(
f"/s/{shop.id}/tags",
{
"action": "apply_suggestion",
"label": "Seasonal",
"product_ids": product_ids,
},
headers={"X-Requested-With": "XMLHttpRequest"},
)
self.assertEqual(res.status_int, 200)
data = res.json
self.assertEqual(data["status"], "ok")
self.assertEqual(data["applied_count"], 2)
self.assertEqual(data["tag"]["slug"], "seasonal")
self.assertEqual(data["tag"]["product_count"], 2)
def test_ajax_dismiss_suggestion_returns_json(self):
shop = self._create_shop_helper(
user_creds=self.user1_creds,
shop_params={**self.shop1_params, "name": "ajax-dismiss-shop"},
)
res = self.testapp.post(
f"/s/{shop.id}/tags",
{"action": "dismiss_suggestion", "label": "Foo Bar"},
headers={"X-Requested-With": "XMLHttpRequest"},
)
self.assertEqual(res.status_int, 200)
data = res.json
self.assertEqual(data["status"], "ok")
self.assertIn("foo", data["dismissed_tokens"])
self.assertIn("bar", data["dismissed_tokens"])
def test_non_ajax_still_redirects(self):
"""Without X-Requested-With, every action 302-redirects (no-JS flow)."""
shop = self._create_shop_helper(
user_creds=self.user1_creds,
shop_params={**self.shop1_params, "name": "no-js-shop"},
)
res = self.testapp.post(
f"/s/{shop.id}/tags",
{"action": "create", "name": "Math"},
# NO X-Requested-With
)
self.assertEqual(res.status_int, 302)

View file

@ -1974,10 +1974,34 @@ def shop_tag_detail(request):
return ctx
def _is_ajax(request):
return request.headers.get("X-Requested-With") == "XMLHttpRequest"
def _tag_ajax_response(request, data=None):
"""MPS-24 Phase 2.4: JSON response for the SPA-enhanced tag editor.
Pops Pyramid flash so the JS can render toasts; piggybacks any
per-action payload (tag, suggestion, etc.) under `data`.
"""
messages = list(request.session.pop_flash())
payload = {"status": "ok", "messages": messages}
if data:
payload.update(data)
return Response(json=payload, content_type="application/json")
@view_config(route_name="shop_tags", renderer="shop_tags.j2")
@shop_editor_required()
def shop_tags(request):
"""Bulk tag editor for shop operators (MPS-24)."""
"""Bulk tag editor for shop operators (MPS-24).
Capability-driven: every POST is a real form that 302-redirects
back to the editor for no-JS clients. With JS,
`static/js/tag_bulk.js` intercepts submits and POSTs with
`X-Requested-With: XMLHttpRequest`; the view detects the header
and returns JSON for in-place DOM updates.
"""
shop = get_shop_from_matchdict(request)
if shop is None:
raise HTTPNotFound()
@ -1989,10 +2013,24 @@ def shop_tags(request):
tag = get_or_create_tag(request.dbsession, shop, name)
if tag is None:
request.session.flash(("That tag name is invalid.", "error"))
if _is_ajax(request):
return _tag_ajax_response(request, {"status": "error"})
else:
request.session.flash(
(f"Tag '{tag.name}' is ready to use.", "success")
)
if _is_ajax(request):
return _tag_ajax_response(
request,
{
"tag": {
"id": tag.uuid_str,
"name": tag.name,
"slug": tag.slug,
"product_count": 0,
}
},
)
return HTTPFound(f"/s/{shop.id}/tags")
if action == "delete":
@ -2002,6 +2040,8 @@ def shop_tags(request):
name = tag.name
request.dbsession.delete(tag)
request.session.flash((f"Tag '{name}' deleted.", "success"))
if _is_ajax(request):
return _tag_ajax_response(request, {"deleted_slug": tag_slug})
return HTTPFound(f"/s/{shop.id}/tags")
if action in ("attach", "detach"):
@ -2015,6 +2055,7 @@ def shop_tags(request):
if product_id
else None
)
attached = None
if (
tag is not None
and product is not None
@ -2028,6 +2069,7 @@ def shop_tags(request):
"success",
)
)
attached = True
elif action == "detach" and tag in product.tags:
product.tags.remove(tag)
request.session.flash(
@ -2036,6 +2078,16 @@ def shop_tags(request):
"success",
)
)
attached = False
if _is_ajax(request):
return _tag_ajax_response(
request,
{
"tag_slug": tag_slug,
"product_id": product_id,
"attached": attached,
},
)
return HTTPFound(
f"/s/{shop.id}/tags?focus={tag_slug}"
)
@ -2045,12 +2097,14 @@ def shop_tags(request):
if action == "dismiss_suggestion":
import json as _json
label = (request.params.get("label") or "").strip()
dismissed_tokens = []
if label:
existing = list(shop.tag_stopwords)
tokens = [t.strip().lower() for t in label.split() if t.strip()]
for tok in tokens:
if tok and tok not in existing:
existing.append(tok)
dismissed_tokens.append(tok)
shop.tag_stopwords_json = _json.dumps(existing)
request.session.flash(
(
@ -2058,6 +2112,11 @@ def shop_tags(request):
"success",
)
)
if _is_ajax(request):
return _tag_ajax_response(
request,
{"label": label, "dismissed_tokens": dismissed_tokens},
)
return HTTPFound(f"/s/{shop.id}/tags?show_suggestions=1")
# MPS-24 Phase 2: one-click apply a suggested cluster — create the
@ -2072,12 +2131,16 @@ def shop_tags(request):
request.session.flash(
("Could not apply suggestion (empty label or no products).", "error")
)
if _is_ajax(request):
return _tag_ajax_response(request, {"status": "error"})
return HTTPFound(f"/s/{shop.id}/tags?show_suggestions=1")
tag = get_or_create_tag(request.dbsession, shop, label)
if tag is None:
request.session.flash(
("Could not create that tag (invalid name).", "error")
)
if _is_ajax(request):
return _tag_ajax_response(request, {"status": "error"})
return HTTPFound(f"/s/{shop.id}/tags?show_suggestions=1")
applied = 0
for product_id in product_ids:
@ -2093,6 +2156,20 @@ def shop_tags(request):
"success",
)
)
if _is_ajax(request):
return _tag_ajax_response(
request,
{
"label": label,
"tag": {
"id": tag.uuid_str,
"name": tag.name,
"slug": tag.slug,
"product_count": applied,
},
"applied_count": applied,
},
)
return HTTPFound(f"/s/{shop.id}/tags?show_suggestions=1")
# GET: render bulk tagger.