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:
parent
660b577d32
commit
7c227ab467
9 changed files with 628 additions and 32 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
274
make_post_sell/static/js/tag_bulk.js
Normal file
274
make_post_sell/static/js/tag_bulk.js
Normal 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 →</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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -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 →</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" />
|
||||
|
|
|
|||
|
|
@ -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 →</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" />
|
||||
|
|
|
|||
|
|
@ -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 «{{ s.label }}» 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 →</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">✓ 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 -%}
|
||||
|
|
|
|||
|
|
@ -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 →</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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue