Introducing The Forge (#1072)

The Forge allows anyone to easily create their own Typebot Block.

Closes #380
This commit is contained in:
Baptiste Arnaud
2023-12-13 10:22:02 +01:00
committed by GitHub
parent c373108b55
commit 5e019bbb22
184 changed files with 42659 additions and 37411 deletions

View File

@@ -0,0 +1,23 @@
import { ForgedBlock } from '@typebot.io/forge-schemas'
import { BlockV6 } from '@typebot.io/schemas'
import { BlockIcon } from '../editor/components/BlockIcon'
import { BlockLabel } from '../editor/components/BlockLabel'
import { useForgedBlock } from './hooks/useForgedBlock'
import { BlockCardLayout } from '../editor/components/BlockCardLayout'
export const ForgedBlockCard = (props: {
type: ForgedBlock['type']
onMouseDown: (e: React.MouseEvent, type: BlockV6['type']) => void
}) => {
const { blockDef } = useForgedBlock(props.type)
return (
<BlockCardLayout
{...props}
tooltip={blockDef?.fullName ? blockDef.fullName : undefined}
>
<BlockIcon type={props.type} />
<BlockLabel type={props.type} />
</BlockCardLayout>
)
}

View File

@@ -0,0 +1,18 @@
import { useColorMode } from '@chakra-ui/react'
import { ForgedBlock } from '@typebot.io/forge-schemas'
import { useForgedBlock } from './hooks/useForgedBlock'
export const ForgedBlockIcon = ({
type,
mt,
}: {
type: ForgedBlock['type']
mt?: string
}): JSX.Element => {
const { colorMode } = useColorMode()
const { blockDef } = useForgedBlock(type)
if (!blockDef) return <></>
if (colorMode === 'dark' && blockDef.DarkLogo)
return <blockDef.DarkLogo width="1rem" style={{ marginTop: mt }} />
return <blockDef.LightLogo width="1rem" style={{ marginTop: mt }} />
}

View File

@@ -0,0 +1,9 @@
import { ForgedBlock } from '@typebot.io/forge-schemas'
import { useForgedBlock } from './hooks/useForgedBlock'
import { Text } from '@chakra-ui/react'
export const ForgedBlockLabel = ({ type }: { type: ForgedBlock['type'] }) => {
const { blockDef } = useForgedBlock(type)
return <Text fontSize="sm">{blockDef?.name}</Text>
}

View File

@@ -0,0 +1,63 @@
import prisma from '@typebot.io/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { encrypt } from '@typebot.io/lib/api/encryption/encrypt'
import { z } from 'zod'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
import { forgedCredentialsSchemas } from '@typebot.io/forge-schemas'
const inputShape = {
data: true,
type: true,
workspaceId: true,
name: true,
} as const
export const createCredentials = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/credentials',
protect: true,
},
})
.input(
z.object({
credentials: z.discriminatedUnion(
'type',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
forgedCredentialsSchemas.map((i) => i.pick(inputShape))
),
})
)
.output(
z.object({
credentialsId: z.string(),
})
)
.mutation(async ({ input: { credentials }, ctx: { user } }) => {
const workspace = await prisma.workspace.findFirst({
where: {
id: credentials.workspaceId,
},
select: { id: true, members: true },
})
if (!workspace || isWriteWorkspaceForbidden(workspace, user))
throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' })
const { encryptedData, iv } = await encrypt(credentials.data)
const createdCredentials = await prisma.credentials.create({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
data: {
...credentials,
data: encryptedData,
iv,
},
select: {
id: true,
},
})
return { credentialsId: createdCredentials.id }
})

View File

@@ -0,0 +1,43 @@
import prisma from '@typebot.io/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
export const deleteCredentials = authenticatedProcedure
.input(
z.object({
credentialsId: z.string(),
workspaceId: z.string(),
})
)
.output(
z.object({
credentialsId: z.string(),
})
)
.mutation(
async ({ input: { credentialsId, workspaceId }, ctx: { user } }) => {
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: {
some: { userId: user.id, role: { in: ['ADMIN', 'MEMBER'] } },
},
},
select: { id: true, members: true },
})
if (!workspace || isWriteWorkspaceForbidden(workspace, user))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
await prisma.credentials.delete({
where: {
id: credentialsId,
},
})
return { credentialsId }
}
)

