feat: MPS-24 Phase 2.7 — per-product SPA tag chips on product edit

Operator report: adding/removing a tag on the product edit page
refreshed the whole screen. Tags lived only as a comma-separated
<input name=tags> inside the big product form, so any tag change
needed a full Save Settings POST + page reload.

- New route/view: product_tags -> /p/{id}/tags (before product_slug
  catch-all), @shop_editor_required + @trial_active_required.
  action=add (get_or_create_tag + attach) / action=remove (detach).
  AJAX (X-Requested-With) -> JSON, no reload; plain POST -> 302 back
  to edit (no-JS still works). Rebuilds discovery ring like product_edit.
- Shared is_ajax() in views/__init__.py (single source of truth;
  shop.py:_is_ajax delegates — bulk tagger behaviour unchanged).
- product_edit.j2: comma field kept as no-JS path; js-only chip
  editor added. product_tags.js reveals chips, demotes raw input to
  hidden, keeps it in lock-step so a later full Save is a no-op.
- .tag-chip-removable family in common.css (tokens-only, Grid-only,
  always-visible remove button) + /styleguide#tagchips.
- Harden tag_bulk.js: init() binds the delegated submit listener
  unconditionally (no early-return that could strand the bulk-tagger
  SPA into full reloads).
- Tests: unit (slug dedupe invariant), integration
  (TestProductTagAddRemoveIntegration), functional (TestProductTagsSpa
  incl. bulk-tagger-AJAX-returns-JSON regression guard). 1124 passed.

Docs: architecture.md, design-system.md, CLAUDE.md, mps-24.md.
This commit is contained in:
russell@unturf.com 2026-05-16 07:58:25 -04:00
parent 48a5eb715d
commit f591620424
No known key found for this signature in database
16 changed files with 838 additions and 19 deletions

View file

@ -478,7 +478,18 @@ Routes (registered before `shop_slug` catch-all):
- `/s/{shop_id}/tag/{slug}` — public tag detail page (works without JS)
Tag input on product edit: comma-separated `tags` field on
`product_edit.j2`; server slugifies, dedupes, upserts.
`product_edit.j2`; server slugifies, dedupes, upserts. **Phase 2.7**:
that field is now the no-JS fallback. With JS, `static/js/product_tags.js`
turns it into a chip editor and POSTs each add/remove to the
`product_tags` route — `/p/{product_id}/tags` (registered before the
`product_slug` catch-all), `@shop_editor_required` + `@trial_active_required`,
`action=add|remove`. AJAX (`X-Requested-With`) → JSON, no full page
reload; plain POST → 302 back to product edit. The JS keeps the hidden
`tags` field in lock-step with the chips so a later full "Save Settings"
is a no-op, never a stale revert. Capability-driven split helper:
`views/__init__.py:is_ajax(request)` (single source of truth;
`shop.py:_is_ajax` delegates to it). Removable chip component:
`.tag-chip-removable` family in `common.css` + `/styleguide#tagchips`.
Home rendering: `views/shop.py:_build_home_layout_context()` builds
`home_chips` (list of `Tag`), `home_lanes` (list of

View file

@ -221,6 +221,7 @@ mps_page_session (raw rows)
| 6-sentence SERP excerpt (MPS-24 Phase 2.6) | `Product.excerpt_sentences(n=6, max_chars=1500)` via shared `_strip_markdown()` helper | `shop_tag.j2` row description | Always on |
| 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) |
## Ticket Index
@ -250,7 +251,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) | In progress (Phases 1 + 2 landed) |
| [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) |
## Related Docs

View file

