✨ Introducing The Forge (#1072)
The Forge allows anyone to easily create their own Typebot Block. Closes #380
This commit is contained in:
23
apps/builder/src/features/forge/ForgedBlockCard.tsx
Normal file
23
apps/builder/src/features/forge/ForgedBlockCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
apps/builder/src/features/forge/ForgedBlockIcon.tsx
Normal file
18
apps/builder/src/features/forge/ForgedBlockIcon.tsx
Normal 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 }} />
|
||||
}
|
||||
9
apps/builder/src/features/forge/ForgedBlockLabel.tsx
Normal file
9
apps/builder/src/features/forge/ForgedBlockLabel.tsx
Normal 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>
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
)
|
||||
@@ -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 }
|
||||
})
|
||||
10
apps/builder/src/features/forge/api/credentials/router.ts
Normal file
10
apps/builder/src/features/forge/api/credentials/router.ts
Normal 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,
|
||||
})
|
||||
81
apps/builder/src/features/forge/api/fetchSelectItems.ts
Normal file
81
apps/builder/src/features/forge/api/fetchSelectItems.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
)
|
||||
6
apps/builder/src/features/forge/api/router.ts
Normal file
6
apps/builder/src/features/forge/api/router.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { router } from '@/helpers/server/trpc'
|
||||
import { fetchSelectItems } from './fetchSelectItems'
|
||||
|
||||
export const integrationsRouter = router({
|
||||
fetchSelectItems,
|
||||
})
|
||||
115
apps/builder/src/features/forge/components/ForgeSelectInput.tsx
Normal file
115
apps/builder/src/features/forge/components/ForgeSelectInput.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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]
|
||||
}, [])
|
||||
27
apps/builder/src/features/forge/hooks/useForgedBlock.ts
Normal file
27
apps/builder/src/features/forge/hooks/useForgedBlock.ts
Normal 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])
|
||||
Reference in New Issue
Block a user