View File

@@ -0,0 +1,43 @@
import prisma from '@typebot.io/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { enabledBlocks } from '@typebot.io/forge-repository'
export const listCredentials = authenticatedProcedure
.input(
z.object({
workspaceId: z.string(),
type: z.enum(enabledBlocks),
})
)
.output(
z.object({
credentials: z.array(z.object({ id: z.string(), name: z.string() })),
})
)
.query(async ({ input: { workspaceId, type }, ctx: { user } }) => {
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
},
select: {
id: true,
members: true,
credentials: {
where: {
type,
},
select: {
id: true,
name: true,
},
},
},
})
if (!workspace || isReadWorkspaceFobidden(workspace, user))
throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' })
return { credentials: workspace.credentials }
})

View File

@@ -0,0 +1,10 @@
import { router } from '@/helpers/server/trpc'
import { createCredentials } from './createCredentials'
import { deleteCredentials } from './deleteCredentials'
import { listCredentials } from './listCredentials'
export const forgedCredentialsRouter = router({
createCredentials,
listCredentials,
deleteCredentials,
})

View File

@@ -0,0 +1,81 @@
import prisma from '@typebot.io/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { forgedBlocks } from '@typebot.io/forge-schemas'
import { decrypt } from '@typebot.io/lib/api/encryption/decrypt'
export const fetchSelectItems = authenticatedProcedure
.input(
z.object({
integrationId: z.string(),
fetcherId: z.string(),
options: z.any(),
workspaceId: z.string(),
})
)
.output(
z.object({
items: z.array(
z.string().or(z.object({ label: z.string(), value: z.string() }))
),
})
)
.query(
async ({
input: { workspaceId, integrationId, fetcherId, options },
ctx: { user },
}) => {
if (!options.credentialsId) return { items: [] }
const workspace = await prisma.workspace.findFirst({
where: { id: workspaceId },
select: {
members: {
select: {
userId: true,
},
},
credentials: {
where: {
id: options.credentialsId,
},
select: {
id: true,
data: true,
iv: true,
},
},
},
})
if (!workspace || isReadWorkspaceFobidden(workspace, user))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No workspace found',
})
const credentials = workspace.credentials.at(0)
if (!credentials) return { items: [] }
const credentialsData = await decrypt(credentials.data, credentials.iv)
const blockDef = forgedBlocks.find((b) => b.id === integrationId)
const fetchers = (blockDef?.fetchers ?? []).concat(
blockDef?.actions.flatMap((action) => action.fetchers ?? []) ?? []
)
const fetcher = fetchers.find((fetcher) => fetcher.id === fetcherId)
if (!fetcher) return { items: [] }
return {
items: await fetcher.fetch({
credentials: credentialsData,
options,
}),
}
}
)

View File

@@ -0,0 +1,6 @@
import { router } from '@/helpers/server/trpc'
import { fetchSelectItems } from './fetchSelectItems'
export const integrationsRouter = router({
fetchSelectItems,
})

View File

