feat(FN-3764): add manage view with edit modal for CLI printing press plugi
FN-3764 adds a manage view and edit draft modal to the CLI printing press plugin, wiring in draft storage, wizard routes, and plugin view registry integration so users can view and edit their printing press drafts directly from the dashboard. Includes corresponding tests, documentation updates, and Fusion-Task-Id: FN-3764
This commit is contained in:
@@ -2,13 +2,18 @@
|
||||
|
||||
Bundled first-party Fusion plugin that adds a plugin-owned dashboard wizard for drafting an external service CLI definition.
|
||||
|
||||
## v1 scope (FN-3763)
|
||||
## v1 scope (FN-3763 + FN-3764)
|
||||
|
||||
- Provides one dashboard view: **Create Service CLI** (`viewId: wizard`)
|
||||
- Provides two dashboard views:
|
||||
- **Create Service CLI** (`viewId: wizard`)
|
||||
- **Manage Service CLIs** (`viewId: manage`)
|
||||
- Wizard collects service basics, HTTP transport details, endpoints, and non-OAuth credential placeholders
|
||||
- Manage view supports list/inspect/edit/regenerate/delete against saved drafts
|
||||
- Saves draft payloads to interim JSON files under:
|
||||
- `<projectRoot>/.fusion/plugins/cli-printing-press/drafts/<id>.json`
|
||||
- Success state is **draft saved** only
|
||||
- Regenerate in v1 is a stub endpoint that re-saves the draft and returns:
|
||||
- `stub: true`
|
||||
- `message: "Regenerate stub — full generation lands in FN-3765/FN-3767"`
|
||||
|
||||
## Provisional architecture assumptions (pending FN-3762/FN-3766)
|
||||
|
||||
@@ -22,12 +27,18 @@ The following choices are intentionally provisional and may be revised by archit
|
||||
## Deferred follow-ups
|
||||
|
||||
- OAuth credential flows: **FN-3762 / FN-3766**
|
||||
- Draft management views (list/inspect/edit): **FN-3764**
|
||||
- Run/test and regenerate actions: **FN-3765**
|
||||
- Run/test actions and real generator execution: **FN-3765**
|
||||
- Canonical storage migration (replace JSON stash): **FN-3766**
|
||||
- Runtime exposure/integration: **FN-3767**
|
||||
- Workflow-step exposure: **FN-3768**
|
||||
|
||||
## Frontend API target
|
||||
|
||||
The wizard posts drafts to `/api/plugins/fusion-plugin-cli-printing-press/drafts` (host-prefixed plugin route target), following the roadmap plugin routing convention.
|
||||
Plugin views call host-prefixed plugin routes under `/api/plugins/fusion-plugin-cli-printing-press/`:
|
||||
|
||||
- `POST /drafts` — save draft
|
||||
- `GET /drafts` — list summaries
|
||||
- `GET /drafts/:id` — fetch full draft
|
||||
- `PUT /drafts/:id` — update draft
|
||||
- `POST /drafts/:id/regenerate` — v1 stub regenerate response
|
||||
- `DELETE /drafts/:id` — remove draft
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
"icon": "Wand2",
|
||||
"placement": "primary",
|
||||
"order": 60
|
||||
},
|
||||
{
|
||||
"viewId": "manage",
|
||||
"label": "Manage Service CLIs",
|
||||
"componentPath": "./manage-view",
|
||||
"icon": "List",
|
||||
"placement": "primary",
|
||||
"order": 61
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"./dashboard-view": {
|
||||
"types": "./src/dashboard-view.tsx",
|
||||
"import": "./src/dashboard-view.tsx"
|
||||
},
|
||||
"./manage-view": {
|
||||
"types": "./src/manage-view.tsx",
|
||||
"import": "./src/manage-view.tsx"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { mkdtemp, readdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDraftStore } from "../storage/draft-store";
|
||||
import { createDraftStore, NotFoundError } from "../storage/draft-store";
|
||||
import type { ServiceDraft } from "../wizard/types";
|
||||
|
||||
function makeDraft(): ServiceDraft {
|
||||
@@ -22,4 +22,29 @@ describe("draft store", () => {
|
||||
await store.delete(created.id);
|
||||
expect(await store.get(created.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("updates an existing draft and replaces endpoints", async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "cli-printing-press-"));
|
||||
const store = createDraftStore({ rootDir });
|
||||
const created = await store.create(makeDraft());
|
||||
|
||||
const updated = await store.update(created.id, {
|
||||
name: "Renamed",
|
||||
endpoints: [{ id: "e2", name: "Health", method: "GET", path: "/health" }],
|
||||
});
|
||||
|
||||
expect(updated.name).toBe("Renamed");
|
||||
expect(updated.endpoints).toHaveLength(1);
|
||||
expect(updated.endpoints[0]?.id).toBe("e2");
|
||||
expect(updated.updatedAt).not.toBe(created.updatedAt);
|
||||
|
||||
const draftFiles = await readdir(join(rootDir, ".fusion", "plugins", "cli-printing-press", "drafts"));
|
||||
expect(draftFiles.some((entry) => entry.includes(".tmp-"))).toBe(false);
|
||||
});
|
||||
|
||||
it("throws NotFoundError on update for unknown ids", async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "cli-printing-press-"));
|
||||
const store = createDraftStore({ rootDir });
|
||||
await expect(store.update("missing", { name: "Nope" })).rejects.toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment jsdom
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CliPrintingPressManageView } from "../manage-view";
|
||||
import type { ServiceDraft } from "../wizard/types";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
List: () => null,
|
||||
Pencil: () => null,
|
||||
RefreshCw: () => null,
|
||||
Trash2: () => null,
|
||||
}));
|
||||
|
||||
function makeDraft(id: string, name = "Demo"): ServiceDraft {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
slug: id,
|
||||
description: "",
|
||||
baseUrl: `https://${id}.example.com`,
|
||||
transport: "http",
|
||||
endpoints: [{ id: `${id}-e1`, name: "Ping", method: "GET", path: "/ping" }],
|
||||
credential: { kind: "none" },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CliPrintingPressManageView", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders empty state", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => [] })));
|
||||
render(<CliPrintingPressManageView />);
|
||||
expect(await screen.findByText(/No saved drafts yet/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders list, detail, edit/save, regenerate, and delete", async () => {
|
||||
const draft1 = makeDraft("draft-1", "Draft One");
|
||||
const draft2 = makeDraft("draft-2", "Draft Two");
|
||||
const updated = { ...draft1, name: "Draft One Edited" };
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (method === "GET" && url.endsWith("/drafts")) {
|
||||
return { ok: true, json: async () => ([
|
||||
{ id: draft1.id, name: draft1.name, slug: draft1.slug, updatedAt: draft1.updatedAt },
|
||||
{ id: draft2.id, name: draft2.name, slug: draft2.slug, updatedAt: draft2.updatedAt },
|
||||
]) };
|
||||
}
|
||||
if (method === "GET" && url.endsWith(`/drafts/${draft1.id}`)) return { ok: true, json: async () => draft1 };
|
||||
if (method === "GET" && url.endsWith(`/drafts/${draft2.id}`)) return { ok: true, json: async () => draft2 };
|
||||
if (method === "PUT" && url.endsWith(`/drafts/${draft1.id}`)) return { ok: true, json: async () => updated };
|
||||
if (method === "POST" && url.endsWith(`/drafts/${draft1.id}/regenerate`)) {
|
||||
return { ok: true, json: async () => ({ draft: { ...updated, regeneratedAt: new Date().toISOString() }, stub: true, message: "Regenerate stub — full generation lands in FN-3765/FN-3767" }) };
|
||||
}
|
||||
if (method === "DELETE" && url.endsWith(`/drafts/${draft1.id}`)) return { ok: true, status: 204, json: async () => ({}) };
|
||||
return { ok: false, status: 404, json: async () => ({ error: "Not found" }) };
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(globalThis, "confirm").mockReturnValue(true);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CliPrintingPressManageView />);
|
||||
|
||||
expect(await screen.findByText("Draft One")).toBeTruthy();
|
||||
expect(screen.getByText("Draft Two")).toBeTruthy();
|
||||
expect(await screen.findByText(/Base URL:/)).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Edit/i }));
|
||||
await user.clear(screen.getByLabelText("Name"));
|
||||
await user.type(screen.getByLabelText("Name"), "Draft One Edited");
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining(`/drafts/${draft1.id}`), expect.objectContaining({ method: "PUT" })));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Regenerate/i }));
|
||||
expect(await screen.findByText(/Regenerate stub/)).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Delete/i }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining(`/drafts/${draft1.id}`), expect.objectContaining({ method: "DELETE" })));
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ describe("cli-printing-press plugin", () => {
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("registers the wizard dashboard view", () => {
|
||||
it("registers the wizard and manage dashboard views", () => {
|
||||
expect(plugin.dashboardViews).toEqual([
|
||||
{
|
||||
viewId: "wizard",
|
||||
@@ -23,6 +23,14 @@ describe("cli-printing-press plugin", () => {
|
||||
placement: "primary",
|
||||
order: 60,
|
||||
},
|
||||
{
|
||||
viewId: "manage",
|
||||
label: "Manage Service CLIs",
|
||||
componentPath: "./manage-view",
|
||||
icon: "List",
|
||||
placement: "primary",
|
||||
order: 61,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,23 @@ describe("wizard routes", () => {
|
||||
expect(invalidRes.status).toBe(400);
|
||||
expect((invalidRes.body as { errors: Record<string, string> }).errors.slug).toBeTruthy();
|
||||
|
||||
const putRes = await route("PUT", "/drafts/:id").handler({ params: { id }, body: { ...makeDraft(), id, name: "Renamed" } }, ctx);
|
||||
expect(putRes.status).toBe(200);
|
||||
expect((putRes.body as { name: string }).name).toBe("Renamed");
|
||||
|
||||
const invalidPutRes = await route("PUT", "/drafts/:id").handler({ params: { id }, body: { ...makeDraft(), id, baseUrl: "invalid-url" } }, ctx);
|
||||
expect(invalidPutRes.status).toBe(400);
|
||||
|
||||
const missingPutRes = await route("PUT", "/drafts/:id").handler({ params: { id: "missing" }, body: { ...makeDraft(), id: "missing" } }, ctx);
|
||||
expect(missingPutRes.status).toBe(404);
|
||||
|
||||
const regenRes = await route("POST", "/drafts/:id/regenerate").handler({ params: { id } }, ctx);
|
||||
expect(regenRes.status).toBe(200);
|
||||
expect((regenRes.body as { stub: boolean }).stub).toBe(true);
|
||||
|
||||
const missingRegenRes = await route("POST", "/drafts/:id/regenerate").handler({ params: { id: "missing" } }, ctx);
|
||||
expect(missingRegenRes.status).toBe(404);
|
||||
|
||||
const deleteRes = await route("DELETE", "/drafts/:id").handler({ params: { id } }, ctx);
|
||||
expect(deleteRes.status).toBe(204);
|
||||
});
|
||||
|
||||
@@ -20,8 +20,17 @@ const plugin = definePlugin({
|
||||
placement: "primary",
|
||||
order: 60,
|
||||
},
|
||||
{
|
||||
viewId: "manage",
|
||||
label: "Manage Service CLIs",
|
||||
componentPath: "./manage-view",
|
||||
icon: "List",
|
||||
placement: "primary",
|
||||
order: 61,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
export { CliPrintingPressWizardView } from "./dashboard-view.js";
|
||||
export { CliPrintingPressManageView } from "./manage-view.js";
|
||||
|
||||
79
plugins/fusion-plugin-cli-printing-press/src/manage-view.css
Normal file
79
plugins/fusion-plugin-cli-printing-press/src/manage-view.css
Normal file
@@ -0,0 +1,79 @@
|
||||
.cli-press-manage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.cli-press-manage-header h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cli-press-manage-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.cli-press-manage-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.cli-press-manage-row {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.cli-press-manage-row.is-selected {
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
border-color: var(--todo);
|
||||
}
|
||||
|
||||
.cli-press-manage-row-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cli-press-manage-row-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.cli-press-manage-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.cli-press-manage-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cli-press-manage-status {
|
||||
color: var(--text);
|
||||
background: var(--status-in-progress-bg);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.cli-press-manage-state {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.cli-press-manage-json-preview {
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cli-press-manage-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
120
plugins/fusion-plugin-cli-printing-press/src/manage-view.tsx
Normal file
120
plugins/fusion-plugin-cli-printing-press/src/manage-view.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
|
||||
import { List, Pencil, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { EditDraftModal } from "./manage/EditDraftModal.js";
|
||||
import { useDrafts } from "./manage/useDrafts.js";
|
||||
import type { ServiceDraft } from "./wizard/types.js";
|
||||
import "./manage-view.css";
|
||||
|
||||
// FN-3763 symbol drift note: plugin id/registry route uses "fusion-plugin-cli-printing-press"
|
||||
// and bundled registration lives in registerBundledPluginViews.ts (not pluginViewRegistry.tsx).
|
||||
export function CliPrintingPressManageView({ context: _context }: { context?: PluginDashboardViewContext }) {
|
||||
const { drafts, loading, error, refresh, getDraft, updateDraft, regenerateDraft, deleteDraft } = useDrafts();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectedDraft, setSelectedDraft] = useState<ServiceDraft | null>(null);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drafts.length) {
|
||||
setSelectedId(null);
|
||||
setSelectedDraft(null);
|
||||
return;
|
||||
}
|
||||
const activeId = selectedId && drafts.some((item) => item.id === selectedId) ? selectedId : drafts[0]?.id ?? null;
|
||||
setSelectedId(activeId);
|
||||
}, [drafts, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
void (async () => {
|
||||
try {
|
||||
setDetailError(null);
|
||||
setSelectedDraft(await getDraft(selectedId));
|
||||
} catch (err) {
|
||||
setDetailError(err instanceof Error ? err.message : "Failed to load draft details");
|
||||
}
|
||||
})();
|
||||
}, [getDraft, selectedId]);
|
||||
|
||||
const selectedListItem = useMemo(() => drafts.find((item) => item.id === selectedId) ?? null, [drafts, selectedId]);
|
||||
|
||||
async function onRegenerate() {
|
||||
if (!selectedId) return;
|
||||
try {
|
||||
const response = await regenerateDraft(selectedId);
|
||||
setSelectedDraft(response.draft);
|
||||
setStatusMessage(response.message);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setStatusMessage(err instanceof Error ? err.message : "Failed to regenerate draft");
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!selectedId) return;
|
||||
if (!globalThis.confirm("Delete this draft?")) return;
|
||||
try {
|
||||
await deleteDraft(selectedId);
|
||||
setStatusMessage("Draft removed");
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setStatusMessage(err instanceof Error ? err.message : "Failed to delete draft");
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveEditedDraft(nextDraft: ServiceDraft) {
|
||||
if (!selectedId) return;
|
||||
const saved = await updateDraft(selectedId, nextDraft);
|
||||
setSelectedDraft(saved);
|
||||
setIsEditOpen(false);
|
||||
setStatusMessage("Draft updated");
|
||||
await refresh();
|
||||
}
|
||||
|
||||
if (loading) return <section className="card cli-press-manage-state"><p>Loading drafts…</p></section>;
|
||||
if (error) return <section className="card cli-press-manage-state"><p className="form-error">{error}</p></section>;
|
||||
|
||||
return (
|
||||
<section className="cli-press-manage">
|
||||
<header className="cli-press-manage-header">
|
||||
<h2><List /> Manage Service CLIs</h2>
|
||||
</header>
|
||||
{statusMessage ? <p className="cli-press-manage-status">{statusMessage}</p> : null}
|
||||
{!drafts.length ? <div className="card"><p>No saved drafts yet. Use the Create Service CLI view to add one.</p></div> : (
|
||||
<div className="cli-press-manage-layout">
|
||||
<div className="cli-press-manage-list">
|
||||
{drafts.map((draft) => (
|
||||
<button key={draft.id} className={`card cli-press-manage-row${draft.id === selectedId ? " is-selected" : ""}`} onClick={() => setSelectedId(draft.id)}>
|
||||
<div className="cli-press-manage-row-title">{draft.name || draft.slug}</div>
|
||||
<div className="cli-press-manage-row-meta">{draft.slug} • {new Date(draft.updatedAt).toLocaleString()}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="card cli-press-manage-detail">
|
||||
{detailError ? <p className="form-error">{detailError}</p> : null}
|
||||
{selectedDraft ? (
|
||||
<>
|
||||
<h3>{selectedDraft.name}</h3>
|
||||
<p><strong>Slug:</strong> {selectedListItem?.slug}</p>
|
||||
<p><strong>Base URL:</strong> {selectedDraft.baseUrl}</p>
|
||||
<p><strong>Endpoints:</strong> {selectedDraft.endpoints.length}</p>
|
||||
<p><strong>Credentials:</strong> {selectedDraft.credential.kind}</p>
|
||||
<p><strong>Updated:</strong> {new Date(selectedDraft.updatedAt).toLocaleString()}</p>
|
||||
<div className="cli-press-manage-actions">
|
||||
<button className="btn" onClick={() => setIsEditOpen(true)}><Pencil /> Edit</button>
|
||||
<button className="btn" onClick={() => void onRegenerate()}><RefreshCw /> Regenerate</button>
|
||||
<button className="btn btn-danger" onClick={() => void onDelete()}><Trash2 /> Delete</button>
|
||||
</div>
|
||||
</>
|
||||
) : <p>Select a draft to inspect details.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isEditOpen && selectedDraft ? <EditDraftModal initialDraft={selectedDraft} onClose={() => setIsEditOpen(false)} onSave={onSaveEditedDraft} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default CliPrintingPressManageView;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { BasicsStep, CredentialsStep, EndpointsStep, TransportStep } from "../wizard/steps.js";
|
||||
import type { ServiceDraft, WizardStep } from "../wizard/types.js";
|
||||
import { validateBasics, validateCredentials, validateDraft, validateEndpoints, validateTransport } from "../wizard/validation.js";
|
||||
|
||||
const STEPS: WizardStep[] = ["basics", "transport", "endpoints", "credentials", "review"];
|
||||
|
||||
export function EditDraftModal({
|
||||
initialDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
initialDraft: ServiceDraft;
|
||||
onClose: () => void;
|
||||
onSave: (draft: ServiceDraft) => Promise<void>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<ServiceDraft>(initialDraft);
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const currentStep = STEPS[stepIndex];
|
||||
const currentValidation = useMemo(() => {
|
||||
if (currentStep === "basics") return validateBasics(draft);
|
||||
if (currentStep === "transport") return validateTransport(draft);
|
||||
if (currentStep === "endpoints") return validateEndpoints(draft);
|
||||
if (currentStep === "credentials") return validateCredentials(draft.credential);
|
||||
return { ok: true } as const;
|
||||
}, [currentStep, draft]);
|
||||
|
||||
async function saveDraft() {
|
||||
const validation = validateDraft(draft);
|
||||
if (!validation.ok) {
|
||||
setError(Object.values(validation.errors)[0] ?? "Draft validation failed");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave(draft);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save draft");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" role="dialog" aria-modal="true">
|
||||
<div className="modal modal-lg">
|
||||
<div className="modal-header">
|
||||
<h2>Edit Service CLI Draft</h2>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close edit modal">×</button>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
{currentStep === "basics" ? <BasicsStep draft={draft} onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))} /> : null}
|
||||
{currentStep === "transport" ? <TransportStep /> : null}
|
||||
{currentStep === "endpoints" ? <EndpointsStep draft={draft} onChange={(endpoints) => setDraft((current) => ({ ...current, endpoints }))} /> : null}
|
||||
{currentStep === "credentials" ? <CredentialsStep draft={draft} onChange={(credential) => setDraft((current) => ({ ...current, credential }))} /> : null}
|
||||
{currentStep === "review" ? <pre className="card cli-press-manage-json-preview">{JSON.stringify(draft, null, 2)}</pre> : null}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
<button className="btn" disabled={stepIndex === 0 || saving} onClick={() => setStepIndex((value) => Math.max(0, value - 1))}>Back</button>
|
||||
{stepIndex < STEPS.length - 1 ? (
|
||||
<button className="btn btn-primary" disabled={!currentValidation.ok || saving} onClick={() => setStepIndex((value) => Math.min(STEPS.length - 1, value + 1))}>Next</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" disabled={saving} onClick={() => void saveDraft()}>{saving ? "Saving…" : "Save"}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ServiceDraft } from "../wizard/types.js";
|
||||
|
||||
export interface DraftListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const BASE_PATH = "/api/plugins/fusion-plugin-cli-printing-press/drafts";
|
||||
|
||||
async function parseError(response: Response, fallback: string): Promise<string> {
|
||||
const body = await response.json().catch(() => ({} as { error?: string }));
|
||||
return body.error ?? fallback;
|
||||
}
|
||||
|
||||
export function useDrafts() {
|
||||
const [drafts, setDrafts] = useState<DraftListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async (signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(BASE_PATH, { signal });
|
||||
if (!response.ok) throw new Error(await parseError(response, "Failed to load drafts"));
|
||||
setDrafts(await response.json() as DraftListItem[]);
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load drafts");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void refresh(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [refresh]);
|
||||
|
||||
const getDraft = useCallback(async (id: string): Promise<ServiceDraft> => {
|
||||
const response = await fetch(`${BASE_PATH}/${id}`);
|
||||
if (!response.ok) throw new Error(await parseError(response, "Failed to load draft"));
|
||||
return await response.json() as ServiceDraft;
|
||||
}, []);
|
||||
|
||||
const updateDraft = useCallback(async (id: string, draft: ServiceDraft): Promise<ServiceDraft> => {
|
||||
const response = await fetch(`${BASE_PATH}/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(draft),
|
||||
});
|
||||
if (!response.ok) throw new Error(await parseError(response, "Failed to update draft"));
|
||||
return await response.json() as ServiceDraft;
|
||||
}, []);
|
||||
|
||||
const regenerateDraft = useCallback(async (id: string): Promise<{ draft: ServiceDraft; stub: boolean; message: string }> => {
|
||||
const response = await fetch(`${BASE_PATH}/${id}/regenerate`, { method: "POST" });
|
||||
if (!response.ok) throw new Error(await parseError(response, "Failed to regenerate draft"));
|
||||
return await response.json() as { draft: ServiceDraft; stub: boolean; message: string };
|
||||
}, []);
|
||||
|
||||
const deleteDraft = useCallback(async (id: string): Promise<void> => {
|
||||
const response = await fetch(`${BASE_PATH}/${id}`, { method: "DELETE" });
|
||||
if (!response.ok && response.status !== 204) throw new Error(await parseError(response, "Failed to delete draft"));
|
||||
}, []);
|
||||
|
||||
return { drafts, loading, error, refresh, getDraft, updateDraft, regenerateDraft, deleteDraft };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResult } from "@fusion/core";
|
||||
import { createDraftStore } from "../storage/draft-store.js";
|
||||
import { createDraftStore, NotFoundError } from "../storage/draft-store.js";
|
||||
import type { ServiceDraft } from "../wizard/types.js";
|
||||
import { validateDraft } from "../wizard/validation.js";
|
||||
|
||||
@@ -45,6 +45,39 @@ export function createCliPrintingPressRoutes(): PluginRouteDefinition[] {
|
||||
return draft ? ok(draft) : ok({ error: "Draft not found" }, 404);
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "PUT",
|
||||
path: "/drafts/:id",
|
||||
handler: async (req, ctx: PluginContext) => {
|
||||
const request = asRequest(req);
|
||||
const draft = request.body as ServiceDraft;
|
||||
const result = validateDraft(draft);
|
||||
if (!result.ok) return { status: 400, body: { error: Object.values(result.errors)[0] ?? "Validation failed", errors: result.errors } };
|
||||
const store = createDraftStore({ rootDir: ctx.taskStore.getRootDir() });
|
||||
try {
|
||||
const updated = await store.update(request.params.id, draft);
|
||||
return ok(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) return ok({ error: "Draft not found" }, 404);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/drafts/:id/regenerate",
|
||||
handler: async (req, ctx: PluginContext) => {
|
||||
const request = asRequest(req);
|
||||
const store = createDraftStore({ rootDir: ctx.taskStore.getRootDir() });
|
||||
try {
|
||||
const draft = await store.update(request.params.id, { regeneratedAt: new Date().toISOString() });
|
||||
return ok({ draft, stub: true, message: "Regenerate stub — full generation lands in FN-3765/FN-3767" });
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) return ok({ error: "Draft not found" }, 404);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "DELETE",
|
||||
path: "/drafts/:id",
|
||||
|
||||
@@ -1,20 +1,46 @@
|
||||
/* Interim storage — replaced by FN-3766's canonical schema. Do not extend without updating that ticket. */
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { ServiceDraft } from "../wizard/types.js";
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
constructor(id: string) {
|
||||
super(`Draft not found: ${id}`);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDraft(existing: ServiceDraft, patch: Partial<ServiceDraft>): ServiceDraft {
|
||||
const mergedCredential = patch.credential && typeof patch.credential === "object"
|
||||
? { ...existing.credential, ...patch.credential }
|
||||
: existing.credential;
|
||||
|
||||
return {
|
||||
...existing,
|
||||
...patch,
|
||||
credential: mergedCredential,
|
||||
endpoints: patch.endpoints ?? existing.endpoints,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDraftStore({ rootDir }: { rootDir: string }) {
|
||||
const draftsDir = join(rootDir, ".fusion", "plugins", "cli-printing-press", "drafts");
|
||||
|
||||
async function ensureDir() { await mkdir(draftsDir, { recursive: true }); }
|
||||
|
||||
async function writeAtomic(path: string, draft: ServiceDraft): Promise<void> {
|
||||
const tempPath = `${path}.tmp-${randomUUID()}`;
|
||||
await writeFile(tempPath, JSON.stringify(draft, null, 2), "utf8");
|
||||
await rename(tempPath, path);
|
||||
}
|
||||
|
||||
return {
|
||||
async create(input: ServiceDraft) {
|
||||
await ensureDir();
|
||||
const now = new Date().toISOString();
|
||||
const draft: ServiceDraft = { ...input, id: input.id || randomUUID(), createdAt: input.createdAt || now, updatedAt: now };
|
||||
await writeFile(join(draftsDir, `${draft.id}.json`), JSON.stringify(draft, null, 2), "utf8");
|
||||
await writeAtomic(join(draftsDir, `${draft.id}.json`), draft);
|
||||
return draft;
|
||||
},
|
||||
async list() {
|
||||
@@ -26,6 +52,19 @@ export function createDraftStore({ rootDir }: { rootDir: string }) {
|
||||
async get(id: string) {
|
||||
try { return JSON.parse(await readFile(join(draftsDir, `${id}.json`), "utf8")) as ServiceDraft; } catch { return null; }
|
||||
},
|
||||
async update(id: string, patch: Partial<ServiceDraft>) {
|
||||
await ensureDir();
|
||||
const current = await this.get(id);
|
||||
if (!current) throw new NotFoundError(id);
|
||||
const updated: ServiceDraft = {
|
||||
...mergeDraft(current, patch),
|
||||
id: current.id,
|
||||
createdAt: current.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeAtomic(join(draftsDir, `${id}.json`), updated);
|
||||
return updated;
|
||||
},
|
||||
async delete(id: string) {
|
||||
await rm(join(draftsDir, `${id}.json`), { force: true });
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ServiceDraft {
|
||||
credential: CredentialPattern;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
regeneratedAt?: string;
|
||||
}
|
||||
|
||||
export type WizardStep = "basics" | "transport" | "endpoints" | "credentials" | "review";
|
||||
|
||||
Reference in New Issue
Block a user