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 { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
dialectLabel,
|
dialectLabel,
|
||||||
|
EXPORT_STATUSES,
|
||||||
|
exportUrl,
|
||||||
getProductsSummary,
|
getProductsSummary,
|
||||||
listRuns,
|
listRuns,
|
||||||
type ProductsSummary,
|
type ProductsSummary,
|
||||||
@@ -63,12 +65,31 @@ export default function ProductsPage() {
|
|||||||
{!empty && <span className="products__count"> · {summary.product_count.toLocaleString()}</span>}
|
{!empty && <span className="products__count"> · {summary.product_count.toLocaleString()}</span>}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="products__actions">
|
<div className="products__actions">
|
||||||
<div className="products__export">
|
{empty ? (
|
||||||
<button type="button" className="btn-secondary" disabled title="Export arrives in a coming release">
|
<div className="products__export">
|
||||||
Export
|
<button type="button" className="btn-secondary" disabled>
|
||||||
</button>
|
Export
|
||||||
<span className="note">Export arrives in a coming release</span>
|
</button>
|
||||||
</div>
|
<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">
|
<a className="btn-primary" href="#/products/import">
|
||||||
Import products
|
Import products
|
||||||
</a>
|
</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__count { color: var(--text-on-dark-mute); font-weight: var(--weight-medium); }
|
||||||
.products__actions { display: flex; gap: 12px; align-items: center; }
|
.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 { display: flex; flex-direction: column; gap: 4px; align-items: center; }
|
||||||
.products__export .note { font-size: 11.5px; }
|
.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 .btn-primary { width: auto; text-decoration: none; }
|
||||||
.products .empty { margin: 24px auto 0; }
|
.products .empty { margin: 24px auto 0; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user