@@ -0,0 +1,115 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { Select } from '@/components/inputs/Select'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { useToast } from '@/hooks/useToast'
import { trpc } from '@/lib/trpc'
import {
FormControl,
FormHelperText,
FormLabel,
HStack,
Stack,
} from '@chakra-ui/react'
import { ForgedBlockDefinition, ForgedBlock } from '@typebot.io/forge-schemas'
import { ReactNode, useMemo } from 'react'
type Props = {
blockDef: ForgedBlockDefinition
defaultValue?: string
fetcherId: string
options: ForgedBlock['options']
placeholder?: string
label?: string
helperText?: ReactNode
moreInfoTooltip?: string
direction?: 'row' | 'column'
isRequired?: boolean
onChange: (value: string | undefined) => void
}
export const ForgeSelectInput = ({
defaultValue,
fetcherId,
options,
blockDef,
placeholder,
label,
helperText,
moreInfoTooltip,
isRequired,
direction = 'column',
onChange,
}: Props) => {
const { workspace } = useWorkspace()
const { showToast } = useToast()
const baseFetcher = useMemo(() => {
const fetchers = blockDef.fetchers ?? []
return fetchers.find((fetcher) => fetcher.id === fetcherId)
}, [blockDef.fetchers, fetcherId])
const actionFetcher = useMemo(() => {
if (baseFetcher) return
const fetchers = blockDef.actions.flatMap((action) => action.fetchers ?? [])
return fetchers.find((fetcher) => fetcher.id === fetcherId)
}, [baseFetcher, blockDef.actions, fetcherId])
const { data } = trpc.integrations.fetchSelectItems.useQuery(
{
integrationId: blockDef.id,
options: pick(options, [
...(actionFetcher ? ['action'] : []),
...(blockDef.auth ? ['credentialsId'] : []),
...((baseFetcher
? baseFetcher.dependencies
: actionFetcher?.dependencies) ?? []),
]),
workspaceId: workspace?.id as string,
fetcherId,
},
{
enabled: !!workspace?.id && (!!baseFetcher || !!actionFetcher),
onError: (error) => {
showToast({
description: error.message,
status: 'error',
})
},
}
)
return (
<FormControl
isRequired={isRequired}
as={direction === 'column' ? Stack : HStack}
justifyContent="space-between"
width={label ? 'full' : 'auto'}
spacing={direction === 'column' ? 2 : 3}
>
{label && (
<FormLabel mb="0" mr="0" flexShrink={0}>
{label}{' '}
{moreInfoTooltip && (
<MoreInfoTooltip>{moreInfoTooltip}</MoreInfoTooltip>
)}
</FormLabel>
)}
<Select
items={data?.items}
selectedItem={defaultValue}
onSelect={onChange}
placeholder={placeholder}
/>
{helperText && <FormHelperText mt="0">{helperText}</FormHelperText>}
</FormControl>
)
}
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
if (!obj) return {} as Pick<T, K>
const ret: any = {}
keys.forEach((key) => {
ret[key] = obj[key]
})
return ret
}

View File

@@ -0,0 +1,40 @@
import { SetVariableLabel } from '@/components/SetVariableLabel'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { Stack, Text } from '@chakra-ui/react'
import { useForgedBlock } from '../hooks/useForgedBlock'
import { ForgedBlock } from '@typebot.io/forge-schemas'
type Props = {
block: ForgedBlock
}
export const ForgedBlockNodeContent = ({ block }: Props) => {
const { blockDef, actionDef } = useForgedBlock(
block.type,
block.options?.action
)
const { typebot } = useTypebot()
const setVariableIds = actionDef?.getSetVariableIds?.(block.options) ?? []
return (
<Stack>
<Text
color={block.options?.action ? 'currentcolor' : 'gray.500'}
noOfLines={1}
>
{block.options?.action &&
(!blockDef?.auth || block.options.credentialsId)
? block.options.action
: 'Configure...'}
</Text>
{typebot &&
setVariableIds.map((variableId, idx) => (
<SetVariableLabel
key={variableId + idx}
variables={typebot.variables}
variableId={variableId}
/>
))}
</Stack>
)
}

View File

@@ -0,0 +1,66 @@
import { Stack, useDisclosure } from '@chakra-ui/react'
import { BlockOptions } from '@typebot.io/schemas'
import { ForgedCredentialsDropdown } from './credentials/ForgedCredentialsDropdown'
import { ForgedCredentialsModal } from './credentials/ForgedCredentialsModal'
import { ZodObjectLayout } from './zodLayouts/ZodObjectLayout'
import { ZodActionDiscriminatedUnion } from './zodLayouts/ZodActionDiscriminatedUnion'
import { useForgedBlock } from '../hooks/useForgedBlock'
import { ForgedBlock } from '@typebot.io/forge-schemas'
type Props = {
block: ForgedBlock
onOptionsChange: (options: BlockOptions) => void
}
export const ForgedBlockSettings = ({ block, onOptionsChange }: Props) => {
const { blockDef, blockSchema } = useForgedBlock(block.type)
const { isOpen, onOpen, onClose } = useDisclosure()
const updateCredentialsId = (credentialsId?: string) => {
onOptionsChange({
...block.options,
credentialsId,
})
}
if (!blockDef || !blockSchema) return null
return (
<Stack spacing={4}>
{blockDef.auth && (
<>
<ForgedCredentialsModal
blockDef={blockDef}
isOpen={isOpen}
onClose={onClose}
onNewCredentials={updateCredentialsId}
/>
<ForgedCredentialsDropdown
key={block.options?.credentialsId ?? 'none'}
blockDef={blockDef}
currentCredentialsId={block.options?.credentialsId}
onCredentialsSelect={updateCredentialsId}
onAddClick={onOpen}
/>
</>
)}
{(block.options !== undefined || blockDef.auth === undefined) && (
<>
{blockDef.options && (
<ZodObjectLayout
schema={blockDef.options}
data={block.options}
blockOptions={block.options}
blockDef={blockDef}
onDataChange={onOptionsChange}
/>
)}
<ZodActionDiscriminatedUnion
schema={blockSchema.shape.options}
blockDef={blockDef}
blockOptions={block.options}
onDataChange={onOptionsChange}
/>
</>
)}
</Stack>
)
}

