From 2fb379b102a0ee215a3d802ed45b619c998b0a08 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Tue, 5 Mar 2024 15:46:28 +0100 Subject: [PATCH] :sparkles: Add "turn into" option in block context menu Closes #1302 --- apps/builder/package.json | 1 + apps/builder/src/components/ContextMenu.tsx | 4 +- apps/builder/src/components/icons.tsx | 9 ++ .../components/ForgedBlockTurnIntoMenu.tsx | 95 +++++++++++++++++++ .../features/forge/hooks/useForgedBlock.ts | 7 +- .../components/nodes/block/BlockNode.tsx | 51 +++++++++- .../nodes/block/BlockNodeContextMenu.tsx | 23 ++++- .../blocks/chatNode/actions/sendMessage.ts | 1 + .../mistral/actions/createChatCompletion.ts | 9 ++ packages/forge/blocks/mistral/auth.ts | 2 +- .../actions/createChatCompletion.tsx | 9 ++ .../openai/actions/createChatCompletion.tsx | 9 ++ .../actions/createChatCompletion.tsx | 9 ++ packages/forge/core/package.json | 3 +- packages/forge/core/types.ts | 10 ++ pnpm-lock.yaml | 15 +++ 16 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 apps/builder/src/features/forge/components/ForgedBlockTurnIntoMenu.tsx diff --git a/apps/builder/package.json b/apps/builder/package.json index d9fae736d..5bc55fe85 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -91,6 +91,7 @@ "tinycolor2": "1.6.0", "unsplash-js": "7.0.18", "use-debounce": "9.0.4", + "zod-validation-error": "3.0.3", "zustand": "4.5.0" }, "devDependencies": { diff --git a/apps/builder/src/components/ContextMenu.tsx b/apps/builder/src/components/ContextMenu.tsx index 9cb4e4e61..c40f1767c 100644 --- a/apps/builder/src/components/ContextMenu.tsx +++ b/apps/builder/src/components/ContextMenu.tsx @@ -18,7 +18,7 @@ import { export interface ContextMenuProps { onOpen?: () => void - renderMenu: () => JSX.Element | null + renderMenu: ({ onClose }: { onClose: () => void }) => JSX.Element | null children: ( ref: MutableRefObject, isOpened: boolean @@ -101,7 +101,7 @@ export function ContextMenu( }} {...props.menuButtonProps} /> - {props.renderMenu()} + {props.renderMenu({ onClose: onCloseHandler })} )} diff --git a/apps/builder/src/components/icons.tsx b/apps/builder/src/components/icons.tsx index 8d23227ff..fbe3d2e92 100644 --- a/apps/builder/src/components/icons.tsx +++ b/apps/builder/src/components/icons.tsx @@ -662,3 +662,12 @@ export const UnlinkIcon = (props: IconProps) => ( ) + +export const RepeatIcon = (props: IconProps) => ( + + + + + + +) diff --git a/apps/builder/src/features/forge/components/ForgedBlockTurnIntoMenu.tsx b/apps/builder/src/features/forge/components/ForgedBlockTurnIntoMenu.tsx new file mode 100644 index 000000000..d7be84cae --- /dev/null +++ b/apps/builder/src/features/forge/components/ForgedBlockTurnIntoMenu.tsx @@ -0,0 +1,95 @@ +import { + MenuList, + MenuItem, + useDisclosure, + Menu, + MenuButton, + HStack, + Text, +} from '@chakra-ui/react' +import { RepeatIcon, ChevronRightIcon } from '@/components/icons' +import { useDebouncedCallback } from 'use-debounce' +import { useForgedBlock } from '@/features/forge/hooks/useForgedBlock' +import { ForgedBlockIcon } from '@/features/forge/ForgedBlockIcon' +import { ForgedBlock } from '@typebot.io/forge-schemas' +import { TurnableIntoParam } from '@typebot.io/forge' +import { ZodObject } from 'zod' +import { BlockV6 } from '@typebot.io/schemas' + +type Props = { + block: BlockV6 + onTurnIntoClick: ( + params: TurnableIntoParam, + /* eslint-disable @typescript-eslint/no-explicit-any */ + blockSchema: ZodObject + ) => void +} + +export const ForgedBlockTurnIntoMenu = ({ block, onTurnIntoClick }: Props) => { + const { actionDef } = useForgedBlock( + block.type, + 'options' in block ? block.options?.action : undefined + ) + const { onClose, onOpen, isOpen } = useDisclosure() + const debounceSubMenuClose = useDebouncedCallback(onClose, 200) + + const handleMouseEnter = () => { + debounceSubMenuClose.cancel() + onOpen() + } + + if ( + !actionDef || + !actionDef?.turnableInto || + actionDef?.turnableInto.length === 0 + ) + return null + return ( + + } + > + + Turn into + + + + + {actionDef.turnableInto.map((params) => ( + onTurnIntoClick(params, blockSchema)} + /> + ))} + + + ) +} + +const TurnIntoMenuItem = ({ + blockType, + onClick, +}: { + blockType: ForgedBlock['type'] + onClick: (blockSchema: ZodObject) => void +}) => { + const { blockDef, blockSchema } = useForgedBlock(blockType) + + if (!blockDef || !blockSchema) return null + return ( + } + onClick={() => onClick(blockSchema)} + > + {blockDef.name} + + ) +} diff --git a/apps/builder/src/features/forge/hooks/useForgedBlock.ts b/apps/builder/src/features/forge/hooks/useForgedBlock.ts index 2be2bb11a..14edc33f2 100644 --- a/apps/builder/src/features/forge/hooks/useForgedBlock.ts +++ b/apps/builder/src/features/forge/hooks/useForgedBlock.ts @@ -1,12 +1,9 @@ import { useMemo } from 'react' import { forgedBlockSchemas, forgedBlocks } from '@typebot.io/forge-schemas' import { enabledBlocks } from '@typebot.io/forge-repository' -import { BlockWithOptions } from '@typebot.io/schemas' +import { BlockV6 } from '@typebot.io/schemas' -export const useForgedBlock = ( - blockType: BlockWithOptions['type'], - action?: string -) => +export const useForgedBlock = (blockType: BlockV6['type'], action?: string) => useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((enabledBlocks as any).includes(blockType) === false) return {} diff --git a/apps/builder/src/features/graph/components/nodes/block/BlockNode.tsx b/apps/builder/src/features/graph/components/nodes/block/BlockNode.tsx index 3ce6904f7..961b2b77a 100644 --- a/apps/builder/src/features/graph/components/nodes/block/BlockNode.tsx +++ b/apps/builder/src/features/graph/components/nodes/block/BlockNode.tsx @@ -45,6 +45,10 @@ import { SettingsModal } from './SettingsModal' import { TElement } from '@udecode/plate-common' import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants' import { useGroupsStore } from '@/features/graph/hooks/useGroupsStore' +import { TurnableIntoParam } from '@typebot.io/forge' +import { ZodError, ZodObject } from 'zod' +import { toast } from 'sonner' +import { fromZodError } from 'zod-validation-error' export const BlockNode = ({ block, @@ -186,6 +190,42 @@ export const BlockNode = ({ } }, []) + const convertBlock = ( + turnIntoParams: TurnableIntoParam, + /* eslint-disable @typescript-eslint/no-explicit-any */ + targetBlockSchema: ZodObject + ) => { + if (!('options' in block) || !block.options) return + + const convertedBlockOptions = turnIntoParams.customMapping + ? turnIntoParams.customMapping(block.options) + : block.options + try { + updateBlock( + indices, + targetBlockSchema.parse({ + ...block, + type: turnIntoParams.blockType, + options: { + ...convertedBlockOptions, + credentialsId: undefined, + }, + } as Block) + ) + setOpenedBlockId(block.id) + } catch (error) { + if (error instanceof ZodError) { + const validationError = fromZodError(error) + console.error(validationError) + toast.error('Could not convert block', { + description: validationError.toString(), + }) + } else { + toast.error('An error occured while converting the block') + } + } + } + const hasIcomingEdge = typebot?.edges.some((edge) => { return edge.to.blockId === block.id }) @@ -198,7 +238,16 @@ export const BlockNode = ({ /> ) : ( - renderMenu={() => } + renderMenu={({ onClose }) => ( + { + convertBlock(params, schema) + onClose() + }} + /> + )} > {(ref, isContextMenuOpened) => ( { +type Props = { + indices: BlockIndices + block: BlockV6 + /* eslint-disable @typescript-eslint/no-explicit-any */ + onTurnIntoClick: (params: TurnableIntoParam, schema: ZodObject) => void +} + +export const BlockNodeContextMenu = ({ + indices, + block, + onTurnIntoClick, +}: Props) => { const { t } = useTranslate() const { deleteBlock, duplicateBlock } = useTypebot() @@ -15,6 +28,10 @@ export const BlockNodeContextMenu = ({ indices }: Props) => { return ( + } onClick={handleDuplicateClick}> {t('duplicate')} diff --git a/packages/forge/blocks/chatNode/actions/sendMessage.ts b/packages/forge/blocks/chatNode/actions/sendMessage.ts index 5fe52bc79..dabb9f1fc 100644 --- a/packages/forge/blocks/chatNode/actions/sendMessage.ts +++ b/packages/forge/blocks/chatNode/actions/sendMessage.ts @@ -8,6 +8,7 @@ import { ChatNodeResponse } from '../types' export const sendMessage = createAction({ auth, name: 'Send Message', + turnableInto: undefined, options: option.object({ botId: option.string.layout({ label: 'Bot ID', diff --git a/packages/forge/blocks/mistral/actions/createChatCompletion.ts b/packages/forge/blocks/mistral/actions/createChatCompletion.ts index 252a93787..b9b8bd583 100644 --- a/packages/forge/blocks/mistral/actions/createChatCompletion.ts +++ b/packages/forge/blocks/mistral/actions/createChatCompletion.ts @@ -67,6 +67,15 @@ export const createChatCompletion = createAction({ name: 'Create chat completion', auth, options, + turnableInto: [ + { + blockType: 'openai', + }, + { + blockType: 'together-ai', + }, + { blockType: 'open-router' }, + ], getSetVariableIds: (options) => options.responseMapping?.map((res) => res.variableId).filter(isDefined) ?? [], diff --git a/packages/forge/blocks/mistral/auth.ts b/packages/forge/blocks/mistral/auth.ts index df8ae5481..f1be792f1 100644 --- a/packages/forge/blocks/mistral/auth.ts +++ b/packages/forge/blocks/mistral/auth.ts @@ -9,7 +9,7 @@ export const auth = { isRequired: true, inputType: 'password', helperText: - 'You can generate an API key [here](https://console.mistral.ai/user/api-keys/).', + 'You can generate an API key [here](https://console.mistral.ai/api-keys).', }), }), } satisfies AuthDefinition diff --git a/packages/forge/blocks/openRouter/actions/createChatCompletion.tsx b/packages/forge/blocks/openRouter/actions/createChatCompletion.tsx index f7be41ce2..1859fdf4f 100644 --- a/packages/forge/blocks/openRouter/actions/createChatCompletion.tsx +++ b/packages/forge/blocks/openRouter/actions/createChatCompletion.tsx @@ -12,6 +12,15 @@ import { ModelsResponse } from '../types' export const createChatCompletion = createAction({ name: 'Create chat completion', auth, + turnableInto: [ + { + blockType: 'openai', + }, + { + blockType: 'together-ai', + }, + { blockType: 'mistral' }, + ], options: parseChatCompletionOptions({ modelFetchId: 'fetchModels', }), diff --git a/packages/forge/blocks/openai/actions/createChatCompletion.tsx b/packages/forge/blocks/openai/actions/createChatCompletion.tsx index 603ececa8..22f11522f 100644 --- a/packages/forge/blocks/openai/actions/createChatCompletion.tsx +++ b/packages/forge/blocks/openai/actions/createChatCompletion.tsx @@ -19,6 +19,15 @@ export const createChatCompletion = createAction({ modelFetchId: 'fetchModels', }), getSetVariableIds: getChatCompletionSetVarIds, + turnableInto: [ + { + blockType: 'open-router', + }, + { + blockType: 'together-ai', + }, + { blockType: 'mistral' }, + ], fetchers: [ { id: 'fetchModels', diff --git a/packages/forge/blocks/togetherAi/actions/createChatCompletion.tsx b/packages/forge/blocks/togetherAi/actions/createChatCompletion.tsx index 2bbe02850..284af945f 100644 --- a/packages/forge/blocks/togetherAi/actions/createChatCompletion.tsx +++ b/packages/forge/blocks/togetherAi/actions/createChatCompletion.tsx @@ -14,6 +14,15 @@ export const createChatCompletion = createAction({ modelHelperText: 'You can find the list of all the models available [here](https://docs.together.ai/docs/inference-models#chat-models). Copy the model string for API.', }), + turnableInto: [ + { + blockType: 'openai', + }, + { + blockType: 'open-router', + }, + { blockType: 'mistral' }, + ], getSetVariableIds: getChatCompletionSetVarIds, run: { server: (params) => diff --git a/packages/forge/core/package.json b/packages/forge/core/package.json index 173ff867c..36a394490 100644 --- a/packages/forge/core/package.json +++ b/packages/forge/core/package.json @@ -11,6 +11,7 @@ }, "devDependencies": { "@typebot.io/tsconfig": "workspace:*", - "@types/react": "18.2.15" + "@types/react": "18.2.15", + "@typebot.io/forge-repository": "workspace:*" } } diff --git a/packages/forge/core/types.ts b/packages/forge/core/types.ts index 277cb31e4..138d6d1db 100644 --- a/packages/forge/core/types.ts +++ b/packages/forge/core/types.ts @@ -1,6 +1,7 @@ import { SVGProps } from 'react' import { z } from './zod' import { ZodRawShape } from 'zod' +import { enabledBlocks } from '@typebot.io/forge-repository' export type VariableStore = { get: (variableId: string) => string | (string | null)[] | null | undefined @@ -32,6 +33,14 @@ export type FunctionToExecute = { export type ReadOnlyVariableStore = Omit +export type TurnableIntoParam = { + blockType: (typeof enabledBlocks)[number] + /** + * If defined will be used to convert the existing block options into the new block options. + */ + customMapping?: (options: T) => any +} + export type ActionDefinition< A extends AuthDefinition, BaseOptions extends z.ZodObject = z.ZodObject<{}>, @@ -40,6 +49,7 @@ export type ActionDefinition< name: string fetchers?: FetcherDefinition & z.infer>[] options?: Options + turnableInto?: TurnableIntoParam>[] getSetVariableIds?: (options: z.infer) => string[] run?: { server?: (params: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58148f046..09ed72d85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,6 +263,9 @@ importers: use-debounce: specifier: 9.0.4 version: 9.0.4(react@18.2.0) + zod-validation-error: + specifier: 3.0.3 + version: 3.0.3(zod@3.22.4) zustand: specifier: 4.5.0 version: 4.5.0(@types/react@18.2.15)(immer@10.0.2)(react@18.2.0) @@ -1414,6 +1417,9 @@ importers: specifier: 3.22.4 version: 3.22.4 devDependencies: + '@typebot.io/forge-repository': + specifier: workspace:* + version: link:../repository '@typebot.io/tsconfig': specifier: workspace:* version: link:../../tsconfig @@ -22787,6 +22793,15 @@ packages: zod: 3.22.4 dev: true + /zod-validation-error@3.0.3(zod@3.22.4): + resolution: {integrity: sha512-cETTrcMq3Ze58vhdR0zD37uJm/694I6mAxcf/ei5bl89cC++fBNxrC2z8lkFze/8hVMPwrbtrwXHR2LB50fpHw==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.18.0 + dependencies: + zod: 3.22.4 + dev: false + /zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}