refactor: streamline recipe execution flows with validation support and enhanced run dialog interactions
This commit is contained in:
parent
91cbb0e933
commit
d4655eb8bf
5 changed files with 198 additions and 24 deletions
|
|
@ -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<StatusTone, string> = {
|
|||
|
||||
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({
|
|||
</Tabs>
|
||||
</div>
|
||||
<div className="flex items-center justify-self-end gap-2">
|
||||
<Button type="button" size="sm" onClick={onPreview} disabled={previewLoading}>
|
||||
<HugeiconsIcon icon={TestTubeIcon} className="size-3.5" />
|
||||
{previewLoading ? "Previewing..." : "Preview"}
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={onRunFull} disabled={fullLoading}>
|
||||
<HugeiconsIcon icon={TestTubeIcon} className="size-3.5" />
|
||||
{fullLoading ? "Starting..." : "Full run"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { type ReactElement, useMemo, useState } from "react";
|
||||
import {
|
||||
CookBookIcon,
|
||||
TestTube01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
|
|
@ -22,13 +27,21 @@ type RunDialogProps = {
|
|||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
kind: RecipeExecutionKind;
|
||||
onKindChange: (kind: RecipeExecutionKind) => void;
|
||||
rows: number;
|
||||
onRowsChange: (rows: number) => void;
|
||||
settings: RecipeRunSettings;
|
||||
onSettingsChange: (patch: Partial<RecipeRunSettings>) => void;
|
||||
loading: boolean;
|
||||
validateLoading: boolean;
|
||||
validateResult: {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
rawDetail: string | null;
|
||||
} | null;
|
||||
errors: string[];
|
||||
onRun: () => void;
|
||||
onValidate: () => void;
|
||||
container?: HTMLDivElement | null;
|
||||
};
|
||||
|
||||
|
|
@ -63,13 +76,17 @@ export function RunDialog({
|
|||
open,
|
||||
onOpenChange,
|
||||
kind,
|
||||
onKindChange,
|
||||
rows,
|
||||
onRowsChange,
|
||||
settings,
|
||||
onSettingsChange,
|
||||
loading,
|
||||
validateLoading,
|
||||
validateResult,
|
||||
errors,
|
||||
onRun,
|
||||
onValidate,
|
||||
container,
|
||||
}: RunDialogProps): ReactElement {
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
|
@ -98,6 +115,16 @@ export function RunDialog({
|
|||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<label className="flex items-center justify-between rounded-xl border bg-muted/20 px-3 py-2 text-sm">
|
||||
<span className="font-medium text-foreground">Preview mode</span>
|
||||
<Switch
|
||||
checked={kind === "preview"}
|
||||
onCheckedChange={(checked) =>
|
||||
onKindChange(checked ? "preview" : "full")
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
|
|
@ -319,7 +346,7 @@ export function RunDialog({
|
|||
{errors.length > 0 && (
|
||||
<div className="max-h-44 space-y-1 overflow-y-auto rounded-xl border border-destructive/30 bg-destructive/5 p-3">
|
||||
<p className="text-xs font-semibold uppercase text-destructive">
|
||||
Validation errors
|
||||
Run checks
|
||||
</p>
|
||||
{errors.map((error) => (
|
||||
<p key={error} className="text-xs text-destructive">
|
||||
|
|
@ -329,6 +356,38 @@ export function RunDialog({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{validateResult && (
|
||||
<div
|
||||
className={
|
||||
validateResult.valid
|
||||
? "space-y-1 rounded-xl border border-emerald-300 bg-emerald-50 p-3"
|
||||
: "space-y-1 rounded-xl border border-destructive/30 bg-destructive/5 p-3"
|
||||
}
|
||||
>
|
||||
<p
|
||||
className={
|
||||
validateResult.valid
|
||||
? "text-xs font-semibold uppercase text-emerald-700"
|
||||
: "text-xs font-semibold uppercase text-destructive"
|
||||
}
|
||||
>
|
||||
{validateResult.valid ? "Validation passed" : "Validation failed"}
|
||||
</p>
|
||||
{!validateResult.valid && validateResult.errors.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{validateResult.errors.map((error) => (
|
||||
<p key={error} className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!validateResult.valid && validateResult.rawDetail && (
|
||||
<p className="text-xs text-destructive">{validateResult.rawDetail}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -338,7 +397,17 @@ export function RunDialog({
|
|||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onValidate}
|
||||
disabled={loading || validateLoading}
|
||||
>
|
||||
<HugeiconsIcon icon={TestTube01Icon} className="size-3.5" />
|
||||
{validateLoading ? "Validating..." : "Validate recipe"}
|
||||
</Button>
|
||||
<Button type="button" onClick={onRun} disabled={loading}>
|
||||
<HugeiconsIcon icon={CookBookIcon} className="size-3.5" />
|
||||
{loading ? "Starting..." : `Start ${kindLabel.toLowerCase()}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -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<boolean>;
|
||||
validateFromDialog: () => Promise<boolean>;
|
||||
validateLoading: boolean;
|
||||
validateResult: {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
rawDetail: string | null;
|
||||
} | null;
|
||||
runPreview: () => Promise<boolean>;
|
||||
runFull: () => Promise<boolean>;
|
||||
cancelExecution: (id: string) => Promise<void>;
|
||||
|
|
@ -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<boolean> => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
openRunDialog: (kind: RecipeExecutionKind) => void;
|
||||
runFromDialog: () => Promise<boolean>;
|
||||
validateFromDialog: () => Promise<boolean>;
|
||||
validateLoading: boolean;
|
||||
validateResult: {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
rawDetail: string | null;
|
||||
} | null;
|
||||
runPreview: () => Promise<boolean>;
|
||||
runFull: () => Promise<boolean>;
|
||||
cancelExecution: (id: string) => Promise<void>;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
>
|
||||
<RecipeStudioHeader
|
||||
activeView={activeView}
|
||||
previewLoading={previewLoading}
|
||||
fullLoading={fullLoading}
|
||||
saveLoading={saveLoading}
|
||||
saveTone={saveTone}
|
||||
savedAtLabel={savedAtLabel}
|
||||
workflowName={workflowName}
|
||||
onWorkflowNameChange={setWorkflowName}
|
||||
onViewChange={setActiveView}
|
||||
onPreview={() => openRunDialog("preview")}
|
||||
onRunFull={() => openRunDialog("full")}
|
||||
onSaveRecipe={() => {
|
||||
void persistRecipe();
|
||||
}}
|
||||
|
|
@ -441,6 +446,32 @@ export function RecipeStudioPage({
|
|||
interactive={interactive}
|
||||
onToggleInteractive={toggleInteractive}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-20 flex justify-center">
|
||||
<div className="pointer-events-auto flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="h-11 px-5"
|
||||
onClick={() => openRunDialog(runDialogKind)}
|
||||
disabled={previewLoading || fullLoading}
|
||||
>
|
||||
<HugeiconsIcon icon={CookBookIcon} className="size-4" />
|
||||
{previewLoading || fullLoading ? "Running..." : "Run"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11 px-5"
|
||||
onClick={() => {
|
||||
openRunDialog(runDialogKind);
|
||||
void validateFromDialog();
|
||||
}}
|
||||
disabled={validateLoading}
|
||||
>
|
||||
<HugeiconsIcon icon={TestTube01Icon} className="size-4" />
|
||||
{validateLoading ? "Validating..." : "Validate"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ReactFlow>
|
||||
) : (
|
||||
<ExecutionsView
|
||||
|
|
@ -487,6 +518,7 @@ export function RecipeStudioPage({
|
|||
open={runDialogOpen}
|
||||
onOpenChange={setRunDialogOpen}
|
||||
kind={runDialogKind}
|
||||
onKindChange={setRunDialogKind}
|
||||
rows={runDialogRows}
|
||||
onRowsChange={(rows) => {
|
||||
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();
|
||||
}}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue