Add validation block (#1242)

This commit is contained in:
Shuchang Zheng
2024-11-22 08:48:06 -08:00
committed by GitHub
parent 8ef02ea810
commit c732b411ac
17 changed files with 364 additions and 48 deletions

View File

@@ -31,7 +31,12 @@ import {
import { WorkflowHeader } from "./WorkflowHeader";
import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext";
import { edgeTypes } from "./edges";
import { AppNode, nodeTypes, WorkflowBlockNode } from "./nodes";
import {
AppNode,
isWorkflowBlockNode,
nodeTypes,
WorkflowBlockNode,
} from "./nodes";
import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel";
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
import "./reactFlowOverrideStyles.css";
@@ -68,6 +73,7 @@ import { ReloadIcon } from "@radix-ui/react-icons";
import { isLoopNode, LoopNode } from "./nodes/LoopNode/types";
import { isTaskNode } from "./nodes/TaskNode/types";
import { useShouldNotifyWhenClosingTab } from "@/hooks/useShouldNotifyWhenClosingTab";
import { isValidationNode } from "./nodes/ValidationNode/types";
function convertToParametersYAML(
parameters: ParametersState,
@@ -452,6 +458,14 @@ function FlowRenderer({
function getWorkflowErrors(): Array<string> {
const errors: Array<string> = [];
const workflowBlockNodes = nodes.filter(isWorkflowBlockNode);
if (workflowBlockNodes[0]!.type === "validation") {
const label = workflowBlockNodes[0]!.data.label;
errors.push(
`${label}: Validation block can't be the first block in a workflow`,
);
}
// check loop node parameters
const loopNodes: Array<LoopNode> = nodes.filter(isLoopNode);
const emptyLoopNodes = loopNodes.filter(
@@ -480,6 +494,23 @@ function FlowRenderer({
}
});
const validationNodes = nodes.filter(isValidationNode);
validationNodes.forEach((node) => {
try {
JSON.parse(node.data.errorCodeMapping);
} catch {
errors.push(`${node.data.label}: Error messages is not valid JSON`);
}
if (
node.data.completeCriterion.length === 0 &&
node.data.terminateCriterion.length === 0
) {
errors.push(
`${node.data.label}: At least one of completion or termination criteria must be provided`,
);
}
});
return errors;
}

View File

@@ -3,7 +3,6 @@ import { NodeBaseData } from "../types";
export type CodeBlockNodeData = NodeBaseData & {
code: string;
editable: boolean;
};
export type CodeBlockNode = Node<CodeBlockNodeData, "codeBlock">;

View File

@@ -4,7 +4,6 @@ import { NodeBaseData } from "../types";
export type DownloadNodeData = NodeBaseData & {
url: string;
editable: boolean;
};
export type DownloadNode = Node<DownloadNodeData, "download">;

View File

@@ -3,7 +3,6 @@ import { NodeBaseData } from "../types";
export type FileParserNodeData = NodeBaseData & {
fileUrl: string;
editable: boolean;
};
export type FileParserNode = Node<FileParserNodeData, "fileParser">;

View File

@@ -3,7 +3,6 @@ import { NodeBaseData } from "../types";
export type LoopNodeData = NodeBaseData & {
loopValue: string;
editable: boolean;
};
export type LoopNode = Node<LoopNodeData, "loop">;

View File

@@ -14,7 +14,6 @@ export type SendEmailNodeData = NodeBaseData & {
subject: string;
body: string;
fileAttachments: string;
editable: boolean;
sender: string;
smtpHostSecretParameterKey?: string;
smtpPortSecretParameterKey?: string;

View File

@@ -28,14 +28,9 @@ import { getAvailableOutputParameterKeys } from "../../workflowEditorUtils";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { ParametersMultiSelect } from "./ParametersMultiSelect";
import {
dataSchemaExampleValue,
errorMappingExampleValue,
fieldPlaceholders,
helpTooltipContent,
type TaskNode,
} from "./types";
import { fieldPlaceholders, helpTooltipContent, type TaskNode } from "./types";
import { Separator } from "@/components/ui/separator";
import { dataSchemaExampleValue, errorMappingExampleValue } from "../types";
function TaskNode({ id, data }: NodeProps<TaskNode>) {
const { updateNodeData } = useReactFlow();

View File

@@ -11,7 +11,6 @@ export type TaskNodeData = NodeBaseData & {
maxStepsOverride: number | null;
allowDownloads: boolean;
downloadSuffix: string | null;
editable: boolean;
parameterKeys: Array<string>;
totpVerificationUrl: string | null;
totpIdentifier: string | null;
@@ -88,14 +87,3 @@ export const fieldPlaceholders = {
totpVerificationUrl: "Provide your 2FA endpoint",
totpIdentifier: "Add an ID that links your TOTP to the task",
};
export const errorMappingExampleValue = {
sample_invalid_credentials: "if the credentials are incorrect, terminate",
};
export const dataSchemaExampleValue = {
type: "object",
properties: {
sample: { type: "string" },
},
};

View File

@@ -4,8 +4,6 @@ import { NodeBaseData } from "../types";
export type TextPromptNodeData = NodeBaseData & {
prompt: string;
jsonSchema: string;
editable: boolean;
label: string;
parameterKeys: Array<string>;
};

View File

@@ -0,0 +1,191 @@
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import { helpTooltipContent, type ValidationNode } from "./types";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { useState } from "react";
import { CheckCircledIcon } from "@radix-ui/react-icons";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { Label } from "@/components/ui/label";
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
import { HelpTooltip } from "@/components/HelpTooltip";
import { Checkbox } from "@/components/ui/checkbox";
import { errorMappingExampleValue } from "../types";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { Switch } from "@/components/ui/switch";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
function ValidationNode({ id, data }: NodeProps<ValidationNode>) {
const { updateNodeData } = useReactFlow();
const { editable } = data;
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const [inputs, setInputs] = useState({
completeCriterion: data.completeCriterion,
terminateCriterion: data.terminateCriterion,
errorCodeMapping: data.errorCodeMapping,
});
const deleteNodeCallback = useDeleteNodeCallback();
function handleChange(key: string, value: unknown) {
if (!editable) {
return;
}
setInputs({ ...inputs, [key]: value });
updateNodeData(id, { [key]: value });
}
return (
<div>
<Handle
type="source"
position={Position.Bottom}
id="a"
className="opacity-0"
/>
<Handle
type="target"
position={Position.Top}
id="b"
className="opacity-0"
/>
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
<header className="flex h-[2.75rem] justify-between">
<div className="flex gap-2">
<div className="flex h-[2.75rem] w-[2.75rem] items-center justify-center rounded border border-slate-600">
<CheckCircledIcon className="size-6" />
</div>
<div className="flex flex-col gap-1">
<EditableNodeTitle
value={label}
editable={editable}
onChange={setLabel}
titleClassName="text-base"
inputClassName="text-base"
/>
<span className="text-xs text-slate-400">Validation Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</header>
<div className="space-y-2">
<Label className="text-xs text-slate-300">Complete if...</Label>
<AutoResizingTextarea
onChange={(event) => {
if (!editable) {
return;
}
handleChange("completeCriterion", event.target.value);
}}
value={inputs.completeCriterion}
className="nopan text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-slate-300">Terminate if...</Label>
<AutoResizingTextarea
onChange={(event) => {
if (!editable) {
return;
}
handleChange("terminateCriterion", event.target.value);
}}
value={inputs.terminateCriterion}
className="nopan text-xs"
/>
</div>
<Separator />
<Accordion type="single" collapsible>
<AccordionItem value="advanced" className="border-b-0">
<AccordionTrigger className="py-0">
Advanced Settings
</AccordionTrigger>
<AccordionContent>
<div className="ml-6 mt-4 space-y-4">
<div className="space-y-2">
<div className="flex gap-4">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Error Messages
</Label>
<HelpTooltip
content={helpTooltipContent["errorCodeMapping"]}
/>
</div>
<Checkbox
checked={inputs.errorCodeMapping !== "null"}
disabled={!editable}
onCheckedChange={(checked) => {
if (!editable) {
return;
}
handleChange(
"errorCodeMapping",
checked
? JSON.stringify(errorMappingExampleValue, null, 2)
: "null",
);
}}
/>
</div>
{inputs.errorCodeMapping !== "null" && (
<div>
<CodeEditor
language="json"
value={inputs.errorCodeMapping}
onChange={(value) => {
if (!editable) {
return;
}
handleChange("errorCodeMapping", value);
}}
className="nowheel nopan"
fontSize={8}
/>
</div>
)}
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Continue on Failure
</Label>
<HelpTooltip
content={helpTooltipContent["continueOnFailure"]}
/>
</div>
<div className="w-52">
<Switch
checked={data.continueOnFailure}
onCheckedChange={(checked) => {
if (!editable) {
return;
}
updateNodeData(id, { continueOnFailure: checked });
}}
/>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
);
}
export { ValidationNode };

View File

@@ -0,0 +1,32 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
export type ValidationNodeData = NodeBaseData & {
completeCriterion: string;
terminateCriterion: string;
errorCodeMapping: string;
parameterKeys: Array<string>;
};
export type ValidationNode = Node<ValidationNodeData, "validation">;
export const validationNodeDefaultData: ValidationNodeData = {
label: "",
completeCriterion: "",
terminateCriterion: "",
errorCodeMapping: "null",
continueOnFailure: false,
editable: true,
parameterKeys: [],
};
export const helpTooltipContent = {
errorCodeMapping:
"Knowing about why a block terminated can be important, specify error messages here.",
continueOnFailure:
"Allow the workflow to continue if it encounters a failure.",
} as const;
export function isValidationNode(node: Node): node is ValidationNode {
return node.type === "validation";
}

View File

@@ -19,6 +19,8 @@ import type { NodeAdderNode } from "./NodeAdderNode/types";
import { NodeAdderNode as NodeAdderNodeComponent } from "./NodeAdderNode/NodeAdderNode";
import { StartNode as StartNodeComponent } from "./StartNode/StartNode";
import type { StartNode } from "./StartNode/types";
import type { ValidationNode } from "./ValidationNode/types";
import { ValidationNode as ValidationNodeComponent } from "./ValidationNode/ValidationNode";
export type UtilityNode = StartNode | NodeAdderNode;
@@ -30,7 +32,8 @@ export type WorkflowBlockNode =
| CodeBlockNode
| FileParserNode
| UploadNode
| DownloadNode;
| DownloadNode
| ValidationNode;
export function isUtilityNode(node: AppNode): node is UtilityNode {
return node.type === "nodeAdder" || node.type === "start";
@@ -53,4 +56,5 @@ export const nodeTypes = {
download: memo(DownloadNodeComponent),
nodeAdder: memo(NodeAdderNodeComponent),
start: memo(StartNodeComponent),
validation: memo(ValidationNodeComponent),
};

View File

@@ -1,4 +1,16 @@
export type NodeBaseData = {
label: string;
continueOnFailure: boolean;
editable: boolean;
};
export const errorMappingExampleValue = {
sample_invalid_credentials: "if the credentials are incorrect, terminate",
} as const;
export const dataSchemaExampleValue = {
type: "object",
properties: {
sample: { type: "string" },
},
} as const;

View File

@@ -1,5 +1,6 @@
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
import {
CheckCircledIcon,
Cross2Icon,
CursorTextIcon,
EnvelopeClosedIcon,
@@ -20,54 +21,60 @@ const nodeLibraryItems: Array<{
}> = [
{
nodeType: "task",
icon: <ListBulletIcon className="h-6 w-6" />,
icon: <ListBulletIcon className="size-6" />,
title: "Task Block",
description: "Takes actions or extracts information",
},
{
nodeType: "loop",
icon: <UpdateIcon className="h-6 w-6" />,
icon: <UpdateIcon className="size-6" />,
title: "For Loop Block",
description: "Repeats nested elements",
},
{
nodeType: "textPrompt",
icon: <CursorTextIcon className="h-6 w-6" />,
icon: <CursorTextIcon className="size-6" />,
title: "Text Prompt Block",
description: "Generates AI response",
},
{
nodeType: "sendEmail",
icon: <EnvelopeClosedIcon className="h-6 w-6" />,
icon: <EnvelopeClosedIcon className="size-6" />,
title: "Send Email Block",
description: "Sends an email",
},
// temporarily removed
// {
// nodeType: "codeBlock",
// icon: <CodeIcon className="h-6 w-6" />,
// icon: <CodeIcon className="size-6" />,
// title: "Code Block",
// description: "Executes Python code",
// },
{
nodeType: "fileParser",
icon: <FileIcon className="h-6 w-6" />,
icon: <FileIcon className="size-6" />,
title: "File Parser Block",
description: "Downloads and parses a file",
},
// disabled
// {
// nodeType: "download",
// icon: <DownloadIcon className="h-6 w-6" />,
// icon: <DownloadIcon className="size-6" />,
// title: "Download Block",
// description: "Downloads a file from S3",
// },
{
nodeType: "upload",
icon: <UploadIcon className="h-6 w-6" />,
icon: <UploadIcon className="size-6" />,
title: "Upload Block",
description: "Uploads a file to S3",
},
{
nodeType: "validation",
icon: <CheckCircledIcon className="size-6" />,
title: "Validation Block",
description: "Validate the state of the workflow or terminate",
},
];
type Props = {
@@ -91,7 +98,7 @@ function WorkflowNodeLibraryPanel({ onNodeClick, first }: Props) {
<h1 className="text-lg">Node Library</h1>
{!first && (
<Cross2Icon
className="h-6 w-6 cursor-pointer"
className="size-6 cursor-pointer"
onClick={() => {
closeWorkflowPanel();
}}
@@ -139,7 +146,7 @@ function WorkflowNodeLibraryPanel({ onNodeClick, first }: Props) {
</span>
</div>
</div>
<PlusIcon className="h-6 w-6" />
<PlusIcon className="size-6" />
</div>
);
})}

View File

@@ -21,6 +21,7 @@ import {
TaskBlockYAML,
TextPromptBlockYAML,
UploadToS3BlockYAML,
ValidationBlockYAML,
WorkflowCreateYAMLRequest,
} from "../types/workflowYamlTypes";
import {
@@ -48,6 +49,7 @@ import { taskNodeDefaultData } from "./nodes/TaskNode/types";
import { textPromptNodeDefaultData } from "./nodes/TextPromptNode/types";
import { NodeBaseData } from "./nodes/types";
import { uploadNodeDefaultData } from "./nodes/UploadNode/types";
import { validationNodeDefaultData } from "./nodes/ValidationNode/types";
export const NEW_NODE_LABEL_PREFIX = "block_";
@@ -133,6 +135,7 @@ function convertToNode(
const commonData: NodeBaseData = {
label: block.label,
continueOnFailure: block.continue_on_failure,
editable: true,
};
switch (block.block_type) {
case "task": {
@@ -142,7 +145,6 @@ function convertToNode(
type: "task",
data: {
...commonData,
editable: true,
url: block.url ?? "",
navigationGoal: block.navigation_goal ?? "",
dataExtractionGoal: block.data_extraction_goal ?? "",
@@ -159,6 +161,20 @@ function convertToNode(
},
};
}
case "validation": {
return {
...identifiers,
...common,
type: "validation",
data: {
...commonData,
errorCodeMapping: JSON.stringify(block.error_code_mapping, null, 2),
completeCriterion: block.complete_criterion ?? "",
terminateCriterion: block.terminate_criterion ?? "",
parameterKeys: block.parameters.map((p) => p.key),
},
};
}
case "code": {
return {
...identifiers,
@@ -166,7 +182,6 @@ function convertToNode(
type: "codeBlock",
data: {
...commonData,
editable: true,
code: block.code,
},
};
@@ -178,7 +193,6 @@ function convertToNode(
type: "sendEmail",
data: {
...commonData,
editable: true,
body: block.body,
fileAttachments: block.file_attachments.join(", "),
recipients: block.recipients.join(", "),
@@ -198,7 +212,6 @@ function convertToNode(
type: "textPrompt",
data: {
...commonData,
editable: true,
prompt: block.prompt,
jsonSchema: JSON.stringify(block.json_schema, null, 2),
parameterKeys: block.parameters.map((p) => p.key),
@@ -212,7 +225,6 @@ function convertToNode(
type: "loop",
data: {
...commonData,
editable: true,
loopValue: block.loop_over.key,
},
};
@@ -224,7 +236,6 @@ function convertToNode(
type: "fileParser",
data: {
...commonData,
editable: true,
fileUrl: block.file_url,
},
};
@@ -237,7 +248,6 @@ function convertToNode(
type: "download",
data: {
...commonData,
editable: true,
url: block.url,
},
};
@@ -250,7 +260,6 @@ function convertToNode(
type: "upload",
data: {
...commonData,
editable: true,
path: block.path,
},
};
@@ -456,6 +465,17 @@ function createNode(
},
};
}
case "validation": {
return {
...identifiers,
...common,
type: "validation",
data: {
...validationNodeDefaultData,
label,
},
};
}
case "loop": {
return {
...identifiers,
@@ -575,6 +595,19 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML {
cache_actions: node.data.cacheActions,
};
}
case "validation": {
return {
...base,
block_type: "validation",
complete_criterion: node.data.completeCriterion,
terminate_criterion: node.data.terminateCriterion,
parameter_keys: node.data.parameterKeys,
error_code_mapping: JSONParseSafe(node.data.errorCodeMapping) as Record<
string,
string
> | null,
};
}
case "sendEmail": {
return {
...base,
@@ -1045,6 +1078,17 @@ function convertBlocks(blocks: Array<WorkflowBlock>): Array<BlockYAML> {
};
return blockYaml;
}
case "validation": {
const blockYaml: ValidationBlockYAML = {
...base,
block_type: "validation",
complete_criterion: block.complete_criterion,
terminate_criterion: block.terminate_criterion,
error_code_mapping: block.error_code_mapping,
parameter_keys: block.parameters.map((p) => p.key),
};
return blockYaml;
}
case "for_loop": {
const blockYaml: ForLoopBlockYAML = {
...base,

View File

@@ -189,6 +189,14 @@ export type FileURLParserBlock = WorkflowBlockBase & {
file_type: "csv";
};
export type ValidationBlock = WorkflowBlockBase & {
block_type: "validation";
complete_criterion: string | null;
terminate_criterion: string | null;
error_code_mapping: Record<string, string> | null;
parameters: Array<WorkflowParameter>;
};
export type WorkflowBlock =
| TaskBlock
| ForLoopBlock
@@ -197,7 +205,8 @@ export type WorkflowBlock =
| UploadToS3Block
| DownloadToS3Block
| SendEmailBlock
| FileURLParserBlock;
| FileURLParserBlock
| ValidationBlock;
export type WorkflowDefinition = {
parameters: Array<Parameter>;

View File

@@ -75,6 +75,7 @@ const BlockTypes = {
UPLOAD_TO_S3: "upload_to_s3",
SEND_EMAIL: "send_email",
FILE_URL_PARSER: "file_url_parser",
VALIDATION: "validation",
} as const;
export type BlockType = (typeof BlockTypes)[keyof typeof BlockTypes];
@@ -87,7 +88,8 @@ export type BlockYAML =
| UploadToS3BlockYAML
| SendEmailBlockYAML
| FileUrlParserBlockYAML
| ForLoopBlockYAML;
| ForLoopBlockYAML
| ValidationBlockYAML;
export type BlockYAMLBase = {
block_type: BlockType;
@@ -113,6 +115,14 @@ export type TaskBlockYAML = BlockYAMLBase & {
cache_actions: boolean;
};
export type ValidationBlockYAML = BlockYAMLBase & {
block_type: "validation";
complete_criterion: string | null;
terminate_criterion: string | null;
error_code_mapping: Record<string, string> | null;
parameter_keys?: Array<string> | null;
};
export type CodeBlockYAML = BlockYAMLBase & {
block_type: "code";
code: string;