View File

@@ -0,0 +1,176 @@
import {
Button,
ButtonProps,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Stack,
Text,
} from '@chakra-ui/react'
import { ChevronLeftIcon, PlusIcon, TrashIcon } from '@/components/icons'
import React, { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { trpc } from '@/lib/trpc'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { ForgedBlockDefinition } from '@typebot.io/forge-schemas'
import { useToast } from '@/hooks/useToast'
type Props = Omit<ButtonProps, 'type'> & {
blockDef: ForgedBlockDefinition
currentCredentialsId: string | undefined
onAddClick: () => void
onCredentialsSelect: (credentialId?: string) => void
}
export const ForgedCredentialsDropdown = ({
currentCredentialsId,
blockDef,
onCredentialsSelect,
onAddClick,
...props
}: Props) => {
const router = useRouter()
const { showToast } = useToast()
const { workspace, currentRole } = useWorkspace()
const { data, refetch, isLoading } =
trpc.integrationCredentials.listCredentials.useQuery(
{
workspaceId: workspace?.id as string,
type: blockDef.id,
},
{ enabled: !!workspace?.id }
)
const [isDeleting, setIsDeleting] = useState<string>()
const { mutate } = trpc.credentials.deleteCredentials.useMutation({
onMutate: ({ credentialsId }) => {
setIsDeleting(credentialsId)
},
onError: (error) => {
showToast({
description: error.message,
})
},
onSuccess: ({ credentialsId }) => {
if (credentialsId === currentCredentialsId) onCredentialsSelect(undefined)
refetch()
},
onSettled: () => {
setIsDeleting(undefined)
},
})
const currentCredential = data?.credentials.find(
(c) => c.id === currentCredentialsId
)
const handleMenuItemClick = useCallback(
(credentialsId: string) => () => {
onCredentialsSelect(credentialsId)
},
[onCredentialsSelect]
)
const clearQueryParams = useCallback(() => {
const hasQueryParams = router.asPath.includes('?')
if (hasQueryParams)
router.push(router.asPath.split('?')[0], undefined, { shallow: true })
}, [router])
useEffect(() => {
if (!router.isReady) return
if (router.query.credentialsId) {
handleMenuItemClick(router.query.credentialsId.toString())()
clearQueryParams()
}
}, [
clearQueryParams,
handleMenuItemClick,
router.isReady,
router.query.credentialsId,
])
const deleteCredentials =
(credentialsId: string) => async (e: React.MouseEvent) => {
if (!workspace) return
e.stopPropagation()
mutate({ workspaceId: workspace.id, credentialsId })
}
if (!data || data?.credentials.length === 0) {
return (
<Button
colorScheme="gray"
textAlign="left"
leftIcon={<PlusIcon />}
onClick={onAddClick}
isDisabled={currentRole === 'GUEST'}
isLoading={isLoading}
{...props}
>
Add {blockDef.auth.name}
</Button>
)
}
return (
<Menu isLazy>
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
colorScheme="gray"
justifyContent="space-between"
textAlign="left"
{...props}
>
<Text
noOfLines={1}
overflowY="visible"
h={props.size === 'sm' ? '18px' : '20px'}
>
{currentCredential
? currentCredential.name
: `Select ${blockDef.auth.name}`}
</Text>
</MenuButton>
<MenuList>
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
{data?.credentials.map((credentials) => (
<MenuItem
role="menuitem"
minH="40px"
key={credentials.id}
onClick={handleMenuItemClick(credentials.id)}
fontSize="16px"
fontWeight="normal"
rounded="none"
justifyContent="space-between"
>
{credentials.name}
<IconButton
icon={<TrashIcon />}
aria-label="Remove credentials"
size="xs"
onClick={deleteCredentials(credentials.id)}
isLoading={isDeleting === credentials.id}
/>
</MenuItem>
))}
{currentRole === 'GUEST' ? null : (
<MenuItem
maxW="500px"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
icon={<PlusIcon />}
onClick={onAddClick}
>
Connect new
</MenuItem>
)}
</Stack>
</MenuList>
</Menu>
)
}

View File

@@ -0,0 +1,112 @@
import { TextInput } from '@/components/inputs/TextInput'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { useToast } from '@/hooks/useToast'
import { trpc } from '@/lib/trpc'
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Stack,
ModalFooter,
Button,
} from '@chakra-ui/react'
import React, { useState } from 'react'
import { ZodObjectLayout } from '../zodLayouts/ZodObjectLayout'
import { ForgedBlockDefinition } from '@typebot.io/forge-schemas'
type Props = {
blockDef: ForgedBlockDefinition
isOpen: boolean
onClose: () => void
onNewCredentials: (id: string) => void
}
export const ForgedCredentialsModal = ({
blockDef,
isOpen,
onClose,
onNewCredentials,
}: Props) => {
const { workspace } = useWorkspace()
const { showToast } = useToast()
const [name, setName] = useState('')
const [data, setData] = useState({})
const [isCreating, setIsCreating] = useState(false)
const {
credentials: {
listCredentials: { refetch: refetchCredentials },
},
} = trpc.useContext()
const { mutate } = trpc.integrationCredentials.createCredentials.useMutation({
onMutate: () => setIsCreating(true),
onSettled: () => setIsCreating(false),
onError: (err) => {
showToast({
description: err.message,
status: 'error',
})
},
onSuccess: (data) => {
refetchCredentials()
onNewCredentials(data.credentialsId)
onClose()
},
})
const createOpenAICredentials = async (e: React.FormEvent) => {
e.preventDefault()
if (!workspace) return
mutate({
credentials: {
type: blockDef.id,
workspaceId: workspace.id,
name,
data,
},
})
}
if (!blockDef.auth) return null
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Add {blockDef.auth.name}</ModalHeader>
<ModalCloseButton />
<form onSubmit={createOpenAICredentials}>
<ModalBody as={Stack} spacing="6">
<TextInput
isRequired
label="Name"
onChange={setName}
placeholder="My account"
withVariableButton={false}
debounceTimeout={0}
/>
<ZodObjectLayout
schema={blockDef.auth.schema}
data={data}
onDataChange={setData}
/>
</ModalBody>
<ModalFooter>
<Button
type="submit"
isLoading={isCreating}
isDisabled={Object.keys(data).length === 0}
colorScheme="blue"
>
Create
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,55 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { DropdownList } from '@/components/DropdownList'
import { z } from '@typebot.io/forge/zod'
import { useMemo } from 'react'
import { ZodObjectLayout } from './ZodObjectLayout'
import { isDefined } from '@typebot.io/lib'
import { ForgedBlockDefinition, ForgedBlock } from '@typebot.io/forge-schemas'
type Props = {
blockDef?: ForgedBlockDefinition
blockOptions?: ForgedBlock['options']
schema: z.ZodOptional<z.ZodDiscriminatedUnion<'action', z.ZodObject<any>[]>>
onDataChange: (options: ForgedBlock['options']) => void
}
export const ZodActionDiscriminatedUnion = ({
blockDef,
blockOptions,
schema,
onDataChange,
}: Props) => {
const innerSchema = schema._def.innerType
const currentOptions = blockOptions?.action
? innerSchema._def.optionsMap.get(blockOptions?.action)
: undefined
const keysBeforeActionField = useMemo(() => {
if (!currentOptions) return []
return Object.keys(currentOptions.shape).slice(
0,
Object.keys(currentOptions.shape).findIndex((key) => key === 'action') + 1
)
}, [currentOptions])
return (
<>
<DropdownList
currentItem={blockOptions?.action}
onItemSelect={(item) => onDataChange({ ...blockOptions, action: item })}
items={
[...innerSchema._def.optionsMap.keys()].filter(isDefined) as string[]
}
placeholder="Select an action"
/>
{currentOptions && (
<ZodObjectLayout
schema={currentOptions}
data={blockOptions}
blockDef={blockDef}
blockOptions={blockOptions}
onDataChange={onDataChange}
ignoreKeys={keysBeforeActionField}
/>
)}
</>
)
}

View File

@@ -0,0 +1,53 @@
import { DropdownList } from '@/components/DropdownList'
import { z } from '@typebot.io/forge/zod'
import { ZodObjectLayout } from './ZodObjectLayout'
import { isDefined } from '@typebot.io/lib'
import { ForgedBlockDefinition, ForgedBlock } from '@typebot.io/forge-schemas'
/* eslint-disable @typescript-eslint/no-explicit-any */
export const ZodDiscriminatedUnionLayout = ({
discriminant,
data,
schema,
dropdownPlaceholder,
blockDef,
blockOptions,
onDataChange,
}: {
discriminant: string
data: any
schema: z.ZodDiscriminatedUnion<string, z.ZodObject<any>[]>
dropdownPlaceholder: string
blockDef?: ForgedBlockDefinition
blockOptions?: ForgedBlock['options']
onDataChange: (value: string) => void
}) => {
const currentOptions = data?.[discriminant]
? schema._def.optionsMap.get(data?.[discriminant])
: undefined
return (
<>
<DropdownList
currentItem={data?.[discriminant]}
onItemSelect={(item) => onDataChange({ ...data, [discriminant]: item })}
items={
[...schema._def.optionsMap.keys()].filter(
(key) =>
isDefined(key) &&
!schema._def.optionsMap.get(key)?._def.layout?.isHidden
) as string[]
}
placeholder={dropdownPlaceholder}
/>
{currentOptions && (
<ZodObjectLayout
schema={currentOptions}
data={data}
blockDef={blockDef}
blockOptions={blockOptions}
onDataChange={onDataChange}
/>
)}
</>
)
}

View File

@@ -0,0 +1,263 @@
import { NumberInput, TextInput, Textarea } from '@/components/inputs'
import { z } from '@typebot.io/forge/zod'
import { ZodLayoutMetadata } from '@typebot.io/forge/zod'
import Markdown, { Components } from 'react-markdown'
import { ZodTypeAny } from 'zod'
import { ForgeSelectInput } from '../ForgeSelectInput'
import { ZodObjectLayout } from './ZodObjectLayout'
import { TableList } from '@/components/TableList'
import { ZodDiscriminatedUnionLayout } from './ZodDiscriminatedUnionLayout'
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Stack,
Text,
} from '@chakra-ui/react'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
import { DropdownList } from '@/components/DropdownList'
import { ForgedBlockDefinition, ForgedBlock } from '@typebot.io/forge-schemas'
const mdComponents = {
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="md-link"
>
{children}
</a>
),
} satisfies Components
/* eslint-disable @typescript-eslint/no-explicit-any */
export const ZodFieldLayout = ({
data,
schema,
isInAccordion,
blockDef,
blockOptions,
onDataChange,
}: {
data: any
schema: z.ZodTypeAny
isInAccordion?: boolean
blockDef?: ForgedBlockDefinition
blockOptions?: ForgedBlock['options']
onDataChange: (val: any) => void
}) => {
const layout = schema._def.layout as ZodLayoutMetadata<ZodTypeAny> | undefined
const type = schema._def.innerType
? schema._def.innerType._def.typeName
: schema._def.typeName
if (layout?.isHidden) return null
switch (type) {
case 'ZodObject':
return (
<ZodObjectLayout
schema={schema as z.ZodObject<any>}
data={data}
onDataChange={onDataChange}
isInAccordion={isInAccordion}
blockDef={blockDef}
blockOptions={blockOptions}
/>
)
case 'ZodDiscriminatedUnion': {
return (
<ZodDiscriminatedUnionLayout
discriminant={schema._def.discriminator}
data={data}
schema={schema as z.ZodDiscriminatedUnion<string, z.ZodObject<any>[]>}
dropdownPlaceholder={`Select a ${schema._def.discriminator}`}
onDataChange={onDataChange}
/>
)
}
case 'ZodArray': {
if (layout?.accordion)
return (
<Accordion allowToggle>
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
{layout?.accordion}
</Text>
<AccordionIcon />
</AccordionButton>
<AccordionPanel as={Stack} pt="4">
<ZodArrayContent
data={data}
schema={schema}
layout={layout}
onDataChange={onDataChange}
isInAccordion
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
)
return (
<ZodArrayContent
data={data}
schema={schema}
layout={layout}
onDataChange={onDataChange}
/>
)
}
case 'ZodEnum': {
return (
<DropdownList
currentItem={data ?? layout?.defaultValue}
onItemSelect={onDataChange}
items={schema._def.innerType._def.values}
label={layout?.label}
helperText={
layout?.helperText ? (
<Markdown components={mdComponents}>{layout.helperText}</Markdown>
) : undefined
}
moreInfoTooltip={layout?.moreInfoTooltip}
placeholder={layout?.placeholder}
direction={layout?.direction}
/>
)
}
case 'ZodNumber':
case 'ZodUnion': {
return (
<NumberInput
defaultValue={data ?? layout?.defaultValue}
label={layout?.label}
placeholder={layout?.placeholder}
helperText={
layout?.helperText ? (
<Markdown components={mdComponents}>{layout.helperText}</Markdown>
) : undefined
}
isRequired={layout?.isRequired}
moreInfoTooltip={layout?.moreInfoTooltip}
onValueChange={onDataChange}
/>
)
}
case 'ZodString': {
if (layout?.fetcher) {
if (!blockDef) return null
return (
<ForgeSelectInput
defaultValue={data ?? layout.defaultValue}
placeholder={layout.placeholder}
fetcherId={layout.fetcher}
options={blockOptions}
blockDef={blockDef}
label={layout.label}
helperText={
layout?.helperText ? (
<Markdown components={mdComponents}>
{layout.helperText}
</Markdown>
) : undefined
}
moreInfoTooltip={layout?.moreInfoTooltip}
onChange={onDataChange}
/>
)
}
if (layout?.input === 'variableDropdown') {
return (
<VariableSearchInput
initialVariableId={data}
onSelectVariable={(variable) => onDataChange(variable?.id)}
placeholder={layout?.placeholder}
label={layout?.label}
moreInfoTooltip={layout.moreInfoTooltip}
helperText={
layout?.helperText ? (
<Markdown components={mdComponents}>
{layout.helperText}
</Markdown>
) : undefined
}
/>
)
}
if (layout?.input === 'textarea') {
return (
<Textarea
defaultValue={data ?? layout?.defaultValue}
label={layout?.label}
placeholder={layout?.placeholder}
helperText={
layout?.helperText ? (
<Markdown components={mdComponents}>
{layout.helperText}
</Markdown>
) : undefined
}
isRequired={layout?.isRequired}
withVariableButton={layout?.withVariableButton}
moreInfoTooltip={layout.moreInfoTooltip}
onChange={onDataChange}
/>
)
}
return (
<TextInput
defaultValue={data ?? layout?.defaultValue}
label={layout?.label}
placeholder={layout?.placeholder}
helperText={
layout?.helperText ? (
<Markdown components={mdComponents}>{layout.helperText}</Markdown>
) : undefined
}
type={layout?.input === 'password' ? 'password' : undefined}
isRequired={layout?.isRequired}
withVariableButton={layout?.withVariableButton}
moreInfoTooltip={layout?.moreInfoTooltip}
onChange={onDataChange}
/>
)
}
}
}
const ZodArrayContent = ({
schema,
data,
layout,
isInAccordion,
onDataChange,
}: {
schema: z.ZodTypeAny
data: any
layout: ZodLayoutMetadata<ZodTypeAny> | undefined
isInAccordion?: boolean
onDataChange: (val: any) => void
}) => (
<TableList
onItemsChange={(items) => {
onDataChange(items)
}}
initialItems={data}
addLabel={`Add ${layout?.itemLabel ?? ''}`}
isOrdered={layout?.isOrdered}
>
{({ item, onItemChange }) => (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<ZodFieldLayout
schema={schema._def.innerType._def.type}
data={item}
isInAccordion={isInAccordion}
onDataChange={onItemChange}
/>
</Stack>
)}
</TableList>
)