@ -284,6 +284,11 @@ All components are documented with live examples at `/styleguide`. The styleguid
| `[data-tag-slugs]` | `.serp-item` | Space-separated tag slugs the item carries; consumed by `tag_filter.js` |
| `.tag-suggest-list` / `.tag-suggest-item` | `shop_tags.j2` (Phase 2) | Suggested-cluster well rendered when operator clicks "Suggest categories from titles + descriptions"; per-cluster grid with label, sample titles, Apply / Dismiss actions |
| `.tag-suggest-actions` | `shop_tags.j2` | Apply / Dismiss button row, `grid-auto-flow: column` |
| `.product-tag-chips` / `.tag-chip-list` | `product_edit.j2` (Phase 2.7) | Per-product SPA tag editor container; `js-only`, revealed by `product_tags.js`. Chips wrap as inline-level pills (the `<ul>` is deliberately not a grid/flex parent) |
| `.tag-chip-removable` | `product_edit.j2`, `/styleguide#tagchips` | Same pill language as `.tag-chip` but an `<li>` with a label + an always-visible `.tag-chip-removable-x` remove button (no hover-only controls). `inline-grid` `auto auto` |
| `.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 |
## CSS Conventions

View file

@ -319,6 +319,51 @@ 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.7 — per-product SPA tag chips on product edit (shipped 2026-05-16)
Operator report: adding/removing a tag on the product edit page
"refreshed the whole screen". Root cause: tags lived only as a
comma-separated `<input name="tags">` *inside the big product form*, so
any tag change required a full "Save Settings" POST + page reload. (The
`/s/{id}/tags` bulk tagger was already a working SPA — separate surface.)
Fix — capability-driven, mirroring the comments/offers/bulk-tagger
pattern:
- **New route + view**: `product_tags` → `/p/{product_id}/tags`
(registered before the `product_slug` catch-all),
`@shop_editor_required` + `@trial_active_required`. `action=add`
(`name`) → `get_or_create_tag` + `product.tags.append`;
`action=remove` (`tag_slug`) → detach. AJAX (`X-Requested-With`) →
JSON `{status, messages, tag, changed}`; plain POST → flash + 302
back to product edit (no-JS still works). Rebuilds the shop
discovery ring when watch mode is on, exactly like `product_edit`.
- **Shared helper**: `views/__init__.py:is_ajax()` — single source of
truth for the capability split; `shop.py:_is_ajax` now delegates to
it (DRY; bulk tagger behaviour unchanged).
- **Template** (`product_edit.j2`): keeps the comma `tags` input as the
no-JS path; adds a `js-only` chip editor (`.product-tag-chips`).
`product_tags.js` reveals the chips, demotes the raw input to
`type=hidden` but keeps it in lock-step with the chips so a later
full "Save Settings" is a no-op, never a stale revert.
- **JS** (`static/js/product_tags.js`): fetch + `X-Requested-With`,
add via Enter/button, remove via delegated click, toast flash, JSON
content-type guard, graceful non-reloading failure.
- **CSS / styleguide**: `.tag-chip-removable` family added to
`common.css` (tokens-only, Grid-only, always-visible remove button)
+ live `/styleguide#tagchips` section + `docs/design-system.md` rows.
- **Bulk tagger hardening**: `tag_bulk.js` `init()` no longer
early-returns before binding the delegated submit listener (a latent
way the SPA could silently fall back to full reloads).
Tests:
| Layer | Cases |
|---|---|
| `test_models.py` | `test_same_name_different_case_yields_same_slug` (slug dedupe invariant the idempotent add relies on) |
| `test_integration.py` | `TestProductTagAddRemoveIntegration`: add/remove round-trip, idempotent-on-slug, remove-unknown-noop |
| `test_functional.py` | `TestProductTagsSpa`: AJAX add/remove → JSON, no-JS add → 302 + persists, idempotent re-add, non-editor 302 (no DB change), edit page renders chip editor, **bulk-tagger-AJAX-returns-JSON regression guard** |
### Phase 2.6c — fix sidebar category links falling through to shop-home catch-all (shipped 2026-05-15)
Defect from 2.6b operator review: clicking any category in the desktop

View file

@ -194,6 +194,9 @@ def includeme(config):
config.add_route("product_edit", "/p/{product_id}/edit")
config.add_route("product_edit2", "/p/{product_id}/{slug:.*}/edit")
# MPS-24: per-product SPA tag add/remove — must register before the
# product_slug catch-all so it isn't swallowed by /p/{id}/{slug}.
config.add_route("product_tags", "/p/{product_id}/tags")
# Offer entry point — must register before product_slug catch-all (MPS-21).
config.add_route("offer_open", "/p/{product_id}/offer")
config.add_route("product_slug", "/p/{product_id}/{slug:.*}")

View file

