diff --git a/src/app/api/canvas-workflows/[id]/__tests__/route.test.ts b/src/app/api/canvas-workflows/[id]/__tests__/route.test.ts index 93145577..c767856d 100644 --- a/src/app/api/canvas-workflows/[id]/__tests__/route.test.ts +++ b/src/app/api/canvas-workflows/[id]/__tests__/route.test.ts @@ -67,6 +67,33 @@ describe("/api/canvas-workflows/[id] route", () => { }); }); + it("proxies update coverUrl and omits missing description", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ status: "0000", message: "ok", data: { id: 32 } }), { status: 200 }) + ); + global.fetch = fetchMock; + + const body = { + title: "Workflow", + properties: "{\"nodes\":[],\"edges\":[]}", + coverUrl: "https://example.com/cover.png", + }; + const response = await POST(createRequest("POST", body), context); + + await expect(response.json()).resolves.toEqual({ success: true, data: { id: 32 } }); + expect(fetchMock).toHaveBeenCalledWith("https://popi.example/api_client/canvas/update", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: "Bearer user-token", + token: "user-token", + }, + body: JSON.stringify({ id: 32, ...body }), + cache: "no-store", + }); + }); + it("proxies delete requests to canvas delete", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ status: "0000", message: "ok", data: true }), { status: 200 }) diff --git a/src/app/api/canvas-workflows/[id]/route.ts b/src/app/api/canvas-workflows/[id]/route.ts index 93b3b597..15cbadd6 100644 --- a/src/app/api/canvas-workflows/[id]/route.ts +++ b/src/app/api/canvas-workflows/[id]/route.ts @@ -78,11 +78,13 @@ export async function POST(request: NextRequest, context: RouteContext) { title?: unknown; desp?: unknown; properties?: unknown; + coverUrl?: unknown; } | null; const title = typeof body?.title === "string" ? body.title.trim() : ""; - const desp = typeof body?.desp === "string" ? body.desp : ""; + const desp = typeof body?.desp === "string" ? body.desp : undefined; const properties = typeof body?.properties === "string" ? body.properties : ""; + const coverUrl = typeof body?.coverUrl === "string" && body.coverUrl.trim() ? body.coverUrl : undefined; if (!title) { return NextResponse.json( @@ -97,6 +99,14 @@ export async function POST(request: NextRequest, context: RouteContext) { ); } + const upstreamBody = { + id, + title, + ...(desp !== undefined ? { desp } : {}), + properties, + ...(coverUrl ? { coverUrl } : {}), + }; + const upstream = await fetch(getPopiBaseUrl(request) + POPI_CANVAS_UPDATE_PATH, { method: "POST", headers: { @@ -105,7 +115,7 @@ export async function POST(request: NextRequest, context: RouteContext) { Authorization: `Bearer ${login.token}`, token: login.token, }, - body: JSON.stringify({ id, title, desp, properties }), + body: JSON.stringify(upstreamBody), cache: "no-store", }); @@ -136,8 +146,6 @@ export async function DELETE(request: NextRequest, context: RouteContext) { const upstreamUrl = new URL(getPopiBaseUrl(request) + POPI_CANVAS_DELETE_PATH); upstreamUrl.searchParams.set("id", String(id)); - console.log('upstreamUrl --------------', upstreamUrl) - const upstream = await fetch(upstreamUrl, { method: "GET", headers: { diff --git a/src/app/canvas/page.tsx b/src/app/canvas/page.tsx index 614abdfe..6066680c 100644 --- a/src/app/canvas/page.tsx +++ b/src/app/canvas/page.tsx @@ -17,6 +17,8 @@ import { installAuthFetchInterceptor } from "@/utils/authFetch"; import { useModelStore } from "@/store/modelStore"; import { isPopiProviderMode } from "@/lib/providerMode"; import { LoginModal } from "@/components/auth/LoginModal"; +import { useCanvasWorkflowLoader } from "@/hooks/useCanvasWorkflowLoader"; +import { useCanvasWorkflowAutoUpdate } from "@/hooks/useCanvasWorkflowAutoUpdate"; if (typeof window !== "undefined") { installAuthFetchInterceptor(); @@ -34,6 +36,10 @@ function CanvasPageContent() { const searchParams = useSearchParams(); const token = searchParams.get("token") || ''; + const workflowId = searchParams.get("workflowId"); + + useCanvasWorkflowLoader(workflowId); + useCanvasWorkflowAutoUpdate(); useEffect(() => { if (token || getStoredLoginToken()) { diff --git a/src/components/CanvasWorkflowPickerModal.tsx b/src/components/CanvasWorkflowPickerModal.tsx index e64f9119..eefa84df 100644 --- a/src/components/CanvasWorkflowPickerModal.tsx +++ b/src/components/CanvasWorkflowPickerModal.tsx @@ -35,6 +35,11 @@ function formatWorkflowTime(value?: string): string { return date.toLocaleString(); } +function buildCanvasWorkflowUrl(searchParams: URLSearchParams): string { + const query = searchParams.toString(); + return query ? `/canvas?${query}` : "/canvas"; +} + export function CanvasWorkflowPickerModal({ isOpen, onClose }: CanvasWorkflowPickerModalProps) { const { t } = useI18n(); const { message } = App.useApp(); @@ -69,7 +74,7 @@ export function CanvasWorkflowPickerModal({ isOpen, onClose }: CanvasWorkflowPic const openWorkflow = (workflow: CanvasWorkflowListItem) => { const nextParams = new URLSearchParams(searchParams.toString()); nextParams.set("workflowId", String(workflow.id)); - router.push(`/?${nextParams.toString()}`); + router.push(buildCanvasWorkflowUrl(nextParams)); onClose(); }; @@ -80,8 +85,7 @@ export function CanvasWorkflowPickerModal({ isOpen, onClose }: CanvasWorkflowPic if (currentCanvasWorkflowId === workflow.id) { const nextParams = new URLSearchParams(searchParams.toString()); nextParams.delete("workflowId"); - const nextQuery = nextParams.toString(); - router.replace(nextQuery ? `/?${nextQuery}` : "/"); + router.replace(buildCanvasWorkflowUrl(nextParams)); clearCurrentCanvasWorkflow(); } await loadWorkflows(pageInfo.page, pageInfo.pageSize); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 21a5f4b9..b5c803f4 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState, useMemo, useCallback, type ReactNode } from "react"; +import { useState, useMemo, useCallback, useEffect, type ReactNode } from "react"; import { Button } from "antd"; import { useWorkflowStore } from "@/store/workflowStore"; +import { useCanvasWorkflowStore } from "@/store/canvasWorkflowStore"; import { useShallow } from "zustand/shallow"; import { ProjectSetupModal } from "./ProjectSetupModal"; import { CostIndicator } from "./CostIndicator"; @@ -96,10 +97,9 @@ export function Header() { workflowName, workflowId, saveDirectoryPath, - hasUnsavedChanges, - lastSavedAt, isSaving, setWorkflowMetadata, + setWorkflowName, saveToFile, loadWorkflow, previousWorkflowSnapshot, @@ -111,10 +111,9 @@ export function Header() { workflowName: state.workflowName, workflowId: state.workflowId, saveDirectoryPath: state.saveDirectoryPath, - hasUnsavedChanges: state.hasUnsavedChanges, - lastSavedAt: state.lastSavedAt, isSaving: state.isSaving, setWorkflowMetadata: state.setWorkflowMetadata, + setWorkflowName: state.setWorkflowName, saveToFile: state.saveToFile, loadWorkflow: state.loadWorkflow, previousWorkflowSnapshot: state.previousWorkflowSnapshot, @@ -129,16 +128,41 @@ export function Header() { const [showWorkflowBrowser, setShowWorkflowBrowser] = useState(false); const [showWorkflowSaveModal, setShowWorkflowSaveModal] = useState(false); const [showCanvasWorkflowPicker, setShowCanvasWorkflowPicker] = useState(false); + const [workflowNameDraft, setWorkflowNameDraft] = useState(workflowName || ""); + const currentCanvasWorkflowId = useCanvasWorkflowStore((state) => state.currentCanvasWorkflowId); + const currentCanvasWorkflowDescription = useCanvasWorkflowStore((state) => state.currentCanvasWorkflowDescription); + const setCurrentCanvasWorkflow = useCanvasWorkflowStore((state) => state.setCurrentCanvasWorkflow); const isProjectConfigured = !!workflowName; const canSave = !!(workflowId && workflowName && saveDirectoryPath); - const formatTime = (timestamp: number) => { - return new Date(timestamp).toLocaleTimeString(language, { - hour: "numeric", - minute: "2-digit", - }); - }; + useEffect(() => { + setWorkflowNameDraft(workflowName || ""); + }, [workflowName]); + + const commitWorkflowName = useCallback(() => { + const nextName = workflowNameDraft.trim(); + if (!nextName || nextName === workflowName) { + setWorkflowNameDraft(workflowName || ""); + return; + } + + setWorkflowName(nextName); + if (currentCanvasWorkflowId) { + setCurrentCanvasWorkflow({ + id: currentCanvasWorkflowId, + title: nextName, + description: currentCanvasWorkflowDescription, + }); + } + }, [ + workflowNameDraft, + workflowName, + setWorkflowName, + currentCanvasWorkflowId, + currentCanvasWorkflowDescription, + setCurrentCanvasWorkflow, + ]); const handleNewProject = () => { setProjectModalMode("new"); @@ -278,8 +302,23 @@ export function Header() {
{isProjectConfigured ? ( <> - {workflowName} - | + setWorkflowNameDraft(event.target.value)} + onBlur={commitWorkflowName} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + if (event.key === "Escape") { + setWorkflowNameDraft(workflowName || ""); + event.currentTarget.blur(); + } + }} + size={Math.max(workflowNameDraft.length, 1)} + className="min-w-0 bg-transparent p-0 text-sm text-neutral-300 outline-none" + aria-label={t("workflowSave.name")} + /> {/* File operations group */} @@ -304,9 +343,6 @@ export function Header() { d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m.75 12 3 3m0 0 3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /> - {hasUnsavedChanges && !isSaving && ( - - )} )} - - {isProjectConfigured ? ( - isSaving ? ( - t("header.saving") - ) : lastSavedAt ? ( - t("header.savedAt", { time: formatTime(lastSavedAt) }) - ) : ( - t("header.notSaved") - ) - ) : ( - t("header.notSaved") - )} - ยท