diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx index afd7c35fab..4a4df6df45 100644 --- a/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx +++ b/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx @@ -2,7 +2,6 @@ import { type KeyboardEvent, type ReactElement, useState } from "react"; import { CookBookIcon, FloppyDiskIcon, - TestTubeIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Badge } from "@/components/ui/badge"; @@ -15,16 +14,12 @@ type StatusTone = "success" | "error"; type RecipeStudioHeaderProps = { activeView: RecipeStudioView; - previewLoading: boolean; - fullLoading: boolean; saveLoading: boolean; saveTone: StatusTone; savedAtLabel: string; workflowName: string; onWorkflowNameChange: (value: string) => void; onViewChange: (view: RecipeStudioView) => void; - onPreview: () => void; - onRunFull: () => void; onSaveRecipe: () => void; }; @@ -35,16 +30,12 @@ const STATUS_MESSAGE_CLASS: Record = { export function RecipeStudioHeader({ activeView, - previewLoading, - fullLoading, saveLoading, saveTone, savedAtLabel, workflowName, onWorkflowNameChange, onViewChange, - onPreview, - onRunFull, onSaveRecipe, }: RecipeStudioHeaderProps): ReactElement { const [editingWorkflowName, setEditingWorkflowName] = useState(false); @@ -116,14 +107,6 @@ export function RecipeStudioHeader({
- - + diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts index 98690824f2..5605d52ae9 100644 --- a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts +++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { toastError } from "@/shared/toast"; import { @@ -46,6 +46,7 @@ type UseRecipeExecutionsParams = { type UseRecipeExecutionsResult = { runDialogOpen: boolean; runDialogKind: RecipeExecutionKind; + setRunDialogKind: (kind: RecipeExecutionKind) => void; setRunDialogOpen: (open: boolean) => void; previewRows: number; fullRows: number; @@ -61,6 +62,13 @@ type UseRecipeExecutionsResult = { setSelectedExecutionId: (id: string) => void; openRunDialog: (kind: RecipeExecutionKind) => void; runFromDialog: () => Promise; + validateFromDialog: () => Promise; + validateLoading: boolean; + validateResult: { + valid: boolean; + errors: string[]; + rawDetail: string | null; + } | null; runPreview: () => Promise; runFull: () => Promise; cancelExecution: (id: string) => Promise; @@ -74,6 +82,12 @@ export function useRecipeExecutions({ onExecutionStart, onPreviewSuccess, }: UseRecipeExecutionsParams): UseRecipeExecutionsResult { + const [validateLoading, setValidateLoading] = useState(false); + const [validateResult, setValidateResult] = useState<{ + valid: boolean; + errors: string[]; + rawDetail: string | null; + } | null>(null); const { runDialogOpen, runDialogKind, @@ -335,9 +349,64 @@ export function useRecipeExecutions({ return runFull(); }, [runDialogKind, runFull, runPreview]); + const validateFromDialog = useCallback(async (): Promise => { + const payload = readPayload(); + if (!payload) { + const nextErrors = payloadResult.errors.length > 0 + ? payloadResult.errors + : [payloadErrorMessage]; + setValidateResult({ + valid: false, + errors: nextErrors, + rawDetail: null, + }); + return false; + } + + const rows = runDialogKind === "preview" ? previewRows : fullRows; + const normalizedRows = sanitizeExecutionRows(rows, runDialogKind); + const executionPayload = buildExecutionPayload({ + payload, + kind: runDialogKind, + rows: normalizedRows, + settings: runSettings, + }); + + setValidateLoading(true); + try { + const validation = await validateRecipe(executionPayload); + const errors = validation.errors.map((item) => item.message); + setValidateResult({ + valid: validation.valid, + errors, + rawDetail: validation.raw_detail ?? null, + }); + return validation.valid; + } catch (error) { + const message = toErrorMessage(error, "Validation failed."); + setValidateResult({ + valid: false, + errors: [message], + rawDetail: null, + }); + return false; + } finally { + setValidateLoading(false); + } + }, [ + fullRows, + payloadErrorMessage, + payloadResult.errors, + previewRows, + readPayload, + runDialogKind, + runSettings, + ]); + const openRunDialog = useCallback( (kind: RecipeExecutionKind): void => { setRunErrors([]); + setValidateResult(null); setRunDialogKind(kind); if (kind === "full") { const payload = readPayload(); @@ -418,6 +487,7 @@ export function useRecipeExecutions({ return { runDialogOpen, runDialogKind, + setRunDialogKind, setRunDialogOpen, previewRows, fullRows, @@ -433,6 +503,9 @@ export function useRecipeExecutions({ setSelectedExecutionId, openRunDialog, runFromDialog, + validateFromDialog, + validateLoading, + validateResult, runPreview, runFull, cancelExecution, diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-studio-actions.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-studio-actions.ts index 119c5f2355..bb7531233b 100644 --- a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-studio-actions.ts +++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-studio-actions.ts @@ -44,6 +44,7 @@ type UseRecipeStudioActionsResult = { setImportOpen: (open: boolean) => void; runDialogOpen: boolean; runDialogKind: RecipeExecutionKind; + setRunDialogKind: (kind: RecipeExecutionKind) => void; setRunDialogOpen: (open: boolean) => void; previewRows: number; fullRows: number; @@ -61,6 +62,13 @@ type UseRecipeStudioActionsResult = { persistRecipe: () => Promise; openRunDialog: (kind: RecipeExecutionKind) => void; runFromDialog: () => Promise; + validateFromDialog: () => Promise; + validateLoading: boolean; + validateResult: { + valid: boolean; + errors: string[]; + rawDetail: string | null; + } | null; runPreview: () => Promise; runFull: () => Promise; cancelExecution: (id: string) => Promise; @@ -113,6 +121,7 @@ export function useRecipeStudioActions({ setImportOpen: persistence.setImportOpen, runDialogOpen: executions.runDialogOpen, runDialogKind: executions.runDialogKind, + setRunDialogKind: executions.setRunDialogKind, setRunDialogOpen: executions.setRunDialogOpen, previewRows: executions.previewRows, fullRows: executions.fullRows, @@ -130,6 +139,9 @@ export function useRecipeStudioActions({ persistRecipe: persistence.persistRecipe, openRunDialog: executions.openRunDialog, runFromDialog: executions.runFromDialog, + validateFromDialog: executions.validateFromDialog, + validateLoading: executions.validateLoading, + validateResult: executions.validateResult, runPreview: executions.runPreview, runFull: executions.runFull, cancelExecution: executions.cancelExecution, diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx index 1308428916..dcb7621dc9 100644 --- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx +++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx @@ -10,7 +10,11 @@ import { Panel, ReactFlow, } from "@xyflow/react"; -import { PlusSignIcon } from "@hugeicons/core-free-icons"; +import { + CookBookIcon, + PlusSignIcon, + TestTube01Icon, +} from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { type ReactElement, @@ -31,6 +35,7 @@ import { RecipeStudioHeader } from "./components/recipe-studio-header"; import { RecipeNode } from "./components/recipe-graph-node"; import { RecipeGraphSemanticEdge } from "./components/recipe-graph-semantic-edge"; import { DataEdge } from "./components/rf-ui/data-edge"; +import { Button } from "@/components/ui/button"; import { ConfigDialog } from "./dialogs/config-dialog"; import { ImportDialog } from "./dialogs/import-dialog"; import { RunDialog } from "./dialogs/preview-dialog"; @@ -283,6 +288,7 @@ export function RecipeStudioPage({ setImportOpen, runDialogOpen, runDialogKind, + setRunDialogKind, setRunDialogOpen, previewRows, fullRows, @@ -300,6 +306,9 @@ export function RecipeStudioPage({ persistRecipe, openRunDialog, runFromDialog, + validateFromDialog, + validateLoading, + validateResult, cancelExecution, loadExecutionDatasetPage, copyRecipe, @@ -346,16 +355,12 @@ export function RecipeStudioPage({ > openRunDialog("preview")} - onRunFull={() => openRunDialog("full")} onSaveRecipe={() => { void persistRecipe(); }} @@ -441,6 +446,32 @@ export function RecipeStudioPage({ interactive={interactive} onToggleInteractive={toggleInteractive} /> +
+
+ + +
+
) : ( { if (runDialogKind === "preview") { @@ -498,7 +530,12 @@ export function RecipeStudioPage({ settings={runSettings} onSettingsChange={setRunSettings} loading={runDialogLoading} + validateLoading={validateLoading} + validateResult={validateResult} errors={runErrors} + onValidate={() => { + void validateFromDialog(); + }} onRun={() => { void runFromDialog(); }}