feat(frontend): Export status-filter menu on the Products page (SD-0002 §5.2, PUC-9)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 22:08:34 -07:00
parent 6f213e1f02
commit 0a85c4fef8
4 changed files with 101 additions and 7 deletions
+27 -6
View File
@@ -3,6 +3,8 @@
import { useEffect, useState } from "react";
import {
dialectLabel,
EXPORT_STATUSES,
exportUrl,
getProductsSummary,
listRuns,
type ProductsSummary,
@@ -63,12 +65,31 @@ export default function ProductsPage() {
{!empty && <span className="products__count"> · {summary.product_count.toLocaleString()}</span>}
</h1>
<div className="products__actions">
<div className="products__export">
<button type="button" className="btn-secondary" disabled title="Export arrives in a coming release">
Export
</button>
<span className="note">Export arrives in a coming release</span>
</div>
{empty ? (
<div className="products__export">
<button type="button" className="btn-secondary" disabled>
Export
</button>
<span className="note">Export arrives when you have products</span>
</div>
) : (
<details className="products__export menu">
<summary className="btn-secondary" role="button">
Export
</summary>
<ul className="menu__list">
{EXPORT_STATUSES.map((s) => (
<li key={s.value}>
{/* A real download: the browser navigates to the streamed
endpoint and saves the attachment (PUC-9). */}
<a className="menu__item" href={exportUrl(s.value)} download>
{s.label}
</a>
</li>
))}
</ul>
</details>
)}
<a className="btn-primary" href="#/products/import">
Import products
</a>
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { EXPORT_STATUSES, exportUrl } from "../../productsApi";
import { isExportEnabled } from "./exportMenu";
describe("export menu", () => {
it("disables export for an empty catalog and enables it once there are products", () => {
expect(isExportEnabled(0)).toBe(false);
expect(isExportEnabled(3)).toBe(true);
});
it("builds the four status download URLs, 'all' first", () => {
expect(EXPORT_STATUSES.map((s) => exportUrl(s.value))).toEqual([
"/api/products/export?status=all",
"/api/products/export?status=active",
"/api/products/export?status=draft",
"/api/products/export?status=archived",
]);
});
});
@@ -0,0 +1,11 @@
// Pure logic behind the Products page Export menu (SD-0002 §5.2, PUC-9). The
// status list + URL builder live in productsApi.ts (re-used by the component);
// this is the one decision the menu turns on — whether export is offered at all.
// The disclosure JSX is verified by the E2E suite, matching SLICE-2/3's pattern
// of pure-logic unit tests + E2E for screens.
// Export is offered only when the catalog has products (PUC-9: an empty catalog
// shows a disabled button + note instead).
export function isExportEnabled(count: number): boolean {
return count > 0;
}
+44 -1
View File
@@ -53,9 +53,52 @@
}
.products__count { color: var(--text-on-dark-mute); font-weight: var(--weight-medium); }
.products__actions { display: flex; gap: 12px; align-items: center; }
/* Disabled Export + its visible "coming release" caption, stacked. */
/* Disabled Export + its visible "no products yet" caption, stacked (empty catalog). */
.products__export { display: flex; flex-direction: column; gap: 4px; align-items: center; }
.products__export .note { font-size: 11.5px; }
/* Export status menu (SD-0002 §5.2 PUC-9). A native <details> disclosure so
it's keyboard-accessible with no extra JS (§6.6). Tokens align with the design
bundle (--surface-raised / --border-card / --radius-panel); the fallbacks keep
it working regardless. */
.products__export.menu {
position: relative;
display: inline-block;
}
.products__export.menu > summary {
list-style: none;
cursor: pointer;
}
.products__export.menu > summary::-webkit-details-marker {
display: none;
}
.menu__list {
position: absolute;
right: 0;
z-index: 10;
margin: 0.25rem 0 0;
padding: 0.25rem;
list-style: none;
background: var(--surface-raised, #fff);
border: 1px solid var(--border-card, #d8d3c8);
border-radius: var(--radius-panel, 8px);
box-shadow: var(--shadow-soft, 0 6px 20px rgba(0, 0, 0, 0.12));
min-width: 10rem;
}
.menu__item {
display: block;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm, 6px);
text-decoration: none;
color: var(--text-on-dark-soft, inherit);
font-family: var(--wv-font-display);
font-size: 14px;
}
.menu__item:hover,
.menu__item:focus {
background: var(--surface-raised-hi, #f3efe7);
color: var(--wv-starlight);
}
.products .btn-primary { width: auto; text-decoration: none; }
.products .empty { margin: 24px auto 0; }