2
0

Add "turn into" option in block context menu

Closes #1302
This commit is contained in:
Baptiste Arnaud
2024-03-05 15:46:28 +01:00
parent 84d6c594af
commit 2fb379b102
16 changed files with 244 additions and 13 deletions

View File

@ -91,6 +91,7 @@
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"unsplash-js": "7.0.18", "unsplash-js": "7.0.18",
"use-debounce": "9.0.4", "use-debounce": "9.0.4",
"zod-validation-error": "3.0.3",
"zustand": "4.5.0" "zustand": "4.5.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -18,7 +18,7 @@ import {
export interface ContextMenuProps<T extends HTMLElement> { export interface ContextMenuProps<T extends HTMLElement> {
onOpen?: () => void onOpen?: () => void
renderMenu: () => JSX.Element | null renderMenu: ({ onClose }: { onClose: () => void }) => JSX.Element | null
children: ( children: (
ref: MutableRefObject<T | null>, ref: MutableRefObject<T | null>,
isOpened: boolean isOpened: boolean
@ -101,7 +101,7 @@ export function ContextMenu<T extends HTMLElement = HTMLElement>(
}} }}
{...props.menuButtonProps} {...props.menuButtonProps}
/> />
{props.renderMenu()} {props.renderMenu({ onClose: onCloseHandler })}
</Menu> </Menu>
</Portal> </Portal>
)} )}

View File

@ -662,3 +662,12 @@ export const UnlinkIcon = (props: IconProps) => (
<line x1="19" x2="22" y1="16" y2="16" /> <line x1="19" x2="22" y1="16" y2="16" />
</Icon> </Icon>
) )
export const RepeatIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="m2 9 3-3 3 3" />
<path d="M13 18H7a2 2 0 0 1-2-2V6" />
<path d="m22 15-3 3-3-3" />
<path d="M11 6h6a2 2 0 0 1 2 2v10" />
</Icon>
)

View File

@ -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<any>
) => 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 (
<Menu isOpen={isOpen} placement="right" offset={[0, 0]} onClose={onClose}>
<MenuButton
as={MenuItem}
onClick={onOpen}
onMouseEnter={handleMouseEnter}
onMouseLeave={debounceSubMenuClose}
icon={<RepeatIcon />}
>
<HStack justifyContent="space-between">
<Text>Turn into</Text>
<ChevronRightIcon />
</HStack>
</MenuButton>
<MenuList
onMouseEnter={handleMouseEnter}
onMouseLeave={debounceSubMenuClose}
>
{actionDef.turnableInto.map((params) => (
<TurnIntoMenuItem
key={params.blockType}
blockType={params.blockType}
onClick={(blockSchema) => onTurnIntoClick(params, blockSchema)}
/>
))}
</MenuList>
</Menu>
)
}
const TurnIntoMenuItem = ({
blockType,
onClick,
}: {
blockType: ForgedBlock['type']
onClick: (blockSchema: ZodObject<any>) => void
}) => {
const { blockDef, blockSchema } = useForgedBlock(blockType)
if (!blockDef || !blockSchema) return null
return (
<MenuItem
icon={<ForgedBlockIcon type={blockType} />}
onClick={() => onClick(blockSchema)}
>
{blockDef.name}
</MenuItem>
)
}

View File