@ -1430,6 +1430,107 @@ a.tag-chip-active:hover {
background: var(--dark-button-bg-hover, #3a4658);
}
/* MPS-24: removable tag chip — per-product SPA tag editor on the
product edit page (product_tags.js). Same pill language as
a.tag-chip but an <li> with a label + an always-visible remove
button (no hover-only controls, per CLAUDE.md mobile rule).
Grid only — no flexbox. */
.product-tag-chips {
display: grid;
gap: var(--space-3, 12px);
margin: var(--space-2, 8px) 0 0 0;
}
ul.tag-chip-list {
margin: 0;
padding: 0;
list-style: none;
}
/* Chips wrap as inline-level pills; the <ul> is intentionally NOT a
grid/flex container so chips flow + wrap naturally. Spacing comes
from per-chip margin since `gap` needs a grid/flex parent. */
li.tag-chip-removable {
display: inline-grid;
grid-template-columns: auto auto;
align-items: center;
gap: var(--space-2, 8px);
margin: 0 var(--space-2, 8px) var(--space-2, 8px) 0;
padding: var(--space-1, 4px) var(--space-2, 8px) var(--space-1, 4px) var(--space-3, 12px);
border: 1px solid var(--color-border, #d1d5db);
border-radius: var(--radius-pill, 999px);
background: var(--color-surface, #fff);
color: var(--color-text, #111);
font-size: var(--type-body-sm-size, 0.875rem);
}
.tag-chip-removable-label {
display: inline-grid;
place-items: center;
}
button.tag-chip-removable-x {
display: inline-grid;
place-items: center;
min-width: 24px;
min-height: 24px;
padding: 0;
border: none;
border-radius: var(--radius-pill, 999px);
background: transparent;
color: var(--color-text-muted, #6b7280);
font-size: var(--type-body-size, 1rem);
line-height: 1;
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease;
}
button.tag-chip-removable-x:hover,
button.tag-chip-removable-x:focus-visible {
background: var(--color-danger-subtle, #fef2f2);
color: var(--color-danger-strong, #991b1b);
}
button.tag-chip-removable-x:disabled {
opacity: 0.5;
cursor: progress;
}
/* Add-a-tag row: text field + button on one line, stacking on
narrow viewports. Grid only. */
.tag-chip-add {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--space-2, 8px);
max-width: 32rem;
}
.tag-chip-add input[type="text"] {
margin: 0;
}
.tag-chip-flash {
display: grid;
gap: var(--space-2, 8px);
}
[data-theme="dark"] li.tag-chip-removable {
background: var(--dark-button-bg, #2d3748);
border-color: var(--dark-border, #475569);
color: #fff;
}
[data-theme="dark"] button.tag-chip-removable-x {
color: var(--dark-text-muted, #cbd5e1);
}
@media (max-width: 600px) {
.tag-chip-add {
grid-template-columns: 1fr;
}
}
/* Sort dropdown — shown on tag detail and filtered shop home above
the grid. Right-aligned on a single row at desktop, full width on
mobile (label above select). Grid only — no flexbox. */

View file

@ -0,0 +1,215 @@
/* MPS-24: per-product SPA tag chip editor for the product edit page.
*
* Capability-driven per CLAUDE.md. The product edit form ships a plain
* comma-separated <input name="tags"> that persists on a full "Save
* Settings" submit — that is the no-JS path and it keeps working.
*
* With JS we:
* 1. reveal the chip editor (server marked it `hidden` + .js-only),
* 2. hide the raw <input> but KEEP it in the form (so a later full
* Save submits a value that matches the chips — a no-op, never a
* stale revert),
* 3. POST every add/remove to /p/{id}/tags with X-Requested-With and
* mutate the chip in place. No full page reload on submit.
*
* Server returns JSON: {status, messages, product_id, action, changed,
* tag:{name,slug}}.
*/
(function () {
"use strict";
function init() {
const root = document.querySelector("[data-product-tags]");
if (!root) return;
const url = root.getAttribute("data-product-tags-url");
const editor = root.querySelector("[data-tag-chips]");
const list = root.querySelector("[data-tag-chip-list]");
const addInput = root.querySelector("[data-tag-add-input]");
const addButton = root.querySelector("[data-tag-add-button]");
const rawInput = root.querySelector('input[name="tags"]');
const flash = root.querySelector("[data-tag-chip-flash]");
if (!url || !editor || !list || !addInput || !rawInput) return;
// Reveal the enhanced editor; demote the raw field to a hidden
// sync target (still submitted by the main product form).
editor.removeAttribute("hidden");
rawInput.type = "hidden";
const rawLabel = root.querySelector('label[for="tags_input"]');
// Keep one visible label above the chips.
if (rawLabel) rawLabel.setAttribute("for", "");
syncRawInput(list, rawInput);
// Add — button click or Enter in the field.
if (addButton) {
addButton.addEventListener("click", function (ev) {
ev.preventDefault();
doAdd();
});
}
addInput.addEventListener("keydown", function (ev) {
if (ev.key === "Enter") {
// Don't let Enter bubble to submit the parent product form.
ev.preventDefault();
doAdd();
}
});
// Remove — event delegation so chips added later still work.
list.addEventListener("click", function (ev) {
const btn = ev.target.closest("[data-tag-remove]");
if (!btn) return;
ev.preventDefault();
const chip = btn.closest("[data-tag-chip]");
if (chip) doRemove(chip);
});
function doAdd() {
const name = (addInput.value || "").trim();
if (!name) return;
setBusy(true);
post(url, { action: "add", name: name })
.then(function (data) {
flashAll(data.messages);
if (data && data.tag) {
if (!chipBySlug(list, data.tag.slug)) {
appendChip(list, data.tag.name, data.tag.slug);
syncRawInput(list, rawInput);
}
addInput.value = "";
}
})
.catch(function (err) {
fail("Could not add that tag. Use “Save Settings” to save tags instead.", err);
})
.finally(function () {
setBusy(false);
addInput.focus();
});
}
function doRemove(chip) {
const slug = chip.getAttribute("data-tag-slug");
const removeBtn = chip.querySelector("[data-tag-remove]");
if (removeBtn) removeBtn.disabled = true;
post(url, { action: "remove", tag_slug: slug })
.then(function (data) {
flashAll(data.messages);
// Desired end-state is "not applied" — drop the chip on any
// ok response (covers already-detached / stale chip too).
chip.remove();
syncRawInput(list, rawInput);
})
.catch(function (err) {
if (removeBtn) removeBtn.disabled = false;
fail("Could not remove that tag. Use “Save Settings” to save tags instead.", err);
});
}
function setBusy(on) {
addInput.disabled = on;
if (addButton) addButton.disabled = on;
}
function fail(msg, err) {
// Never reload the page — that is the very thing we're removing.
// Surface the error; the no-JS path (Save Settings) still works.
console.warn("product_tags:", err);
flashAll([[msg, "error"]]);
}
function flashAll(messages) {
if (!flash || !messages || !messages.length) return;
messages.forEach(function (entry) {
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;
flash.appendChild(toast);
setTimeout(function () {
toast.style.opacity = "0";
setTimeout(function () { toast.remove(); }, 400);
}, 5000);
});
}
}
/* ----- fetch helper ------------------------------------------------ */
function post(url, params) {
const fd = new FormData();
Object.keys(params).forEach(function (k) { fd.append(k, params[k]); });
return fetch(url, {
method: "POST",
body: fd,
headers: { "X-Requested-With": "XMLHttpRequest" },
credentials: "same-origin",
}).then(function (res) {
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");
}
return res.json();
});
}
/* ----- DOM helpers ------------------------------------------------- */
function chipBySlug(list, slug) {
return list.querySelector(
'[data-tag-chip][data-tag-slug="' + cssEscape(slug) + '"]'
);
}
function appendChip(list, name, slug) {
const li = document.createElement("li");
li.className = "tag-chip-removable";
li.setAttribute("data-tag-chip", "");
li.setAttribute("data-tag-slug", slug);
li.innerHTML =
'<span class="tag-chip-removable-label">' + escapeHtml(name) + "</span>" +
'<button type="button" class="tag-chip-removable-x" data-tag-remove ' +
'aria-label="Remove tag ' + escapeHtml(name) + '" ' +
'title="Remove tag">×</button>';
list.appendChild(li);
}
/* Keep the no-JS field in lock-step with the chips so a subsequent
* full "Save Settings" submits the same set the server already has
* (product_edit's tag-sync block becomes a no-op, not a revert). */
function syncRawInput(list, rawInput) {
const names = [];
list.querySelectorAll("[data-tag-chip]").forEach(function (chip) {
const label = chip.querySelector(".tag-chip-removable-label");
if (label) names.push(label.textContent.trim());
});
rawInput.value = names.join(", ");
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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

@ -18,11 +18,13 @@
"use strict";
function init() {
const forms = document.querySelectorAll("form[data-tag-form]");
if (forms.length === 0) {
return;
}
forms.forEach(attach);
// Attach the delegated submit listener UNCONDITIONALLY. Previously
// this early-returned when zero data-tag-form elements existed at
// load — which silently disabled the SPA (every submit then fell
// through to a full page reload) for any state that renders no tag
// form initially or injects forms later. The listener is cheap and
// self-gates inside maybeIntercept().
document.querySelectorAll("form[data-tag-form]").forEach(attach);
// Event delegation isn't enough because we re-render rows; we
// re-attach to any new forms after each mutation via attachAll().

View file

@ -495,16 +495,54 @@
<br />
<br />
{# MPS-24: tags input — comma-separated, slugified server-side. #}
<label for="tags_input">Tags (comma-separated)</label>
<input type="text" name="tags" id="tags_input"
maxlength="512"
placeholder="e.g. math, seasonal, valentines"
value="{% for t in request.product.tags %}{{ t.name }}{% if not loop.last %}, {% endif %}{% endfor %}" />
<small class="note-text">
Tags group this product on your shop's home page. Manage all tags at
<a href="/s/{{ request.shop.id }}/tags" class="shop-theme-link-color">your tag editor</a>.
</small>
{# MPS-24: tags. Capability-driven (CLAUDE.md):
- No JS: edit the comma-separated field, persists on "Save Settings"
like every other field on this form (full page reload).
- JS on: product_tags.js builds a chip editor from this field, hides
the raw input, and POSTs each add/remove to /p/{id}/tags with
X-Requested-With so the chip mutates in place — no full page
reload. It rewrites the hidden field on every change so a later
full "Save Settings" is a no-op, never a stale revert. #}
<div class="product-tags-field"
data-product-tags
data-product-tags-url="/p/{{ product.id }}/tags">
<label for="tags_input">Tags</label>
{# JS-enhanced chip editor. base.j2 ships
<noscript>.js-only{display:none}</noscript>, so this stays hidden
without JS; product_tags.js drops `hidden` once it initialises. #}
<div class="product-tag-chips js-only" data-tag-chips hidden>
<ul class="tag-chip-list" data-tag-chip-list>
{% for t in request.product.tags %}
<li class="tag-chip-removable" data-tag-chip data-tag-slug="{{ t.slug }}">
<span class="tag-chip-removable-label">{{ t.name }}</span>
<button type="button" class="tag-chip-removable-x"
data-tag-remove
aria-label="Remove tag {{ t.name }}"
title="Remove tag">&times;</button>
</li>
{% endfor %}
</ul>
<div class="tag-chip-add">
<input type="text" data-tag-add-input maxlength="64"
placeholder="Add a tag, press Enter" autocomplete="off" />
<button type="button"
class="mps-button mps-button-small mps-button-primary"
data-tag-add-button>Add tag</button>
</div>
<div class="tag-chip-flash" data-tag-chip-flash aria-live="polite"></div>
</div>
{# No-JS fallback / sync target for the chip editor. #}
<input type="text" name="tags" id="tags_input"
maxlength="512"
placeholder="e.g. math, seasonal, valentines"
value="{% for t in request.product.tags %}{{ t.name }}{% if not loop.last %}, {% endif %}{% endfor %}" />
<small class="note-text">
Tags group this product on your shop's home page. Manage all tags at
<a href="/s/{{ request.shop.id }}/tags" class="shop-theme-link-color">your tag editor</a>.
</small>
</div>
<br />
<br />
@ -592,4 +630,6 @@
</div>
<script src="/static/js/product_tags.js" defer></script>
{%- endblock -%}

View file

@ -192,6 +192,7 @@
<a href="#cards">Product Cards</a>
<a href="#cart">Cart</a>
<a href="#comments">Comments</a>
<a href="#tagchips">Tag Chips</a>
<a href="#toggle">Toggle</a>
<a href="#ribbon">Ribbon</a>
<a href="#taskbar">Task Bar</a>
@ -1136,6 +1137,49 @@ The snippet sets inline margin-left for depth-N replies (depth * 20px).</div>
</div>
<!-- ======== TAG CHIPS ======== -->
<div class="sg-section" id="tagchips">
<div class="sg-section-title">Tag Chips</div>
<p style="max-width:600px;line-height:1.6;">
Removable tag chips for the per-product SPA tag editor on the
product edit page (<code>product_tags.js</code> &rarr;
<code>/p/&#123;id&#125;/tags</code>). Capability-driven: the no-JS
path is a comma-separated field that saves with the product form;
with JS each add/remove is an in-place AJAX call &mdash; no full
page reload. The remove button is always visible (no hover-only
controls, per the mobile rule). Grid only.
</p>
<div class="sg-demo">
<div class="product-tag-chips">
<ul class="tag-chip-list">
<li class="tag-chip-removable" data-tag-slug="math">
<span class="tag-chip-removable-label">Math</span>
<button type="button" class="tag-chip-removable-x"
aria-label="Remove tag Math" title="Remove tag">&times;</button>
</li>
<li class="tag-chip-removable" data-tag-slug="seasonal">
<span class="tag-chip-removable-label">Seasonal</span>
<button type="button" class="tag-chip-removable-x"
aria-label="Remove tag Seasonal" title="Remove tag">&times;</button>
</li>
<li class="tag-chip-removable" data-tag-slug="novel-studies">
<span class="tag-chip-removable-label">Novel Studies</span>
<button type="button" class="tag-chip-removable-x"
aria-label="Remove tag Novel Studies" title="Remove tag">&times;</button>
</li>
</ul>
<div class="tag-chip-add">
<input type="text" placeholder="Add a tag, press Enter" autocomplete="off" />
<button type="button" class="mps-button mps-button-small mps-button-primary">Add tag</button>
</div>
<div class="tag-chip-flash">
<div class="tag-flash-toast tag-flash-success">Tag 'Math' applied to 'Algebra Workbook'.</div>
</div>
</div>
</div>
</div>
<!-- ======== TOGGLE ======== -->
<div class="sg-section" id="toggle">
<div class="sg-section-title">Toggle / Details</div>

View file

@ -6750,6 +6750,149 @@ class TestPricingModeFormSection(_AuthenticatedBase):
self.assertIn(b"Offers enabled", res.body)
class TestProductTagsSpa(_AuthenticatedBase):
"""MPS-24: per-product SPA tag add/remove (/p/{id}/tags).
Capability-driven: AJAX (X-Requested-With) -> JSON, no full reload;
plain POST -> 302 back to product edit (no-JS still works). Plus a
regression guard that the bulk tagger AJAX path still returns JSON
(a non-JSON response is exactly what made the screen full-refresh)."""
def _make_product(self, owner_creds=None):
if owner_creds is None:
owner_creds = self.user1_creds
shop = self._create_shop_helper(user_creds=owner_creds)
shop_id = shop.id
from ..models.product import Product
product = Product(title="Algebra Workbook", description="...")
product.shop = shop
product.price_in_cents = 5000
product.is_physical = False
product.is_sellable = True
self.dbsession.add(product)
self.dbsession.flush()
product_id = product.uuid_str
transaction.commit()
return shop_id, product_id
def _product_tag_slugs(self, product_id):
from ..models.product import get_product_by_id
self.dbsession.expire_all()
product = get_product_by_id(self.dbsession, product_id)
return sorted(t.slug for t in product.tags)
def test_ajax_add_tag_returns_json(self):
shop_id, product_id = self._make_product()
res = self.testapp.post(
f"/p/{product_id}/tags",
{"action": "add", "name": "Math"},
headers={"X-Requested-With": "XMLHttpRequest"},
status=200,
)
self.assertIn("application/json", res.content_type)
data = res.json
self.assertEqual(data["status"], "ok")
self.assertTrue(data["changed"])
self.assertEqual(data["tag"]["slug"], "math")
self.assertEqual(data["tag"]["name"], "Math")
self.assertEqual(self._product_tag_slugs(product_id), ["math"])
def test_ajax_remove_tag_returns_json(self):
shop_id, product_id = self._make_product()
# Attach first via the AJAX add path.
self.testapp.post(
f"/p/{product_id}/tags",
{"action": "add", "name": "Seasonal"},
headers={"X-Requested-With": "XMLHttpRequest"},
status=200,
)
self.assertEqual(self._product_tag_slugs(product_id), ["seasonal"])
res = self.testapp.post(
f"/p/{product_id}/tags",
{"action": "remove", "tag_slug": "seasonal"},
headers={"X-Requested-With": "XMLHttpRequest"},
status=200,
)
self.assertIn("application/json", res.content_type)
self.assertTrue(res.json["changed"])
self.assertEqual(self._product_tag_slugs(product_id), [])
def test_non_ajax_add_tag_redirects_and_persists(self):
"""No-JS path: plain POST 302s back to edit and still saves."""
shop_id, product_id = self._make_product()
res = self.testapp.post(
f"/p/{product_id}/tags",
{"action": "add", "name": "Literacy"},
status=302,
)
self.assertIn(str(product_id), res.location)
self.assertEqual(self._product_tag_slugs(product_id), ["literacy"])
def test_ajax_add_existing_tag_is_idempotent(self):
shop_id, product_id = self._make_product()
for _ in range(2):
res = self.testapp.post(
f"/p/{product_id}/tags",
{"action": "add", "name": "Math"},
headers={"X-Requested-With": "XMLHttpRequest"},
status=200,
)
# Second add: not changed, friendly "already applied" message,
# still exactly one tag (no duplicate association).
self.assertFalse(res.json["changed"])
self.assertEqual(self._product_tag_slugs(product_id), ["math"])
def test_ajax_tag_requires_shop_editor(self):
"""A logged-in non-editor cannot mutate another shop's product
tags — decorator 302s (no JSON, no DB change)."""
shop_id, product_id = self._make_product()
# _create_shop_helper left user1 logged in; switch to a
# non-editor (must log out first — see other user-switch tests).
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
self.testapp.post(
f"/p/{product_id}/tags",
{"action": "add", "name": "Hijack"},
headers={"X-Requested-With": "XMLHttpRequest"},
status=302,
)
self.assertEqual(self._product_tag_slugs(product_id), [])
def test_product_edit_renders_chip_editor(self):
shop_id, product_id = self._make_product()
# Seed a tag so a chip renders server-side.
self.testapp.post(
f"/p/{product_id}/tags",
{"action": "add", "name": "Math"},
headers={"X-Requested-With": "XMLHttpRequest"},
status=200,
)
res = self.testapp.get(f"/p/{product_id}/edit", status=200)
body = res.body.decode()
self.assertIn("data-product-tags", body)
self.assertIn(f'/p/{product_id}/tags', body)
self.assertIn("/static/js/product_tags.js", body)
self.assertIn("tag-chip-removable", body)
# No-JS fallback field is still present + pre-filled.
self.assertIn('name="tags"', body)
self.assertIn("Math", body)
def test_bulk_tagger_ajax_returns_json_regression(self):
"""Guard the original report: the bulk tagger must answer AJAX
with JSON, never HTML/redirect — a non-JSON answer is what made
tag_bulk.js fall back to a full form submit (screen refresh)."""
shop_id, _ = self._make_product()
res = self.testapp.post(
f"/s/{shop_id}/tags",
{"action": "create", "name": "Geometry"},
headers={"X-Requested-With": "XMLHttpRequest"},
status=200,
)
self.assertIn("application/json", res.content_type)
self.assertEqual(res.json["status"], "ok")
self.assertEqual(res.json["tag"]["slug"], "geometry")
class TestAuctionCheckout(_AuthenticatedBase):
"""MPS-20: /a/{id}/checkout creates a cart linked to the auction so
/cart shows the winning bid amount as total."""

View file

@ -5563,3 +5563,84 @@ class TestOfferTickIntegration(DatabaseIntegrationTests):
offer.state = OFFER_STATE_PAID
with self.assertRaises(OfferRejected):
cancel_offer_after_accept(offer, buyer)
class TestProductTagAddRemoveIntegration(DatabaseIntegrationTests):
"""MPS-24: the per-product SPA tag endpoint (/p/{id}/tags) is thin —
it delegates to get_or_create_tag + product.tags append/remove. Pin
those model interactions (the same calls the view makes) so the SPA
add/remove can't silently regress at the ORM layer."""
def _shop_and_product(self):
user = get_or_create_user_by_email(self.dbsession, "tagger@example.com")
shop = Shop(
name="Tag Shop",
phone_number="555-555-5555",
billing_address="123 Test St",
description="A test shop",
)
shop.domain_name = "tagshop.test"
self.dbsession.add(user)
self.dbsession.add(shop)
product = Product(title="Algebra Workbook", description="...")
product.shop_id = shop.id
product.price_in_cents = 1000
product.is_physical = False
self.dbsession.add(product)
self.dbsession.flush()
return shop, product
def test_add_then_remove_tag_round_trip(self):
from ..models.tag import get_or_create_tag, get_tag_by_shop_and_slug
shop, product = self._shop_and_product()
# action=add
tag = get_or_create_tag(self.dbsession, shop, "Math")
self.assertIsNotNone(tag)
self.assertEqual(tag.slug, "math")
self.assertNotIn(tag, product.tags)
product.tags.append(tag)
self.dbsession.flush()
self.assertIn(tag, product.tags)
self.assertEqual([t.slug for t in product.tags], ["math"])
# action=remove (slug lookup, like the view)
same = get_tag_by_shop_and_slug(self.dbsession, shop, "math")
self.assertEqual(same.id, tag.id)
product.tags.remove(same)
self.dbsession.flush()
self.assertEqual(list(product.tags), [])
transaction.commit()
def test_add_is_idempotent_on_slug(self):
"""Re-adding 'math' / 'MATH' must not create a duplicate tag nor a
duplicate association — this is the 'already applied' path."""
from ..models.tag import get_or_create_tag
shop, product = self._shop_and_product()
t1 = get_or_create_tag(self.dbsession, shop, "Math")
product.tags.append(t1)
self.dbsession.flush()
t2 = get_or_create_tag(self.dbsession, shop, " MATH ")
self.assertEqual(t1.id, t2.id) # same row, not a second tag
# View guards with `tag in product.tags` before append; mirror that.
self.assertIn(t2, product.tags)
self.assertEqual([t.slug for t in product.tags], ["math"])
transaction.commit()
def test_remove_unknown_slug_is_noop(self):
from ..models.tag import get_or_create_tag, get_tag_by_shop_and_slug
shop, product = self._shop_and_product()
tag = get_or_create_tag(self.dbsession, shop, "Seasonal")
product.tags.append(tag)
self.dbsession.flush()
missing = get_tag_by_shop_and_slug(self.dbsession, shop, "does-not-exist")
self.assertIsNone(missing)
# Nothing removed — Seasonal still applied.
self.assertEqual([t.slug for t in product.tags], ["seasonal"])
transaction.commit()

View file

@ -5169,6 +5169,21 @@ class TestTagModel(unittest.TestCase):
self.assertEqual(tag.name, "Math")
self.assertEqual(tag.slug, "math")
def test_same_name_different_case_yields_same_slug(self):
"""MPS-24 product-edit chip add/remove dedupes on slug. The
idempotent `action=add` path ('already applied') and the
no-JS comma-field sync both rely on slug being case- and
whitespace-stable. Lock that invariant here (no DB)."""
from ..models.tag import Tag
shop = self._make_shop()
slugs = {
Tag(shop=shop, name="Math").slug,
Tag(shop=shop, name="math").slug,
Tag(shop=shop, name=" MATH ").slug,
Tag(shop=shop, name="MaTh").slug,
}
self.assertEqual(slugs, {"math"})
class TestTagSuggestPureFunctions(unittest.TestCase):
"""MPS-24 Phase 2: tokenizer, stemmer, clusterer — no DB."""

View file

@ -6,6 +6,17 @@ def get_referer_or_home(request):
return request.referer if request.referer is not None else "/"
def is_ajax(request):
"""True when the request was made by our progressive-enhancement JS.
Single source of truth for the capability-driven split: a plain
browser submit gets a flash + 302 redirect; an `fetch()` carrying
`X-Requested-With: XMLHttpRequest` gets JSON for in-place DOM
updates (comments, offers, bulk tagger, product-edit tag chips).
"""
return request.headers.get("X-Requested-With") == "XMLHttpRequest"
# view decorator.
def user_required(
flash_msg="You must log in to access that area.",

View file

@ -1,12 +1,14 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from pyramid.response import Response
from . import (
user_required,
shop_editor_required,
trial_active_required,
get_referer_or_home,
is_ajax,
)
from ..models.product import Product
@ -827,6 +829,104 @@ def product_edit(request):
}
@view_config(route_name="product_tags")
@shop_editor_required()
@trial_active_required()
def product_tags(request):
"""MPS-24: per-product SPA tag add/remove (capability-driven).
The product edit form keeps its no-JS comma-separated `tags` field
(handled inline in ``product_edit``). When JS is enabled,
``static/js/product_tags.js`` turns that field into a chip editor and
POSTs each add/remove here with ``X-Requested-With: XMLHttpRequest``.
We persist immediately and return JSON so the chip mutates in place —
no full page reload. A plain (no-JS) POST still works: flash + 302
back to the edit page.
``action=add`` + ``name`` → get_or_create_tag + attach
``action=remove`` + ``tag_slug`` → detach
"""
from ..models.tag import get_or_create_tag, get_tag_by_shop_and_slug
product = request.product
shop = request.shop
if product is None or shop is None or product.shop_id != shop.id:
if is_ajax(request):
return Response(
json={
"status": "error",
"messages": [["Product not found.", "error"]],
},
status=404,
content_type="application/json",
)
raise HTTPNotFound()
action = (request.params.get("action") or "").strip()
tag = None
changed = False
if action == "add":
name = (request.params.get("name") or "").strip()
tag = get_or_create_tag(request.dbsession, shop, name)
if tag is None:
request.session.flash(("That tag name is invalid.", "error"))
elif tag in product.tags:
# Idempotent: re-adding an applied tag is a no-op, not an error.
request.session.flash(
(f"Tag '{tag.name}' is already applied.", "info")
)
else:
product.tags.append(tag)
changed = True
request.session.flash(
(
f"Tag '{tag.name}' applied to '{product.title}'.",
"success",
)
)
elif action == "remove":
tag_slug = (request.params.get("tag_slug") or "").strip().lower()
tag = get_tag_by_shop_and_slug(request.dbsession, shop, tag_slug)
if tag is not None and tag in product.tags:
product.tags.remove(tag)
changed = True
request.session.flash(
(
f"Tag '{tag.name}' removed from '{product.title}'.",
"success",
)
)
elif tag is None:
request.session.flash(("That tag does not exist.", "error"))
else:
request.session.flash(("Unknown tag action.", "error"))
# Mirror product_edit: a tag change alters discovery/home grouping, so
# rebuild the shop's discovery ring when watch mode is on.
if changed and product.error_message is None:
request.dbsession.add(product)
request.dbsession.flush()
if product.shop.watch_mode_enabled:
reforge_discovery_ring_async(
product.shop.id, request.registry["dbsession_factory"]
)
if is_ajax(request):
messages = list(request.session.pop_flash())
payload = {
"status": "ok",
"messages": messages,
"product_id": product.uuid_str,
"action": action,
"changed": changed,
}
if tag is not None:
payload["tag"] = {"name": tag.name, "slug": tag.slug}
return Response(json=payload, content_type="application/json")
return HTTPFound(product.absolute_edit_url(request))
@view_config(route_name="product_inventory_edit")
@shop_editor_required()
def product_inventory_edit(request):

View file

@ -9,6 +9,7 @@ from . import (
user_required,
shop_owner_required,
shop_editor_required,
is_ajax,
)
from ..models.shop import (
@ -2148,7 +2149,8 @@ def shop_tag_detail(request):
def _is_ajax(request):
return request.headers.get("X-Requested-With") == "XMLHttpRequest"
# Back-compat alias; single source of truth lives in views/__init__.py.
return is_ajax(request)
def _tag_ajax_response(request, data=None):