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:
parent
48a5eb715d
commit
f591620424
16 changed files with 838 additions and 19 deletions
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:.*}")
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
215
make_post_sell/static/js/product_tags.js
Normal file
215
make_post_sell/static/js/product_tags.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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">×</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 -%}
|
||||
|
|
|
|||
|
|
@ -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> →
|
||||
<code>/p/{id}/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 — 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">×</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">×</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">×</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>
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue