Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 84 additions & 50 deletions apps/builder/src/features/graph/components/nodes/block/BlockNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
HStack,
Popover,
PopoverTrigger,
Text,
Tooltip,
useColorModeValue,
useDisclosure,
} from "@chakra-ui/react";
Expand Down Expand Up @@ -45,6 +47,10 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { ZodError, type ZodObject } from "zod";
import { fromZodError } from "zod-validation-error";
import {
type BlockValidationError,
useBlockValidation,
} from "../../../hooks/useBlockValidation";
import { BlockSourceEndpoint } from "../../endpoints/BlockSourceEndpoint";
import { TargetEndpoint } from "../../endpoints/TargetEndpoint";
import { BlockNodeContent } from "./BlockNodeContent";
Expand All @@ -66,6 +72,7 @@ export const BlockNode = ({
const bg = useColorModeValue("gray.50", "gray.900");
const previewingBorderColor = useColorModeValue("orange.400", "orange.300");
const borderColor = useColorModeValue("gray.200", "gray.900");
const errorBorderColor = useColorModeValue("red.400", "red.300");
const { pathname, query } = useRouter();
const {
setConnectingIds,
Expand All @@ -84,6 +91,9 @@ export const BlockNode = ({
const [isContextMenuReady, setIsContextMenuReady] = useState(true);
const blockRef = useRef<HTMLDivElement | null>(null);

// Block validation
const { isValid, errors, hasErrors } = useBlockValidation(block);

const isPreviewing =
isConnecting ||
previewingEdge?.to.blockId === block.id ||
Expand Down Expand Up @@ -269,61 +279,85 @@ export const BlockNode = ({
className="prevent-group-drag"
pointerEvents={isAnalytics || isDraggingGraph ? "none" : "auto"}
>
<HStack
flex="1"
userSelect="none"
p="3"
borderWidth={
isContextMenuOpened || isPreviewing ? "2px" : "1px"
<Tooltip
label={
hasErrors ? (
<Text>
Block configuration issues:
{errors.map(
(error: BlockValidationError, index: number) => (
<Text key={index} fontSize="sm" mt={1}>
• {error.message}
</Text>
),
)}
</Text>
) : undefined
}
borderColor={
isContextMenuOpened || isPreviewing
? previewingBorderColor
: borderColor
}
margin={isContextMenuOpened || isPreviewing ? "-1px" : 0}
rounded="lg"
cursor={"pointer"}
bg={bg}
align="flex-start"
w="full"
transition="border-color 0.2s"
hasArrow
placement="top"
isDisabled={!hasErrors}
rounded="md"
p="3"
>
<BlockIcon type={block.type} mt=".25rem" />
{typebot?.groups.at(indices.groupIndex)?.id && (
<BlockNodeContent
block={block}
indices={indices}
groupId={
typebot.groups.at(indices.groupIndex)?.id as string
}
/>
)}
{(hasIcomingEdge || isDefined(connectingIds)) && (
<TargetEndpoint
pos="absolute"
left="-34px"
top="16px"
blockId={block.id}
groupId={groupId}
/>
)}
{(isConnectable ||
(pathname.endsWith("analytics") && isInputBlock(block))) &&
hasDefaultConnector(block) &&
groupId && (
<BlockSourceEndpoint
source={{
blockId: block.id,
}}
groupId={groupId}
<HStack
flex="1"
userSelect="none"
p="3"
borderWidth={
isContextMenuOpened || isPreviewing ? "2px" : "1px"
}
borderColor={
isContextMenuOpened || isPreviewing
? previewingBorderColor
: hasErrors
? errorBorderColor
: borderColor
}
margin={isContextMenuOpened || isPreviewing ? "-1px" : 0}
rounded="lg"
cursor={"pointer"}
bg={bg}
align="flex-start"
w="full"
transition="border-color 0.2s"
>
<BlockIcon type={block.type} mt=".25rem" />
{typebot?.groups.at(indices.groupIndex)?.id && (
<BlockNodeContent
block={block}
indices={indices}
groupId={
typebot.groups.at(indices.groupIndex)?.id as string
}
/>
)}
{(hasIcomingEdge || isDefined(connectingIds)) && (
<TargetEndpoint
pos="absolute"
right="-34px"
bottom="10px"
isHidden={!isConnectable}
left="-34px"
top="16px"
blockId={block.id}
groupId={groupId}
/>
)}
</HStack>
{(isConnectable ||
(pathname.endsWith("analytics") && isInputBlock(block))) &&
hasDefaultConnector(block) &&
groupId && (
<BlockSourceEndpoint
source={{
blockId: block.id,
}}
groupId={groupId}
pos="absolute"
right="-34px"
bottom="10px"
isHidden={!isConnectable}
/>
)}
</HStack>
</Tooltip>
</Flex>
</PopoverTrigger>
{hasSettingsPopover(block) && (
Expand Down
110 changes: 110 additions & 0 deletions apps/builder/src/features/graph/helpers/blockValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {
type BlockV6,
blockSchemaV6,
} from "@typebot.io/blocks-core/schemas/schema";
import { ZodError, type ZodIssue } from "zod";

export interface BlockValidationError {
field: string;
message: string;
path: (string | number)[];
}

export interface BlockValidationResult {
isValid: boolean;
errors: BlockValidationError[];
}

/**
* Converts Zod validation errors to a more user-friendly format
*/
const formatZodErrors = (zodError: ZodError): BlockValidationError[] => {
return zodError.errors.map((issue: ZodIssue) => {
const field = issue.path.length > 0 ? issue.path.join(".") : "block";

// Create more user-friendly error messages
let message = issue.message;

switch (issue.code) {
case "invalid_type":
if (issue.expected === "string" && issue.received === "undefined") {
message = "This field is required";
} else {
message = `Expected ${issue.expected}, got ${issue.received}`;
}
break;
case "too_small":
if (issue.type === "string") {
message = `Must be at least ${issue.minimum} characters`;
} else if (issue.type === "array") {
message = `Must have at least ${issue.minimum} items`;
} else {
message = `Must be at least ${issue.minimum}`;
}
break;
case "too_big":
if (issue.type === "string") {
message = `Must be no more than ${issue.maximum} characters`;
} else if (issue.type === "array") {
message = `Must have no more than ${issue.maximum} items`;
} else {
message = `Must be no more than ${issue.maximum}`;
}
break;
case "invalid_string":
switch (issue.validation) {
case "email":
message = "Must be a valid email address";
break;
case "url":
message = "Must be a valid URL";
break;
case "regex":
message = "Invalid format";
break;
default:
message = `Invalid ${issue.validation} format`;
}
break;
case "custom":
// Keep custom error messages as they are
break;
default:
// Keep default Zod error message for other cases
}

return {
field,
message,
path: issue.path,
};
});
};

/**
* Validates a block against the unified block schema
*/
export const validateBlock = (block: BlockV6): BlockValidationResult => {
try {
blockSchemaV6.parse(block);
return {
isValid: true,
errors: [],
};
} catch (error) {
if (error instanceof ZodError) {
const errors = formatZodErrors(error);
return {
isValid: false,
errors,
};
}

// If it's not a ZodError, assume the block is valid
// This is a fallback for unexpected errors
return {
isValid: true,
errors: [],
};
}
};
20 changes: 20 additions & 0 deletions apps/builder/src/features/graph/hooks/useBlockValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { BlockV6 } from "@typebot.io/blocks-core/schemas/schema";
import { useMemo } from "react";
import {
type BlockValidationError,
validateBlock,
} from "../helpers/blockValidation";

export const useBlockValidation = (block: BlockV6) => {
const validationResult = useMemo(() => {
return validateBlock(block);
}, [block]);

return {
isValid: validationResult.isValid,
errors: validationResult.errors,
hasErrors: validationResult.errors.length > 0,
};
};

export type { BlockValidationError };