SLICE-6: export & the round-trip lock — canonical serializer + streamed export (SD-0002 §7.2) #29
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user