View File

@@ -0,0 +1,122 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Accordion,
AccordionItem,
AccordionButton,
AccordionIcon,
AccordionPanel,
Stack,
Text,
} from '@chakra-ui/react'
import { z } from '@typebot.io/forge/zod'
import { ZodLayoutMetadata } from '@typebot.io/forge/zod'
import { ReactNode } from 'react'
import { ZodTypeAny } from 'zod'
import { ZodFieldLayout } from './ZodFieldLayout'
import { ForgedBlockDefinition, ForgedBlock } from '@typebot.io/forge-schemas'
export const ZodObjectLayout = ({
schema,
data,
isInAccordion,
ignoreKeys,
blockDef,
blockOptions,
onDataChange,
}: {
schema: z.ZodObject<any>
data: any
isInAccordion?: boolean
ignoreKeys?: string[]
blockDef?: ForgedBlockDefinition
blockOptions?: ForgedBlock['options']
onDataChange: (value: any) => void
}) => {
return Object.keys(schema.shape).reduce<{
nodes: ReactNode[]
accordionsCreated: string[]
}>(
(nodes, key, index) => {
if (ignoreKeys?.includes(key)) return nodes
const keySchema = schema.shape[key]
const layout = keySchema._def.layout as
| ZodLayoutMetadata<ZodTypeAny>
| undefined
if (
layout &&
layout.accordion &&
!isInAccordion &&
keySchema._def.innerType._def.typeName !== 'ZodArray'
) {
if (nodes.accordionsCreated.includes(layout.accordion)) return nodes
const accordionKeys = getObjectKeysWithSameAccordionAttr(
layout.accordion,
schema
)
return {
nodes: [
...nodes.nodes,
<Accordion allowToggle key={layout.accordion}>
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
{layout.accordion}
</Text>
<AccordionIcon />
</AccordionButton>
<AccordionPanel as={Stack} spacing={4}>
{accordionKeys.map((accordionKey, idx) => (
<ZodFieldLayout
key={accordionKey + idx}
schema={schema.shape[accordionKey]}
data={data?.[accordionKey]}
onDataChange={(val) =>
onDataChange({ ...data, [accordionKey]: val })
}
blockDef={blockDef}
blockOptions={blockOptions}
isInAccordion
/>
))}
</AccordionPanel>
</AccordionItem>
</Accordion>,
],
accordionsCreated: [
...nodes.accordionsCreated,
layout.accordion as string,
],
}
}
return {
nodes: [
...nodes.nodes,
<ZodFieldLayout
schema={keySchema}
key={index}
data={data?.[key]}
blockDef={blockDef}
blockOptions={blockOptions}
onDataChange={(val) => onDataChange({ ...data, [key]: val })}
/>,
],
accordionsCreated: nodes.accordionsCreated,
}
},
{ nodes: [], accordionsCreated: [] }
).nodes
}
const getObjectKeysWithSameAccordionAttr = (
accordion: string,
schema: z.ZodObject<any>
) =>
Object.keys(schema.shape).reduce<string[]>((keys, currentKey) => {
const l = schema.shape[currentKey]._def.layout as
| ZodLayoutMetadata<ZodTypeAny>
| undefined
return !l?.accordion || l.accordion !== accordion
? keys
: [...keys, currentKey]
}, [])

View File

@@ -0,0 +1,27 @@
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'
export const useForgedBlock = (
blockType: BlockWithOptions['type'],
action?: string
) =>
useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((enabledBlocks as any).includes(blockType) === false) return {}
const blockDef = forgedBlocks.find(
(b) => enabledBlocks.includes(b.id) && b.id === blockType
)
return {
blockDef,
blockSchema: forgedBlockSchemas.find(
(b) =>
enabledBlocks.includes(b.shape.type.value) &&
b.shape.type.value === blockType
),
actionDef: action
? blockDef?.actions.find((a) => a.name === action)
: undefined,
}
}, [action, blockType])