@ -1,12 +1,9 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { forgedBlockSchemas, forgedBlocks } from '@typebot.io/forge-schemas' import { forgedBlockSchemas, forgedBlocks } from '@typebot.io/forge-schemas'
import { enabledBlocks } from '@typebot.io/forge-repository' import { enabledBlocks } from '@typebot.io/forge-repository'
import { BlockWithOptions } from '@typebot.io/schemas' import { BlockV6 } from '@typebot.io/schemas'
export const useForgedBlock = ( export const useForgedBlock = (blockType: BlockV6['type'], action?: string) =>
blockType: BlockWithOptions['type'],
action?: string
) =>
useMemo(() => { useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((enabledBlocks as any).includes(blockType) === false) return {} if ((enabledBlocks as any).includes(blockType) === false) return {}

View File

@ -45,6 +45,10 @@ import { SettingsModal } from './SettingsModal'
import { TElement } from '@udecode/plate-common' import { TElement } from '@udecode/plate-common'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants' import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
import { useGroupsStore } from '@/features/graph/hooks/useGroupsStore' 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 = ({ export const BlockNode = ({
block, block,
@ -186,6 +190,42 @@ export const BlockNode = ({
} }
}, []) }, [])
const convertBlock = (
turnIntoParams: TurnableIntoParam,
/* eslint-disable @typescript-eslint/no-explicit-any */
targetBlockSchema: ZodObject<any>
) => {
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) => { const hasIcomingEdge = typebot?.edges.some((edge) => {
return edge.to.blockId === block.id return edge.to.blockId === block.id
}) })
@ -198,7 +238,16 @@ export const BlockNode = ({
/> />
) : ( ) : (
<ContextMenu<HTMLDivElement> <ContextMenu<HTMLDivElement>
renderMenu={() => <BlockNodeContextMenu indices={indices} />} renderMenu={({ onClose }) => (
<BlockNodeContextMenu
indices={indices}
block={block}
onTurnIntoClick={(params, schema) => {
convertBlock(params, schema)
onClose()
}}
/>
)}
> >
{(ref, isContextMenuOpened) => ( {(ref, isContextMenuOpened) => (
<Popover <Popover

View File

@ -1,11 +1,24 @@
import { MenuList, MenuItem } from '@chakra-ui/react' import { MenuList, MenuItem } from '@chakra-ui/react'
import { CopyIcon, TrashIcon } from '@/components/icons' import { CopyIcon, TrashIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor/providers/TypebotProvider' import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { BlockIndices } from '@typebot.io/schemas' import { BlockIndices, BlockV6 } from '@typebot.io/schemas'
import { useTranslate } from '@tolgee/react' import { useTranslate } from '@tolgee/react'
import { ForgedBlockTurnIntoMenu } from '@/features/forge/components/ForgedBlockTurnIntoMenu'
import { TurnableIntoParam } from '@typebot.io/forge'
import { ZodObject } from 'zod'
type Props = { indices: BlockIndices } type Props = {
export const BlockNodeContextMenu = ({ indices }: Props) => { indices: BlockIndices
block: BlockV6
/* eslint-disable @typescript-eslint/no-explicit-any */
onTurnIntoClick: (params: TurnableIntoParam, schema: ZodObject<any>) => void
}
export const BlockNodeContextMenu = ({
indices,
block,
onTurnIntoClick,
}: Props) => {
const { t } = useTranslate() const { t } = useTranslate()
const { deleteBlock, duplicateBlock } = useTypebot() const { deleteBlock, duplicateBlock } = useTypebot()
@ -15,6 +28,10 @@ export const BlockNodeContextMenu = ({ indices }: Props) => {
return ( return (
<MenuList> <MenuList>
<ForgedBlockTurnIntoMenu
block={block}
onTurnIntoClick={onTurnIntoClick}
/>
<MenuItem icon={<CopyIcon />} onClick={handleDuplicateClick}> <MenuItem icon={<CopyIcon />} onClick={handleDuplicateClick}>
{t('duplicate')} {t('duplicate')}
</MenuItem> </MenuItem>

View File

@ -8,6 +8,7 @@ import { ChatNodeResponse } from '../types'
export const sendMessage = createAction({ export const sendMessage = createAction({
auth, auth,
name: 'Send Message', name: 'Send Message',
turnableInto: undefined,
options: option.object({ options: option.object({
botId: option.string.layout({ botId: option.string.layout({
label: 'Bot ID', label: 'Bot ID',

View File

@ -67,6 +67,15 @@ export const createChatCompletion = createAction({
name: 'Create chat completion', name: 'Create chat completion',
auth, auth,
options, options,
turnableInto: [
{
blockType: 'openai',
},
{
blockType: 'together-ai',
},
{ blockType: 'open-router' },
],
getSetVariableIds: (options) => getSetVariableIds: (options) =>
options.responseMapping?.map((res) => res.variableId).filter(isDefined) ?? options.responseMapping?.map((res) => res.variableId).filter(isDefined) ??
[], [],

View File

@ -9,7 +9,7 @@ export const auth = {
isRequired: true, isRequired: true,
inputType: 'password', inputType: 'password',
helperText: 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 } satisfies AuthDefinition

View File

@ -12,6 +12,15 @@ import { ModelsResponse } from '../types'
export const createChatCompletion = createAction({ export const createChatCompletion = createAction({
name: 'Create chat completion', name: 'Create chat completion',
auth, auth,
turnableInto: [
{
blockType: 'openai',
},
{
blockType: 'together-ai',
},
{ blockType: 'mistral' },
],
options: parseChatCompletionOptions({ options: parseChatCompletionOptions({
modelFetchId: 'fetchModels', modelFetchId: 'fetchModels',
}), }),

View File

@ -19,6 +19,15 @@ export const createChatCompletion = createAction({
modelFetchId: 'fetchModels', modelFetchId: 'fetchModels',
}), }),
getSetVariableIds: getChatCompletionSetVarIds, getSetVariableIds: getChatCompletionSetVarIds,
turnableInto: [
{
blockType: 'open-router',
},
{
blockType: 'together-ai',
},
{ blockType: 'mistral' },
],
fetchers: [ fetchers: [
{ {
id: 'fetchModels', id: 'fetchModels',

View File

@ -14,6 +14,15 @@ export const createChatCompletion = createAction({
modelHelperText: 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.', '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, getSetVariableIds: getChatCompletionSetVarIds,
run: { run: {
server: (params) => server: (params) =>

View File

@ -11,6 +11,7 @@
}, },
"devDependencies": { "devDependencies": {
"@typebot.io/tsconfig": "workspace:*", "@typebot.io/tsconfig": "workspace:*",
"@types/react": "18.2.15" "@types/react": "18.2.15",
"@typebot.io/forge-repository": "workspace:*"
} }
} }

View File

@ -1,6 +1,7 @@
import { SVGProps } from 'react' import { SVGProps } from 'react'
import { z } from './zod' import { z } from './zod'
import { ZodRawShape } from 'zod' import { ZodRawShape } from 'zod'
import { enabledBlocks } from '@typebot.io/forge-repository'
export type VariableStore = { export type VariableStore = {
get: (variableId: string) => string | (string | null)[] | null | undefined get: (variableId: string) => string | (string | null)[] | null | undefined
@ -32,6 +33,14 @@ export type FunctionToExecute = {
export type ReadOnlyVariableStore = Omit<VariableStore, 'set'> export type ReadOnlyVariableStore = Omit<VariableStore, 'set'>
export type TurnableIntoParam<T = {}> = {
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< export type ActionDefinition<
A extends AuthDefinition, A extends AuthDefinition,
BaseOptions extends z.ZodObject<ZodRawShape> = z.ZodObject<{}>, BaseOptions extends z.ZodObject<ZodRawShape> = z.ZodObject<{}>,
@ -40,6 +49,7 @@ export type ActionDefinition<
name: string name: string
fetchers?: FetcherDefinition<A, z.infer<BaseOptions> & z.infer<Options>>[] fetchers?: FetcherDefinition<A, z.infer<BaseOptions> & z.infer<Options>>[]
options?: Options options?: Options
turnableInto?: TurnableIntoParam<z.infer<Options>>[]
getSetVariableIds?: (options: z.infer<Options>) => string[] getSetVariableIds?: (options: z.infer<Options>) => string[]
run?: { run?: {
server?: (params: { server?: (params: {

15
pnpm-lock.yaml generated
View File

@ -263,6 +263,9 @@ importers:
use-debounce: use-debounce:
specifier: 9.0.4 specifier: 9.0.4
version: 9.0.4(react@18.2.0) version: 9.0.4(react@18.2.0)
zod-validation-error:
specifier: 3.0.3
version: 3.0.3(zod@3.22.4)
zustand: zustand:
specifier: 4.5.0 specifier: 4.5.0
version: 4.5.0(@types/react@18.2.15)(immer@10.0.2)(react@18.2.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 specifier: 3.22.4
version: 3.22.4 version: 3.22.4
devDependencies: devDependencies:
'@typebot.io/forge-repository':
specifier: workspace:*
version: link:../repository
'@typebot.io/tsconfig': '@typebot.io/tsconfig':
specifier: workspace:* specifier: workspace:*
version: link:../../tsconfig version: link:../../tsconfig
@ -22787,6 +22793,15 @@ packages:
zod: 3.22.4 zod: 3.22.4
dev: true 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: /zod@3.22.4:
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}