2
0

feat(editor): Restore published version button

Had to migrate webhooks into a standalone table
This commit is contained in:
Baptiste Arnaud
2022-03-01 07:13:09 +01:00
parent 0df719d531
commit e17a1a0869
46 changed files with 578 additions and 348 deletions

View File

@@ -17,7 +17,6 @@ import {
StepOptions, StepOptions,
TextBubbleStep, TextBubbleStep,
Webhook, Webhook,
WebhookStep,
} from 'models' } from 'models'
import { useRef } from 'react' import { useRef } from 'react'
import { import {
@@ -95,11 +94,6 @@ export const StepSettings = ({
const handleOptionsChange = (options: StepOptions) => { const handleOptionsChange = (options: StepOptions) => {
onStepChange({ options } as Partial<Step>) onStepChange({ options } as Partial<Step>)
} }
const handleWebhookChange = (updates: Partial<Webhook>) => {
onStepChange({
webhook: { ...(step as WebhookStep).webhook, ...updates },
} as Partial<Step>)
}
const handleItemChange = (updates: Partial<ConditionItem>) => { const handleItemChange = (updates: Partial<ConditionItem>) => {
onStepChange({ onStepChange({
items: [{ ...(step as ConditionStep).items[0], ...updates }], items: [{ ...(step as ConditionStep).items[0], ...updates }],
@@ -208,7 +202,6 @@ export const StepSettings = ({
<WebhookSettings <WebhookSettings
step={step} step={step}
onOptionsChange={handleOptionsChange} onOptionsChange={handleOptionsChange}
onWebhookChange={handleWebhookChange}
onTestRequestClick={onTestRequestClick} onTestRequestClick={onTestRequestClick}
/> />
) )

View File

@@ -18,12 +18,11 @@ import {
Sheet, Sheet,
useSheets, useSheets,
} from 'services/integrations' } from 'services/integrations'
import { isDefined } from 'utils' import { isDefined, omit } from 'utils'
import { SheetsDropdown } from './SheetsDropdown' import { SheetsDropdown } from './SheetsDropdown'
import { SpreadsheetsDropdown } from './SpreadsheetDropdown' import { SpreadsheetsDropdown } from './SpreadsheetDropdown'
import { CellWithValueStack } from './CellWithValueStack' import { CellWithValueStack } from './CellWithValueStack'
import { CellWithVariableIdStack } from './CellWithVariableIdStack' import { CellWithVariableIdStack } from './CellWithVariableIdStack'
import { omit } from 'services/utils'
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown' import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
type Props = { type Props = {

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { import {
Accordion, Accordion,
AccordionButton, AccordionButton,
@@ -7,6 +7,7 @@ import {
AccordionPanel, AccordionPanel,
Button, Button,
Flex, Flex,
Spinner,
Stack, Stack,
useToast, useToast,
} from '@chakra-ui/react' } from '@chakra-ui/react'
@@ -17,9 +18,10 @@ import {
KeyValue, KeyValue,
WebhookOptions, WebhookOptions,
VariableForTest, VariableForTest,
Webhook,
ResponseVariableMapping, ResponseVariableMapping,
WebhookStep, WebhookStep,
defaultWebhookAttributes,
Webhook,
} from 'models' } from 'models'
import { DropdownList } from 'components/shared/DropdownList' import { DropdownList } from 'components/shared/DropdownList'
import { TableList, TableListItemProps } from 'components/shared/TableList' import { TableList, TableListItemProps } from 'components/shared/TableList'
@@ -32,21 +34,21 @@ import {
import { HeadersInputs, QueryParamsInputs } from './KeyValueInputs' import { HeadersInputs, QueryParamsInputs } from './KeyValueInputs'
import { VariableForTestInputs } from './VariableForTestInputs' import { VariableForTestInputs } from './VariableForTestInputs'
import { DataVariableInputs } from './ResponseMappingInputs' import { DataVariableInputs } from './ResponseMappingInputs'
import { byId } from 'utils'
import { deepEqual } from 'fast-equals'
type Props = { type Props = {
step: WebhookStep step: WebhookStep
onOptionsChange: (options: WebhookOptions) => void onOptionsChange: (options: WebhookOptions) => void
onWebhookChange: (updates: Partial<Webhook>) => void
onTestRequestClick: () => void onTestRequestClick: () => void
} }
export const WebhookSettings = ({ export const WebhookSettings = ({
step: { webhook, options, blockId, id: stepId }, step: { options, blockId, id: stepId, webhookId },
onOptionsChange, onOptionsChange,
onWebhookChange,
onTestRequestClick, onTestRequestClick,
}: Props) => { }: Props) => {
const { typebot, save } = useTypebot() const { typebot, save, webhooks, updateWebhook } = useTypebot()
const [isTestResponseLoading, setIsTestResponseLoading] = useState(false) const [isTestResponseLoading, setIsTestResponseLoading] = useState(false)
const [testResponse, setTestResponse] = useState<string>() const [testResponse, setTestResponse] = useState<string>()
const [responseKeys, setResponseKeys] = useState<string[]>([]) const [responseKeys, setResponseKeys] = useState<string[]>([])
@@ -55,18 +57,52 @@ export const WebhookSettings = ({
position: 'top-right', position: 'top-right',
status: 'error', status: 'error',
}) })
const [localWebhook, setLocalWebhook] = useState(
webhooks.find(byId(webhookId))
)
const handleUrlChange = (url?: string) => onWebhookChange({ url }) useEffect(() => {
const incomingWebhook = webhooks.find(byId(webhookId))
if (deepEqual(incomingWebhook, localWebhook)) return
setLocalWebhook(incomingWebhook)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [webhooks])
const handleMethodChange = (method: HttpMethod) => onWebhookChange({ method }) useEffect(() => {
if (!typebot) return
if (!localWebhook) {
const newWebhook = {
id: webhookId,
...defaultWebhookAttributes,
typebotId: typebot.id,
} as Webhook
updateWebhook(webhookId, newWebhook)
}
return () => {
setLocalWebhook((localWebhook) => {
if (!localWebhook) return
updateWebhook(webhookId, localWebhook).then()
return localWebhook
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleUrlChange = (url?: string) =>
localWebhook && setLocalWebhook({ ...localWebhook, url: url ?? null })
const handleMethodChange = (method: HttpMethod) =>
localWebhook && setLocalWebhook({ ...localWebhook, method })
const handleQueryParamsChange = (queryParams: KeyValue[]) => const handleQueryParamsChange = (queryParams: KeyValue[]) =>
onWebhookChange({ queryParams }) localWebhook && setLocalWebhook({ ...localWebhook, queryParams })
const handleHeadersChange = (headers: KeyValue[]) => const handleHeadersChange = (headers: KeyValue[]) =>
onWebhookChange({ headers }) localWebhook && setLocalWebhook({ ...localWebhook, headers })
const handleBodyChange = (body: string) => onWebhookChange({ body }) const handleBodyChange = (body: string) =>
localWebhook && setLocalWebhook({ ...localWebhook, body })
const handleVariablesChange = (variablesForTest: VariableForTest[]) => const handleVariablesChange = (variablesForTest: VariableForTest[]) =>
onOptionsChange({ ...options, variablesForTest }) onOptionsChange({ ...options, variablesForTest })
@@ -76,10 +112,10 @@ export const WebhookSettings = ({
) => onOptionsChange({ ...options, responseVariableMapping }) ) => onOptionsChange({ ...options, responseVariableMapping })
const handleTestRequestClick = async () => { const handleTestRequestClick = async () => {
if (!typebot) return if (!typebot || !localWebhook) return
setIsTestResponseLoading(true) setIsTestResponseLoading(true)
onTestRequestClick() onTestRequestClick()
await save() await Promise.all([updateWebhook(localWebhook.id, localWebhook), save()])
const { data, error } = await executeWebhook( const { data, error } = await executeWebhook(
typebot.id, typebot.id,
convertVariableForTestToVariables( convertVariableForTestToVariables(
@@ -100,19 +136,20 @@ export const WebhookSettings = ({
[responseKeys] [responseKeys]
) )
if (!localWebhook) return <Spinner />
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<Stack> <Stack>
<Flex> <Flex>
<DropdownList<HttpMethod> <DropdownList<HttpMethod>
currentItem={webhook.method} currentItem={localWebhook.method as HttpMethod}
onItemSelect={handleMethodChange} onItemSelect={handleMethodChange}
items={Object.values(HttpMethod)} items={Object.values(HttpMethod)}
/> />
</Flex> </Flex>
<InputWithVariableButton <InputWithVariableButton
placeholder="Your Webhook URL..." placeholder="Your Webhook URL..."
initialValue={webhook.url ?? ''} initialValue={localWebhook.url ?? ''}
onChange={handleUrlChange} onChange={handleUrlChange}
/> />
</Stack> </Stack>
@@ -124,7 +161,7 @@ export const WebhookSettings = ({
</AccordionButton> </AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6"> <AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<KeyValue> <TableList<KeyValue>
initialItems={webhook.queryParams} initialItems={localWebhook.queryParams}
onItemsChange={handleQueryParamsChange} onItemsChange={handleQueryParamsChange}
Item={QueryParamsInputs} Item={QueryParamsInputs}
addLabel="Add a param" addLabel="Add a param"
@@ -138,7 +175,7 @@ export const WebhookSettings = ({
</AccordionButton> </AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6"> <AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<KeyValue> <TableList<KeyValue>
initialItems={webhook.headers} initialItems={localWebhook.headers}
onItemsChange={handleHeadersChange} onItemsChange={handleHeadersChange}
Item={HeadersInputs} Item={HeadersInputs}
addLabel="Add a value" addLabel="Add a value"
@@ -152,7 +189,7 @@ export const WebhookSettings = ({
</AccordionButton> </AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6"> <AccordionPanel pb={4} as={Stack} spacing="6">
<CodeEditor <CodeEditor
value={webhook.body ?? ''} value={localWebhook.body ?? ''}
lang="json" lang="json"
onChange={handleBodyChange} onChange={handleBodyChange}
/> />

View File

@@ -8,23 +8,27 @@ import {
Text, Text,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { ExternalLinkIcon } from 'assets/icons' import { ExternalLinkIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext'
import { ZapierStep } from 'models' import { ZapierStep } from 'models'
import React from 'react' import React from 'react'
import { byId } from 'utils'
type Props = { type Props = {
step: ZapierStep step: ZapierStep
} }
export const ZapierSettings = ({ step }: Props) => { export const ZapierSettings = ({ step }: Props) => {
const { webhooks } = useTypebot()
const webhook = webhooks.find(byId(step.webhookId))
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<Alert <Alert
status={step.webhook.url ? 'success' : 'info'} status={webhook?.url ? 'success' : 'info'}
bgColor={step.webhook.url ? undefined : 'blue.50'} bgColor={webhook?.url ? undefined : 'blue.50'}
rounded="md" rounded="md"
> >
<AlertIcon /> <AlertIcon />
{step.webhook.url ? ( {webhook?.url ? (
<>Your zap is correctly configured 🚀</> <>Your zap is correctly configured 🚀</>
) : ( ) : (
<Stack> <Stack>
@@ -40,7 +44,7 @@ export const ZapierSettings = ({ step }: Props) => {
</Stack> </Stack>
)} )}
</Alert> </Alert>
{step.webhook.url && <Input value={step.webhook.url} isDisabled />} {webhook?.url && <Input value={webhook?.url} isDisabled />}
</Stack> </Stack>
) )
} }

View File

@@ -1,11 +1,16 @@
import { Text } from '@chakra-ui/react' import { Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { WebhookStep } from 'models' import { WebhookStep } from 'models'
import { byId } from 'utils'
type Props = { type Props = {
step: WebhookStep step: WebhookStep
} }
export const WebhookContent = ({ step: { webhook } }: Props) => { export const WebhookContent = ({ step: { webhookId } }: Props) => {
const { webhooks } = useTypebot()
const webhook = webhooks.find(byId(webhookId))
if (!webhook?.url) return <Text color="gray.500">Configure...</Text> if (!webhook?.url) return <Text color="gray.500">Configure...</Text>
return ( return (
<Text isTruncated pr="6"> <Text isTruncated pr="6">

View File

@@ -1,17 +1,21 @@
import { Text } from '@chakra-ui/react' import { Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { ZapierStep } from 'models' import { ZapierStep } from 'models'
import { isNotDefined } from 'utils' import { byId, isNotDefined } from 'utils'
type Props = { type Props = {
step: ZapierStep step: ZapierStep
} }
export const ZapierContent = ({ step }: Props) => { export const ZapierContent = ({ step: { webhookId } }: Props) => {
if (isNotDefined(step.webhook.body)) const { webhooks } = useTypebot()
const webhook = webhooks.find(byId(webhookId))
if (isNotDefined(webhook?.body))
return <Text color="gray.500">Configure...</Text> return <Text color="gray.500">Configure...</Text>
return ( return (
<Text isTruncated pr="6"> <Text isTruncated pr="6">
{step.webhook.url ? 'Enabled' : 'Disabled'} {webhook?.url ? 'Enabled' : 'Disabled'}
</Text> </Text>
) )
} }

View File

@@ -1,18 +1,75 @@
import { Button } from '@chakra-ui/react' import {
Button,
HStack,
IconButton,
Stack,
Tooltip,
Text,
Menu,
MenuButton,
MenuList,
MenuItem,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { timeSince } from 'services/utils'
import { isNotDefined } from 'utils'
export const PublishButton = () => { export const PublishButton = () => {
const { isPublishing, isPublished, publishTypebot } = useTypebot() const {
isPublishing,
isPublished,
publishTypebot,
publishedTypebot,
restorePublishedTypebot,
} = useTypebot()
return ( return (
<Button <HStack spacing="1px">
ml={2} <Tooltip
colorScheme="blue" borderRadius="md"
isLoading={isPublishing} hasArrow
isDisabled={isPublished} placement="bottom-end"
onClick={publishTypebot} label={
> <Stack>
{isPublished ? 'Published' : 'Publish'} <Text>There are non published changes.</Text>
</Button> <Text fontStyle="italic">
Published version from{' '}
{publishedTypebot &&
timeSince(publishedTypebot.updatedAt.toString())}{' '}
ago
</Text>
</Stack>
}
isDisabled={isNotDefined(publishedTypebot)}
>
<Button
colorScheme="blue"
isLoading={isPublishing}
isDisabled={isPublished}
onClick={publishTypebot}
borderRightRadius={publishedTypebot && !isPublished ? 0 : undefined}
>
{isPublished ? 'Published' : 'Publish'}
</Button>
</Tooltip>
{publishedTypebot && !isPublished && (
<Menu>
<MenuButton
as={IconButton}
colorScheme="blue"
borderLeftRadius={0}
icon={<ChevronLeftIcon transform="rotate(-90deg)" />}
aria-label="Show published version"
/>
<MenuList>
<MenuItem onClick={restorePublishedTypebot}>
Restore published version
</MenuItem>
</MenuList>
</Menu>
)}
</HStack>
) )
} }

View File

@@ -1,4 +1,4 @@
import { Block, Edge, IdMap, PublicBlock, Source, Step, Target } from 'models' import { Block, Edge, IdMap, Source, Step, Target } from 'models'
import { import {
createContext, createContext,
Dispatch, Dispatch,
@@ -88,7 +88,7 @@ export const GraphProvider = ({
isReadOnly = false, isReadOnly = false,
}: { }: {
children: ReactNode children: ReactNode
blocks: (Block | PublicBlock)[] blocks: Block[]
isReadOnly?: boolean isReadOnly?: boolean
}) => { }) => {
const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue) const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue)

View File

@@ -1,5 +1,5 @@
import { useToast } from '@chakra-ui/react' import { useToast } from '@chakra-ui/react'
import { PublicTypebot, Settings, Theme, Typebot } from 'models' import { PublicTypebot, Settings, Theme, Typebot, Webhook } from 'models'
import { Router, useRouter } from 'next/router' import { Router, useRouter } from 'next/router'
import { import {
createContext, createContext,
@@ -11,6 +11,7 @@ import {
} from 'react' } from 'react'
import { import {
createPublishedTypebot, createPublishedTypebot,
parsePublicTypebotToTypebot,
parseTypebotToPublicTypebot, parseTypebotToPublicTypebot,
updatePublishedTypebot, updatePublishedTypebot,
} from 'services/publicTypebot' } from 'services/publicTypebot'
@@ -34,6 +35,7 @@ import { itemsAction, ItemsActions } from './actions/items'
import { generate } from 'short-uuid' import { generate } from 'short-uuid'
import { deepEqual } from 'fast-equals' import { deepEqual } from 'fast-equals'
import { User } from 'db' import { User } from 'db'
import { saveWebhook } from 'services/webhook'
const autoSaveTimeout = 10000 const autoSaveTimeout = 10000
type UpdateTypebotPayload = Partial<{ type UpdateTypebotPayload = Partial<{
@@ -50,6 +52,7 @@ const typebotContext = createContext<
typebot?: Typebot typebot?: Typebot
publishedTypebot?: PublicTypebot publishedTypebot?: PublicTypebot
owner?: User owner?: User
webhooks: Webhook[]
isReadOnly?: boolean isReadOnly?: boolean
isPublished: boolean isPublished: boolean
isPublishing: boolean isPublishing: boolean
@@ -59,6 +62,10 @@ const typebotContext = createContext<
redo: () => void redo: () => void
canRedo: boolean canRedo: boolean
canUndo: boolean canUndo: boolean
updateWebhook: (
webhookId: string,
webhook: Partial<Webhook>
) => Promise<void>
updateTypebot: (updates: UpdateTypebotPayload) => void updateTypebot: (updates: UpdateTypebotPayload) => void
updateOnBothTypebots: (updates: { updateOnBothTypebots: (updates: {
publicId?: string publicId?: string
@@ -66,6 +73,7 @@ const typebotContext = createContext<
customDomain?: string | null customDomain?: string | null
}) => void }) => void
publishTypebot: () => void publishTypebot: () => void
restorePublishedTypebot: () => void
} & BlocksActions & } & BlocksActions &
StepsActions & StepsActions &
ItemsActions & ItemsActions &
@@ -88,27 +96,22 @@ export const TypebotContext = ({
status: 'error', status: 'error',
}) })
const { typebot, publishedTypebot, owner, isReadOnly, isLoading, mutate } = const {
useFetchedTypebot({ typebot,
typebotId, publishedTypebot,
onError: (error) => owner,
toast({ webhooks,
title: 'Error while fetching typebot', isReadOnly,
description: error.message, isLoading,
}), mutate,
}) } = useFetchedTypebot({
typebotId,
useEffect(() => { onError: (error) =>
if ( toast({
!typebot || title: 'Error while fetching typebot',
!localTypebot || description: error.message,
typebot.updatedAt <= localTypebot.updatedAt || }),
deepEqual(typebot, localTypebot) })
)
return
setLocalTypebot({ ...typebot })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typebot])
const [ const [
{ present: localTypebot }, { present: localTypebot },
@@ -133,7 +136,7 @@ export const TypebotContext = ({
toast({ title: error.name, description: error.message }) toast({ title: error.name, description: error.message })
return return
} }
mutate({ typebot: typebotToSave }) mutate({ typebot: typebotToSave, webhooks: webhooks ?? [] })
window.removeEventListener('beforeunload', preventUserFromRefreshing) window.removeEventListener('beforeunload', preventUserFromRefreshing)
} }
@@ -148,6 +151,7 @@ export const TypebotContext = ({
mutate({ mutate({
typebot: currentTypebotRef.current as Typebot, typebot: currentTypebotRef.current as Typebot,
publishedTypebot: newPublishedTypebot, publishedTypebot: newPublishedTypebot,
webhooks: webhooks ?? [],
}) })
} }
@@ -254,7 +258,11 @@ export const TypebotContext = ({
}) })
setIsPublishing(false) setIsPublishing(false)
if (error) return toast({ title: error.name, description: error.message }) if (error) return toast({ title: error.name, description: error.message })
mutate({ typebot: localTypebot, publishedTypebot: data }) mutate({
typebot: localTypebot,
publishedTypebot: data,
webhooks: webhooks ?? [],
})
} }
} }
@@ -272,12 +280,35 @@ export const TypebotContext = ({
}) })
} }
const restorePublishedTypebot = () => {
if (!publishedTypebot || !localTypebot) return
setLocalTypebot(parsePublicTypebotToTypebot(publishedTypebot, localTypebot))
return saveTypebot()
}
const updateWebhook = async (
webhookId: string,
updates: Partial<Webhook>
) => {
if (!typebot) return
const { data } = await saveWebhook(webhookId, updates)
if (data)
mutate({
typebot,
publishedTypebot,
webhooks: (webhooks ?? []).map((w) =>
w.id === webhookId ? data.webhook : w
),
})
}
return ( return (
<typebotContext.Provider <typebotContext.Provider
value={{ value={{
typebot: localTypebot, typebot: localTypebot,
publishedTypebot, publishedTypebot,
owner, owner,
webhooks: webhooks ?? [],
isReadOnly, isReadOnly,
isSavingLoading, isSavingLoading,
save: saveTypebot, save: saveTypebot,
@@ -289,7 +320,9 @@ export const TypebotContext = ({
isPublishing, isPublishing,
isPublished, isPublished,
updateTypebot: updateLocalTypebot, updateTypebot: updateLocalTypebot,
restorePublishedTypebot,
updateOnBothTypebots, updateOnBothTypebots,
updateWebhook,
...blocksActions(localTypebot as Typebot, setLocalTypebot), ...blocksActions(localTypebot as Typebot, setLocalTypebot),
...stepsAction(localTypebot as Typebot, setLocalTypebot), ...stepsAction(localTypebot as Typebot, setLocalTypebot),
...variablesAction(localTypebot as Typebot, setLocalTypebot), ...variablesAction(localTypebot as Typebot, setLocalTypebot),
@@ -314,6 +347,7 @@ export const useFetchedTypebot = ({
const { data, error, mutate } = useSWR< const { data, error, mutate } = useSWR<
{ {
typebot: Typebot typebot: Typebot
webhooks: Webhook[]
publishedTypebot?: PublicTypebot publishedTypebot?: PublicTypebot
owner?: User owner?: User
isReadOnly?: boolean isReadOnly?: boolean
@@ -323,6 +357,7 @@ export const useFetchedTypebot = ({
if (error) onError(error) if (error) onError(error)
return { return {
typebot: data?.typebot, typebot: data?.typebot,
webhooks: data?.webhooks,
publishedTypebot: data?.publishedTypebot, publishedTypebot: data?.publishedTypebot,
owner: data?.owner, owner: data?.owner,
isReadOnly: data?.isReadOnly, isReadOnly: data?.isReadOnly,

View File

@@ -54,10 +54,10 @@ const stepsAction = (
detachStepFromBlock: (indices: StepIndices) => { detachStepFromBlock: (indices: StepIndices) => {
setTypebot(produce(typebot, removeStepFromBlock(indices))) setTypebot(produce(typebot, removeStepFromBlock(indices)))
}, },
deleteStep: (indices: StepIndices) => { deleteStep: ({ blockIndex, stepIndex }: StepIndices) => {
setTypebot( setTypebot(
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
removeStepFromBlock(indices)(typebot) removeStepFromBlock({ blockIndex, stepIndex })(typebot)
removeEmptyBlocks(typebot) removeEmptyBlocks(typebot)
}) })
) )
@@ -91,7 +91,7 @@ const createStepDraft = (
removeEmptyBlocks(typebot) removeEmptyBlocks(typebot)
} }
const createNewStep = ( const createNewStep = async (
typebot: WritableDraft<Typebot>, typebot: WritableDraft<Typebot>,
type: DraggableStepType, type: DraggableStepType,
blockId: string, blockId: string,

View File

@@ -22,10 +22,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
publishedTypebot: true, publishedTypebot: true,
owner: { select: { email: true, name: true, image: true } }, owner: { select: { email: true, name: true, image: true } },
collaborators: { select: { userId: true, type: true } }, collaborators: { select: { userId: true, type: true } },
webhooks: true,
}, },
}) })
if (!typebot) return res.send({ typebot: null }) if (!typebot) return res.send({ typebot: null })
const { publishedTypebot, owner, collaborators, ...restOfTypebot } = typebot const {
publishedTypebot,
owner,
collaborators,
webhooks,
...restOfTypebot
} = typebot
const isReadOnly = const isReadOnly =
collaborators.find((c) => c.userId === user.id)?.type === collaborators.find((c) => c.userId === user.id)?.type ===
CollaborationType.READ CollaborationType.READ
@@ -34,6 +41,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
publishedTypebot, publishedTypebot,
owner, owner,
isReadOnly, isReadOnly,
webhooks,
}) })
} }

View File

@@ -0,0 +1,21 @@
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { defaultWebhookAttributes } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils'
import { methodNotAllowed, notAuthenticated } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const webhook = await prisma.webhook.create({
data: { typebotId, ...defaultWebhookAttributes },
})
return res.send({ webhook })
}
methodNotAllowed(res)
}
export default withSentry(handler)

View File

@@ -0,0 +1,68 @@
import { withSentry } from '@sentry/nextjs'
import { CollaborationType } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils'
import {
badRequest,
forbidden,
methodNotAllowed,
notAuthenticated,
} from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
const webhookId = req.query.webhookId as string
if (req.method === 'GET') {
const webhook = await prisma.webhook.findFirst({
where: {
id: webhookId,
typebot: {
OR: [
{ ownerId: user.id },
{
collaborators: {
some: {
userId: user.id,
},
},
},
],
},
},
})
return res.send({ webhook })
}
if (req.method === 'PUT') {
const data = req.body
if (!('typebotId' in data)) return badRequest(res)
const typebot = await prisma.typebot.findFirst({
where: {
OR: [
{ id: data.typebotId, ownerId: user.id },
{
collaborators: {
some: {
userId: user.id,
type: CollaborationType.WRITE,
},
},
},
],
},
})
if (!typebot) return forbidden(res)
const webhook = await prisma.webhook.upsert({
where: {
id: webhookId,
},
create: data,
update: data,
})
return res.send({ webhook })
}
methodNotAllowed(res)
}
export default withSentry(handler)

View File

@@ -63,12 +63,7 @@
"blockId": "8XnDM1QsqPms4LQHh8q3Jo", "blockId": "8XnDM1QsqPms4LQHh8q3Jo",
"type": "Webhook", "type": "Webhook",
"options": { "responseVariableMapping": [], "variablesForTest": [] }, "options": { "responseVariableMapping": [], "variablesForTest": [] },
"webhook": { "webhookId": "webhook1"
"id": "2L9mPYsLAXdXwcnGVK6pv9",
"method": "GET",
"headers": [],
"queryParams": []
}
} }
] ]
} }

View File

@@ -1,9 +1,7 @@
import { import {
Block,
CredentialsType, CredentialsType,
defaultSettings, defaultSettings,
defaultTheme, defaultTheme,
PublicBlock,
PublicTypebot, PublicTypebot,
Step, Step,
Typebot, Typebot,
@@ -39,6 +37,9 @@ export const createUsers = () =>
], ],
}) })
export const createWebhook = (typebotId: string) =>
prisma.webhook.create({ data: { method: 'GET', typebotId, id: 'webhook1' } })
export const createCollaboration = ( export const createCollaboration = (
userId: string, userId: string,
typebotId: string, typebotId: string,
@@ -139,7 +140,7 @@ const parseTypebotToPublicTypebot = (
): Omit<PublicTypebot, 'createdAt' | 'updatedAt'> => ({ ): Omit<PublicTypebot, 'createdAt' | 'updatedAt'> => ({
id, id,
name: typebot.name, name: typebot.name,
blocks: parseBlocksToPublicBlocks(typebot.blocks), blocks: typebot.blocks,
typebotId: typebot.id, typebotId: typebot.id,
theme: typebot.theme, theme: typebot.theme,
settings: typebot.settings, settings: typebot.settings,
@@ -149,14 +150,6 @@ const parseTypebotToPublicTypebot = (
customDomain: null, customDomain: null,
}) })
const parseBlocksToPublicBlocks = (blocks: Block[]): PublicBlock[] =>
blocks.map((b) => ({
...b,
steps: b.steps.map((s) =>
'webhook' in s ? { ...s, webhook: s.webhook.id } : s
),
}))
const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => ({ const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => ({
id: partialTypebot.id ?? 'typebot', id: partialTypebot.id ?? 'typebot',
folderId: null, folderId: null,
@@ -232,6 +225,6 @@ export const importTypebotInDatabase = async (
data: parseTypebotToPublicTypebot( data: parseTypebotToPublicTypebot(
updates?.id ? `${updates?.id}-public` : 'publicBot', updates?.id ? `${updates?.id}-public` : 'publicBot',
typebot typebot
), ) as any,
}) })
} }

View File

@@ -1,5 +1,5 @@
import test, { expect, Page } from '@playwright/test' import test, { expect, Page } from '@playwright/test'
import { importTypebotInDatabase } from '../../services/database' import { createWebhook, importTypebotInDatabase } from '../../services/database'
import path from 'path' import path from 'path'
import { generate } from 'short-uuid' import { generate } from 'short-uuid'
@@ -13,6 +13,7 @@ test.describe('Webhook step', () => {
id: typebotId, id: typebotId,
} }
) )
await createWebhook(typebotId)
await page.goto(`/typebots/${typebotId}/edit`) await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...') await page.click('text=Configure...')

View File

@@ -1,17 +1,16 @@
import { Block, PublicBlock, PublicStep, PublicTypebot, Typebot } from 'models' import { PublicTypebot, Typebot } from 'models'
import shortId from 'short-uuid' import shortId from 'short-uuid'
import { HStack, Text } from '@chakra-ui/react' import { HStack, Text } from '@chakra-ui/react'
import { CalendarIcon, CodeIcon } from 'assets/icons' import { CalendarIcon, CodeIcon } from 'assets/icons'
import { StepIcon } from 'components/editor/StepsSideBar/StepIcon' import { StepIcon } from 'components/editor/StepsSideBar/StepIcon'
import { byId, isInputStep, sendRequest } from 'utils' import { byId, isInputStep, sendRequest } from 'utils'
import { isDefined } from '@udecode/plate-common'
export const parseTypebotToPublicTypebot = ( export const parseTypebotToPublicTypebot = (
typebot: Typebot typebot: Typebot
): PublicTypebot => ({ ): PublicTypebot => ({
id: shortId.generate(), id: shortId.generate(),
typebotId: typebot.id, typebotId: typebot.id,
blocks: parseBlocksToPublicBlocks(typebot.blocks), blocks: typebot.blocks,
edges: typebot.edges, edges: typebot.edges,
name: typebot.name, name: typebot.name,
publicId: typebot.publicId, publicId: typebot.publicId,
@@ -19,18 +18,29 @@ export const parseTypebotToPublicTypebot = (
theme: typebot.theme, theme: typebot.theme,
variables: typebot.variables, variables: typebot.variables,
customDomain: typebot.customDomain, customDomain: typebot.customDomain,
createdAt: new Date(),
updatedAt: new Date(),
}) })
export const parseBlocksToPublicBlocks = (blocks: Block[]): PublicBlock[] => export const parsePublicTypebotToTypebot = (
blocks.map((b) => ({ typebot: PublicTypebot,
...b, existingTypebot: Typebot
steps: b.steps.map( ): Typebot => ({
(s) => id: typebot.typebotId,
('webhook' in s && isDefined(s.webhook) blocks: typebot.blocks,
? { ...s, webhook: s.webhook.id } edges: typebot.edges,
: s) as PublicStep name: typebot.name,
), publicId: typebot.publicId,
})) settings: typebot.settings,
theme: typebot.theme,
variables: typebot.variables,
customDomain: typebot.customDomain,
createdAt: existingTypebot.createdAt,
updatedAt: existingTypebot.updatedAt,
publishedTypebotId: typebot.id,
folderId: existingTypebot.folderId,
ownerId: existingTypebot.ownerId,
})
export const createPublishedTypebot = async (typebot: PublicTypebot) => export const createPublishedTypebot = async (typebot: PublicTypebot) =>
sendRequest<PublicTypebot>({ sendRequest<PublicTypebot>({

View File

@@ -29,8 +29,6 @@ import {
defaultGoogleAnalyticsOptions, defaultGoogleAnalyticsOptions,
defaultWebhookOptions, defaultWebhookOptions,
StepWithOptionsType, StepWithOptionsType,
defaultWebhookAttributes,
Webhook,
Item, Item,
ItemType, ItemType,
defaultConditionContent, defaultConditionContent,
@@ -39,7 +37,7 @@ import {
import shortId, { generate } from 'short-uuid' import shortId, { generate } from 'short-uuid'
import { Typebot } from 'models' import { Typebot } from 'models'
import useSWR from 'swr' import useSWR from 'swr'
import { fetcher, omit, toKebabCase } from '../utils' import { fetcher, toKebabCase } from '../utils'
import { import {
isBubbleStepType, isBubbleStepType,
stepTypeHasItems, stepTypeHasItems,
@@ -48,8 +46,8 @@ import {
} from 'utils' } from 'utils'
import { deepEqual } from 'fast-equals' import { deepEqual } from 'fast-equals'
import { stringify } from 'qs' import { stringify } from 'qs'
import { isChoiceInput, isConditionStep, sendRequest } from 'utils' import { isChoiceInput, isConditionStep, sendRequest, omit } from 'utils'
import { parseBlocksToPublicBlocks } from '../publicTypebot' import cuid from 'cuid'
export type TypebotInDashboard = Pick< export type TypebotInDashboard = Pick<
Typebot, Typebot,
@@ -173,16 +171,11 @@ export const parseNewStep = (
options: stepTypeHasOption(type) options: stepTypeHasOption(type)
? parseDefaultStepOptions(type) ? parseDefaultStepOptions(type)
: undefined, : undefined,
webhook: stepTypeHasWebhook(type) ? parseDefaultWebhook() : undefined, webhookId: stepTypeHasWebhook(type) ? cuid() : undefined,
items: stepTypeHasItems(type) ? parseDefaultItems(type, id) : undefined, items: stepTypeHasItems(type) ? parseDefaultItems(type, id) : undefined,
} as DraggableStep } as DraggableStep
} }
const parseDefaultWebhook = (): Webhook => ({
id: generate(),
...defaultWebhookAttributes,
})
const parseDefaultItems = ( const parseDefaultItems = (
type: LogicStepType.CONDITION | InputStepType.CHOICE, type: LogicStepType.CONDITION | InputStepType.CHOICE,
stepId: string stepId: string
@@ -255,7 +248,7 @@ export const checkIfPublished = (
typebot: Typebot, typebot: Typebot,
publicTypebot: PublicTypebot publicTypebot: PublicTypebot
) => ) =>
deepEqual(parseBlocksToPublicBlocks(typebot.blocks), publicTypebot.blocks) && deepEqual(typebot.blocks, publicTypebot.blocks) &&
deepEqual(typebot.settings, publicTypebot.settings) && deepEqual(typebot.settings, publicTypebot.settings) &&
deepEqual(typebot.theme, publicTypebot.theme) && deepEqual(typebot.theme, publicTypebot.theme) &&
deepEqual(typebot.variables, publicTypebot.variables) deepEqual(typebot.variables, publicTypebot.variables)

View File

@@ -1,6 +1,6 @@
import imageCompression from 'browser-image-compression' import imageCompression from 'browser-image-compression'
import { Parser } from 'htmlparser2' import { Parser } from 'htmlparser2'
import { PublicStep, Step, Typebot } from 'models' import { Step, Typebot } from 'models'
export const fetcher = async (input: RequestInfo, init?: RequestInit) => { export const fetcher = async (input: RequestInfo, init?: RequestInit) => {
const res = await fetch(input, init) const res = await fetch(input, init)
@@ -36,26 +36,6 @@ export const toKebabCase = (value: string) => {
return matched.map((x) => x.toLowerCase()).join('-') return matched.map((x) => x.toLowerCase()).join('-')
} }
interface Omit {
// eslint-disable-next-line @typescript-eslint/ban-types
<T extends object, K extends [...(keyof T)[]]>(obj: T, ...keys: K): {
[K2 in Exclude<keyof T, K[number]>]: T[K2]
}
}
export const omit: Omit = (obj, ...keys) => {
const ret = {} as {
[K in keyof typeof obj]: typeof obj[K]
}
let key: keyof typeof obj
for (key in obj) {
if (!keys.includes(key)) {
ret[key] = obj[key]
}
}
return ret
}
export const uploadFile = async (file: File, key: string) => { export const uploadFile = async (file: File, key: string) => {
const res = await fetch( const res = await fetch(
`/api/storage/upload-url?key=${encodeURIComponent( `/api/storage/upload-url?key=${encodeURIComponent(
@@ -98,7 +78,7 @@ export const removeUndefinedFields = <T>(obj: T): T =>
{} as T {} as T
) )
export const stepHasOptions = (step: Step | PublicStep) => 'options' in step export const stepHasOptions = (step: Step) => 'options' in step
export const parseVariableHighlight = (content: string, typebot: Typebot) => { export const parseVariableHighlight = (content: string, typebot: Typebot) => {
const varNames = typebot.variables.map((v) => v.name) const varNames = typebot.variables.map((v) => v.name)
@@ -128,3 +108,32 @@ export const readFile = (file: File): Promise<string> => {
fr.readAsText(file) fr.readAsText(file)
}) })
} }
export const timeSince = (date: string) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
const seconds = Math.floor((new Date() - new Date(date)) / 1000)
let interval = seconds / 31536000
if (interval > 1) {
return Math.floor(interval) + ' years'
}
interval = seconds / 2592000
if (interval > 1) {
return Math.floor(interval) + ' months'
}
interval = seconds / 86400
if (interval > 1) {
return Math.floor(interval) + ' days'
}
interval = seconds / 3600
if (interval > 1) {
return Math.floor(interval) + ' hours'
}
interval = seconds / 60
if (interval > 1) {
return Math.floor(interval) + ' minutes'
}
return Math.floor(seconds) + ' seconds'
}

View File

@@ -0,0 +1,9 @@
import { Webhook } from 'models'
import { sendRequest } from 'utils'
export const saveWebhook = (webhookId: string, webhook: Partial<Webhook>) =>
sendRequest<{ webhook: Webhook }>({
method: 'PUT',
url: `/api/webhooks/${webhookId}`,
body: webhook,
})

View File

@@ -7,8 +7,8 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"test": "dotenv -e ./playwright/.env -e .env.local -- yarn playwright test", "test": "yarn playwright test",
"test:open": "dotenv -e ./playwright/.env -e .env.local -v PWDEBUG=1 -- yarn playwright test" "test:open": "PWDEBUG=1 yarn playwright test"
}, },
"dependencies": { "dependencies": {
"@sentry/nextjs": "^6.17.8", "@sentry/nextjs": "^6.17.8",

View File

@@ -1,13 +1,14 @@
import { NotFoundPage } from 'layouts/NotFoundPage' import { NotFoundPage } from 'layouts/NotFoundPage'
import { PublicTypebot } from 'models' import { PublicTypebot } from 'models'
import { GetServerSideProps, GetServerSidePropsContext } from 'next' import { GetServerSideProps, GetServerSidePropsContext } from 'next'
import { isDefined, isNotDefined, omit } from 'utils'
import { TypebotPage, TypebotPageProps } from '../layouts/TypebotPage' import { TypebotPage, TypebotPageProps } from '../layouts/TypebotPage'
import prisma from '../libs/prisma' import prisma from '../libs/prisma'
export const getServerSideProps: GetServerSideProps = async ( export const getServerSideProps: GetServerSideProps = async (
context: GetServerSidePropsContext context: GetServerSidePropsContext
) => { ) => {
let typebot: PublicTypebot | null let typebot: Omit<PublicTypebot, 'createdAt' | 'updatedAt'> | null
const isIE = /MSIE|Trident/.test(context.req.headers['user-agent'] ?? '') const isIE = /MSIE|Trident/.test(context.req.headers['user-agent'] ?? '')
const pathname = context.resolvedUrl.split('?')[0] const pathname = context.resolvedUrl.split('?')[0]
try { try {
@@ -42,17 +43,23 @@ const getTypebotFromPublicId = async (publicId?: string) => {
const typebot = await prisma.publicTypebot.findUnique({ const typebot = await prisma.publicTypebot.findUnique({
where: { publicId }, where: { publicId },
}) })
return (typebot as unknown as PublicTypebot) ?? null if (isNotDefined(typebot)) return null
return omit(typebot as PublicTypebot, 'createdAt', 'updatedAt')
} }
const getTypebotFromCustomDomain = async (customDomain: string) => { const getTypebotFromCustomDomain = async (customDomain: string) => {
const typebot = await prisma.publicTypebot.findUnique({ const typebot = await prisma.publicTypebot.findUnique({
where: { customDomain }, where: { customDomain },
}) })
return (typebot as unknown as PublicTypebot) ?? null if (isNotDefined(typebot)) return null
return omit(typebot as PublicTypebot, 'createdAt', 'updatedAt')
} }
const App = ({ typebot, ...props }: TypebotPageProps) => const App = ({ typebot, ...props }: TypebotPageProps) =>
typebot ? <TypebotPage typebot={typebot} {...props} /> : <NotFoundPage /> isDefined(typebot) ? (
<TypebotPage typebot={typebot} {...props} />
) : (
<NotFoundPage />
)
export default App export default App

View File

@@ -7,6 +7,7 @@ import {
Variable, Variable,
Webhook, Webhook,
WebhookResponse, WebhookResponse,
WebhookStep,
} from 'models' } from 'models'
import { parseVariables } from 'bot-engine' import { parseVariables } from 'bot-engine'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
@@ -32,14 +33,16 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
} }
const typebot = (await prisma.typebot.findUnique({ const typebot = (await prisma.typebot.findUnique({
where: { id: typebotId }, where: { id: typebotId },
})) as unknown as Typebot include: { webhooks: true },
})) as unknown as Typebot & { webhooks: Webhook[] }
const step = typebot.blocks.find(byId(blockId))?.steps.find(byId(stepId)) const step = typebot.blocks.find(byId(blockId))?.steps.find(byId(stepId))
if (!step || !('webhook' in step)) const webhook = typebot.webhooks.find(byId((step as WebhookStep).webhookId))
if (!webhook)
return res return res
.status(404) .status(404)
.send({ statusCode: 404, data: { message: `Couldn't find webhook` } }) .send({ statusCode: 404, data: { message: `Couldn't find webhook` } })
const result = await executeWebhook(typebot)( const result = await executeWebhook(typebot)(
step.webhook, webhook,
variables, variables,
blockId, blockId,
resultValues resultValues
@@ -133,7 +136,7 @@ const getBodyContent =
resultValues, resultValues,
blockId, blockId,
}: { }: {
body?: string body?: string | null
resultValues?: ResultValues resultValues?: ResultValues
blockId: string blockId: string
}): string | undefined => { }): string | undefined => {

View File

@@ -1,10 +1,9 @@
import { withSentry } from '@sentry/nextjs' import { withSentry } from '@sentry/nextjs'
import { Prisma } from 'db'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { HttpMethod, Typebot } from 'models' import { Typebot, WebhookStep } from 'models'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser } from 'services/api/utils' import { authenticateUser } from 'services/api/utils'
import { isWebhookStep, methodNotAllowed } from 'utils' import { byId, methodNotAllowed } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') { if (req.method === 'POST') {
@@ -15,17 +14,18 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(403).send({ message: 'url is missing in body' }) return res.status(403).send({ message: 'url is missing in body' })
const { url } = body const { url } = body
const typebotId = req.query.typebotId.toString() const typebotId = req.query.typebotId.toString()
const blockId = req.query.blockId.toString()
const stepId = req.query.stepId.toString() const stepId = req.query.stepId.toString()
const typebot = (await prisma.typebot.findUnique({ const typebot = (await prisma.typebot.findUnique({
where: { id_ownerId: { id: typebotId, ownerId: user.id } }, where: { id_ownerId: { id: typebotId, ownerId: user.id } },
})) as unknown as Typebot | undefined })) as unknown as Typebot | undefined
if (!typebot) return res.status(400).send({ message: 'Typebot not found' }) if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
try { try {
const updatedTypebot = addUrlToWebhookStep(url, typebot, stepId) const { webhookId } = typebot.blocks
await prisma.typebot.update({ .find(byId(blockId))
where: { id_ownerId: { id: typebotId, ownerId: user.id } }, ?.steps.find(byId(stepId)) as WebhookStep
data: { blocks: updatedTypebot.blocks as Prisma.JsonArray }, await prisma.webhook.update({ where: { id: webhookId }, data: { url } })
})
return res.send({ message: 'success' }) return res.send({ message: 'success' })
} catch (err) { } catch (err) {
return res return res
@@ -36,30 +36,4 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return methodNotAllowed(res) return methodNotAllowed(res)
} }
const addUrlToWebhookStep = (
url: string,
typebot: Typebot,
stepId: string
): Typebot => ({
...typebot,
blocks: typebot.blocks.map((b) => ({
...b,
steps: b.steps.map((s) => {
if (s.id === stepId) {
if (!isWebhookStep(s)) throw new Error()
return {
...s,
webhook: {
...s.webhook,
url,
method: HttpMethod.POST,
body: '{{state}}',
},
}
}
return s
}),
})),
})
export default withSentry(handler) export default withSentry(handler)

View File

@@ -1,30 +1,30 @@
import { withSentry } from '@sentry/nextjs' import { withSentry } from '@sentry/nextjs'
import { Prisma } from 'db'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { Typebot } from 'models' import { Typebot, WebhookStep } from 'models'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser } from 'services/api/utils' import { authenticateUser } from 'services/api/utils'
import { omit } from 'services/utils' import { byId, methodNotAllowed } from 'utils'
import { isWebhookStep, methodNotAllowed } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') { if (req.method === 'POST') {
const user = await authenticateUser(req) const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' }) if (!user) return res.status(401).json({ message: 'Not authenticated' })
const typebotId = req.query.typebotId.toString() const typebotId = req.query.typebotId.toString()
const blockId = req.query.blockId.toString()
const stepId = req.query.stepId.toString() const stepId = req.query.stepId.toString()
const typebot = (await prisma.typebot.findUnique({ const typebot = (await prisma.typebot.findUnique({
where: { id_ownerId: { id: typebotId, ownerId: user.id } }, where: { id_ownerId: { id: typebotId, ownerId: user.id } },
})) as unknown as Typebot | undefined })) as unknown as Typebot | undefined
if (!typebot) return res.status(400).send({ message: 'Typebot not found' }) if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
try { try {
const updatedTypebot = removeUrlFromWebhookStep(typebot, stepId) const { webhookId } = typebot.blocks
await prisma.typebot.update({ .find(byId(blockId))
where: { id_ownerId: { id: typebotId, ownerId: user.id } }, ?.steps.find(byId(stepId)) as WebhookStep
data: { await prisma.webhook.update({
blocks: updatedTypebot.blocks as Prisma.JsonArray, where: { id: webhookId },
}, data: { url: null },
}) })
return res.send({ message: 'success' }) return res.send({ message: 'success' })
} catch (err) { } catch (err) {
return res return res
@@ -35,21 +35,4 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return methodNotAllowed(res) return methodNotAllowed(res)
} }
const removeUrlFromWebhookStep = (
typebot: Typebot,
stepId: string
): Typebot => ({
...typebot,
blocks: typebot.blocks.map((b) => ({
...b,
steps: b.steps.map((s) => {
if (s.id === stepId) {
if (!isWebhookStep(s)) throw new Error()
return { ...s, webhook: omit(s.webhook, 'url') }
}
return s
}),
})),
})
export default withSentry(handler) export default withSentry(handler)

View File

@@ -3,7 +3,7 @@ import prisma from 'libs/prisma'
import { Block } from 'models' import { Block } from 'models'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser } from 'services/api/utils' import { authenticateUser } from 'services/api/utils'
import { isWebhookStep, methodNotAllowed } from 'utils' import { byId, isNotDefined, isWebhookStep, methodNotAllowed } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') { if (req.method === 'GET') {
@@ -12,13 +12,15 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const typebotId = req.query.typebotId.toString() const typebotId = req.query.typebotId.toString()
const typebot = await prisma.typebot.findUnique({ const typebot = await prisma.typebot.findUnique({
where: { id_ownerId: { id: typebotId, ownerId: user.id } }, where: { id_ownerId: { id: typebotId, ownerId: user.id } },
select: { blocks: true }, select: { blocks: true, webhooks: true },
}) })
const emptyWebhookSteps = (typebot?.blocks as Block[]).reduce< const emptyWebhookSteps = (typebot?.blocks as Block[]).reduce<
{ blockId: string; id: string; name: string }[] { blockId: string; id: string; name: string }[]
>((emptyWebhookSteps, block) => { >((emptyWebhookSteps, block) => {
const steps = block.steps.filter( const steps = block.steps.filter(
(step) => isWebhookStep(step) && !step.webhook.url (step) =>
isWebhookStep(step) &&
isNotDefined(typebot?.webhooks.find(byId(step.webhookId))?.url)
) )
return [ return [
...emptyWebhookSteps, ...emptyWebhookSteps,

View File

@@ -1,6 +1,9 @@
import { devices, PlaywrightTestConfig } from '@playwright/test' import { devices, PlaywrightTestConfig } from '@playwright/test'
import path from 'path' import path from 'path'
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('dotenv').config({ path: path.join(__dirname, '.env.local') })
const config: PlaywrightTestConfig = { const config: PlaywrightTestConfig = {
globalSetup: require.resolve(path.join(__dirname, 'playwright/global-setup')), globalSetup: require.resolve(path.join(__dirname, 'playwright/global-setup')),
testDir: path.join(__dirname, 'playwright/tests'), testDir: path.join(__dirname, 'playwright/tests'),

View File

@@ -284,12 +284,7 @@
"blockId": "webhookBlock", "blockId": "webhookBlock",
"type": "Webhook", "type": "Webhook",
"options": { "responseVariableMapping": [], "variablesForTest": [] }, "options": { "responseVariableMapping": [], "variablesForTest": [] },
"webhook": { "webhookId": "webhook1"
"id": "3zZp4961n6CeorWR43jdV9",
"method": "GET",
"headers": [],
"queryParams": []
}
} }
] ]
} }

View File

@@ -1,9 +1,6 @@
import { FullConfig } from '@playwright/test' import { FullConfig } from '@playwright/test'
import { setupDatabase, teardownDatabase } from './services/database' import { setupDatabase, teardownDatabase } from './services/database'
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('dotenv').config({ path: '.env' })
async function globalSetup(config: FullConfig) { async function globalSetup(config: FullConfig) {
const { baseURL } = config.projects[0].use const { baseURL } = config.projects[0].use
if (!baseURL) throw new Error('baseURL is missing') if (!baseURL) throw new Error('baseURL is missing')

View File

@@ -1,8 +1,6 @@
import { import {
Block,
defaultSettings, defaultSettings,
defaultTheme, defaultTheme,
PublicBlock,
PublicTypebot, PublicTypebot,
Step, Step,
Typebot, Typebot,
@@ -12,12 +10,13 @@ import { readFileSync } from 'fs'
const prisma = new PrismaClient() const prisma = new PrismaClient()
export const teardownDatabase = () => { export const teardownDatabase = async () => {
try { try {
return prisma.user.delete({ await prisma.user.delete({
where: { id: 'user' }, where: { id: 'user' },
}) })
} catch {} } catch {}
return
} }
export const setupDatabase = () => createUser() export const setupDatabase = () => createUser()
@@ -32,6 +31,15 @@ export const createUser = () =>
}, },
}) })
export const createWebhook = (typebotId: string) =>
prisma.webhook.create({
data: {
id: 'webhook1',
typebotId: typebotId,
method: 'GET',
},
})
export const createTypebots = async (partialTypebots: Partial<Typebot>[]) => { export const createTypebots = async (partialTypebots: Partial<Typebot>[]) => {
await prisma.typebot.createMany({ await prisma.typebot.createMany({
data: partialTypebots.map(parseTestTypebot) as any[], data: partialTypebots.map(parseTestTypebot) as any[],
@@ -49,7 +57,7 @@ const parseTypebotToPublicTypebot = (
): PublicTypebot => ({ ): PublicTypebot => ({
id, id,
name: typebot.name, name: typebot.name,
blocks: parseBlocksToPublicBlocks(typebot.blocks), blocks: typebot.blocks,
typebotId: typebot.id, typebotId: typebot.id,
theme: typebot.theme, theme: typebot.theme,
settings: typebot.settings, settings: typebot.settings,
@@ -57,16 +65,10 @@ const parseTypebotToPublicTypebot = (
variables: typebot.variables, variables: typebot.variables,
edges: typebot.edges, edges: typebot.edges,
customDomain: null, customDomain: null,
createdAt: typebot.createdAt,
updatedAt: typebot.updatedAt,
}) })
const parseBlocksToPublicBlocks = (blocks: Block[]): PublicBlock[] =>
blocks.map((b) => ({
...b,
steps: b.steps.map((s) =>
'webhook' in s ? { ...s, webhook: s.webhook.id } : s
),
}))
const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => ({ const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => ({
id: partialTypebot.id ?? 'typebot', id: partialTypebot.id ?? 'typebot',
folderId: null, folderId: null,

View File

@@ -1,5 +1,9 @@
import test, { expect } from '@playwright/test' import test, { expect } from '@playwright/test'
import { createResults, importTypebotInDatabase } from '../services/database' import {
createResults,
createWebhook,
importTypebotInDatabase,
} from '../services/database'
import path from 'path' import path from 'path'
const typebotId = 'webhook-flow' const typebotId = 'webhook-flow'
@@ -9,6 +13,7 @@ test.beforeAll(async () => {
path.join(__dirname, '../fixtures/typebots/api.json'), path.join(__dirname, '../fixtures/typebots/api.json'),
{ id: typebotId } { id: typebotId }
) )
await createWebhook(typebotId)
await createResults({ typebotId }) await createResults({ typebotId })
} catch (err) {} } catch (err) {}
}) })
@@ -49,13 +54,13 @@ test('can get webhook steps', async ({ request }) => {
test('can subscribe webhook', async ({ request }) => { test('can subscribe webhook', async ({ request }) => {
expect( expect(
( (
await request.patch( await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/subscribeWebhook`, `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/subscribeWebhook`,
{ data: { url: 'https://test.com' } } { data: { url: 'https://test.com' } }
) )
).status() ).status()
).toBe(401) ).toBe(401)
const response = await request.patch( const response = await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/subscribeWebhook`, `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/subscribeWebhook`,
{ {
headers: { headers: {
@@ -73,12 +78,12 @@ test('can subscribe webhook', async ({ request }) => {
test('can unsubscribe webhook', async ({ request }) => { test('can unsubscribe webhook', async ({ request }) => {
expect( expect(
( (
await request.delete( await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/unsubscribeWebhook` `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/unsubscribeWebhook`
) )
).status() ).status()
).toBe(401) ).toBe(401)
const response = await request.delete( const response = await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/unsubscribeWebhook`, `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/unsubscribeWebhook`,
{ {
headers: { Authorization: 'Bearer userToken' }, headers: { Authorization: 'Bearer userToken' },

View File

@@ -1,19 +0,0 @@
interface Omit {
// eslint-disable-next-line @typescript-eslint/ban-types
<T extends object, K extends [...(keyof T)[]]>(obj: T, ...keys: K): {
[K2 in Exclude<keyof T, K[number]>]: T[K2]
}
}
export const omit: Omit = (obj, ...keys) => {
const ret = {} as {
[K in keyof typeof obj]: typeof obj[K]
}
let key: keyof typeof obj
for (key in obj) {
if (!keys.includes(key)) {
ret[key] = obj[key]
}
}
return ret
}

View File

@@ -3,7 +3,6 @@ import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { ChatStep } from './ChatStep' import { ChatStep } from './ChatStep'
import { AvatarSideContainer } from './AvatarSideContainer' import { AvatarSideContainer } from './AvatarSideContainer'
import { HostAvatarsContext } from '../../contexts/HostAvatarsContext' import { HostAvatarsContext } from '../../contexts/HostAvatarsContext'
import { PublicStep } from 'models'
import { useTypebot } from '../../contexts/TypebotContext' import { useTypebot } from '../../contexts/TypebotContext'
import { import {
isBubbleStep, isBubbleStep,
@@ -17,9 +16,10 @@ import { executeIntegration } from 'services/integration'
import { parseRetryStep, stepCanBeRetried } from 'services/inputs' import { parseRetryStep, stepCanBeRetried } from 'services/inputs'
import { parseVariables } from 'index' import { parseVariables } from 'index'
import { useAnswers } from 'contexts/AnswersContext' import { useAnswers } from 'contexts/AnswersContext'
import { Step } from 'models'
type ChatBlockProps = { type ChatBlockProps = {
steps: PublicStep[] steps: Step[]
startStepIndex: number startStepIndex: number
onScroll: () => void onScroll: () => void
onBlockEnd: (edgeId?: string) => void onBlockEnd: (edgeId?: string) => void
@@ -34,7 +34,7 @@ export const ChatBlock = ({
const { typebot, updateVariableValue, createEdge, apiHost, isPreview } = const { typebot, updateVariableValue, createEdge, apiHost, isPreview } =
useTypebot() useTypebot()
const { resultValues } = useAnswers() const { resultValues } = useAnswers()
const [displayedSteps, setDisplayedSteps] = useState<PublicStep[]>([]) const [displayedSteps, setDisplayedSteps] = useState<Step[]>([])
useEffect(() => { useEffect(() => {
const nextStep = steps[startStepIndex] const nextStep = steps[startStepIndex]

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useAnswers } from '../../../contexts/AnswersContext' import { useAnswers } from '../../../contexts/AnswersContext'
import { useHostAvatars } from '../../../contexts/HostAvatarsContext' import { useHostAvatars } from '../../../contexts/HostAvatarsContext'
import { InputStep, InputStepType, PublicStep } from 'models' import { InputStep, InputStepType, Step } from 'models'
import { GuestBubble } from './bubbles/GuestBubble' import { GuestBubble } from './bubbles/GuestBubble'
import { TextForm } from './inputs/TextForm' import { TextForm } from './inputs/TextForm'
import { byId, isBubbleStep, isInputStep } from 'utils' import { byId, isBubbleStep, isInputStep } from 'utils'
@@ -16,7 +16,7 @@ export const ChatStep = ({
step, step,
onTransitionEnd, onTransitionEnd,
}: { }: {
step: PublicStep step: Step
onTransitionEnd: (answerContent?: string, isRetry?: boolean) => void onTransitionEnd: (answerContent?: string, isRetry?: boolean) => void
}) => { }) => {
const { addAnswer } = useAnswers() const { addAnswer } = useAnswers()

View File

@@ -5,7 +5,7 @@ import { useFrame } from 'react-frame-component'
import { setCssVariablesValue } from '../services/theme' import { setCssVariablesValue } from '../services/theme'
import { useAnswers } from '../contexts/AnswersContext' import { useAnswers } from '../contexts/AnswersContext'
import { deepEqual } from 'fast-equals' import { deepEqual } from 'fast-equals'
import { Answer, Edge, PublicBlock, Theme, VariableWithValue } from 'models' import { Answer, Block, Edge, Theme, VariableWithValue } from 'models'
import { byId, isNotDefined } from 'utils' import { byId, isNotDefined } from 'utils'
import { animateScroll as scroll } from 'react-scroll' import { animateScroll as scroll } from 'react-scroll'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
@@ -27,7 +27,7 @@ export const ConversationContainer = ({
const { typebot, updateVariableValue } = useTypebot() const { typebot, updateVariableValue } = useTypebot()
const { document: frameDocument } = useFrame() const { document: frameDocument } = useFrame()
const [displayedBlocks, setDisplayedBlocks] = useState< const [displayedBlocks, setDisplayedBlocks] = useState<
{ block: PublicBlock; startStepIndex: number }[] { block: Block; startStepIndex: number }[]
>([]) >([])
const [localAnswer, setLocalAnswer] = useState<Answer | undefined>() const [localAnswer, setLocalAnswer] = useState<Answer | undefined>()
const { const {

View File

@@ -5,7 +5,7 @@ import {
EmailInputStep, EmailInputStep,
InputStepType, InputStepType,
PhoneNumberInputStep, PhoneNumberInputStep,
PublicStep, Step,
UrlInputStep, UrlInputStep,
Variable, Variable,
} from 'models' } from 'models'
@@ -34,7 +34,7 @@ export const isInputValid = (
} }
export const stepCanBeRetried = ( export const stepCanBeRetried = (
step: PublicStep step: Step
): step is EmailInputStep | UrlInputStep | PhoneNumberInputStep => ): step is EmailInputStep | UrlInputStep | PhoneNumberInputStep =>
isInputStep(step) && 'retryMessageContent' in step.options isInputStep(step) && 'retryMessageContent' in step.options

View File

@@ -11,9 +11,9 @@ import {
GoogleAnalyticsStep, GoogleAnalyticsStep,
WebhookStep, WebhookStep,
SendEmailStep, SendEmailStep,
PublicBlock,
ZapierStep, ZapierStep,
ResultValues, ResultValues,
Block,
} from 'models' } from 'models'
import { stringify } from 'qs' import { stringify } from 'qs'
import { sendRequest } from 'utils' import { sendRequest } from 'utils'
@@ -31,7 +31,7 @@ type IntegrationContext = {
isPreview: boolean isPreview: boolean
variables: Variable[] variables: Variable[]
resultValues: ResultValues resultValues: ResultValues
blocks: PublicBlock[] blocks: Block[]
updateVariableValue: (variableId: string, value: string) => void updateVariableValue: (variableId: string, value: string) => void
} }
@@ -170,7 +170,6 @@ const executeWebhook = async (
isPreview, isPreview,
}: IntegrationContext }: IntegrationContext
) => { ) => {
if (!step.webhook) return step.outgoingEdgeId
const { data, error } = await sendRequest({ const { data, error } = await sendRequest({
url: `${apiHost}/api/typebots/${typebotId}/blocks/${blockId}/steps/${stepId}/executeWebhook`, url: `${apiHost}/api/typebots/${typebotId}/blocks/${blockId}/steps/${stepId}/executeWebhook`,
method: 'POST', method: 'POST',
@@ -186,6 +185,7 @@ const executeWebhook = async (
const value = safeEval(`(${JSON.stringify(data)}).${varMapping?.bodyPath}`) const value = safeEval(`(${JSON.stringify(data)}).${varMapping?.bodyPath}`)
updateVariableValue(varMapping.variableId, value) updateVariableValue(varMapping.variableId, value)
}) })
return step.outgoingEdgeId
} }
const sendEmail = async ( const sendEmail = async (

View File

@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "Webhook" (
"id" TEXT NOT NULL,
"url" TEXT,
"method" TEXT NOT NULL,
"queryParams" JSONB[],
"headers" JSONB[],
"body" TEXT,
"typebotId" TEXT NOT NULL,
CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_typebotId_fkey" FOREIGN KEY ("typebotId") REFERENCES "Typebot"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -129,6 +129,7 @@ model Typebot {
customDomain String? @unique customDomain String? @unique
collaborators CollaboratorsOnTypebots[] collaborators CollaboratorsOnTypebots[]
invitations Invitation[] invitations Invitation[]
webhooks Webhook[]
@@unique([id, ownerId]) @@unique([id, ownerId])
} }
@@ -202,3 +203,14 @@ model Coupon {
code String @id @unique code String @id @unique
dateRedeemed DateTime? dateRedeemed DateTime?
} }
model Webhook {
id String @id @default(cuid())
url String?
method String
queryParams Json[]
headers Json[]
body String?
typebotId String
typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
}

View File

@@ -4,3 +4,4 @@ export * from './result'
export * from './answer' export * from './answer'
export * from './utils' export * from './utils'
export * from './credentials' export * from './credentials'
export * from './webhooks'

View File

@@ -1,22 +1,13 @@
import { Block, Edge, Settings, Step, Theme, Variable } from './typebot' import { Block, Edge, Settings, Theme, Variable } from './typebot'
import { PublicTypebot as PublicTypebotFromPrisma } from 'db' import { PublicTypebot as PublicTypebotFromPrisma } from 'db'
export type PublicTypebot = Omit< export type PublicTypebot = Omit<
PublicTypebotFromPrisma, PublicTypebotFromPrisma,
| 'blocks' 'blocks' | 'theme' | 'settings' | 'variables' | 'edges'
| 'theme'
| 'settings'
| 'variables'
| 'edges'
| 'createdAt'
| 'updatedAt'
> & { > & {
blocks: PublicBlock[] blocks: Block[]
variables: Variable[] variables: Variable[]
edges: Edge[] edges: Edge[]
theme: Theme theme: Theme
settings: Settings settings: Settings
} }
export type PublicBlock = Omit<Block, 'steps'> & { steps: PublicStep[] }
export type PublicStep = Omit<Step, 'webhook'> & { webhook?: string }

View File

@@ -34,7 +34,7 @@ export type GoogleAnalyticsStep = StepBase & {
export type WebhookStep = StepBase & { export type WebhookStep = StepBase & {
type: IntegrationStepType.WEBHOOK type: IntegrationStepType.WEBHOOK
options: WebhookOptions options: WebhookOptions
webhook: Webhook webhookId: string
} }
export type ZapierStep = Omit<WebhookStep, 'type'> & { export type ZapierStep = Omit<WebhookStep, 'type'> & {
@@ -118,39 +118,12 @@ export type WebhookOptions = {
responseVariableMapping: ResponseVariableMapping[] responseVariableMapping: ResponseVariableMapping[]
} }
export enum HttpMethod {
POST = 'POST',
GET = 'GET',
PUT = 'PUT',
DELETE = 'DELETE',
PATCH = 'PATCH',
HEAD = 'HEAD',
CONNECT = 'CONNECT',
OPTIONS = 'OPTIONS',
TRACE = 'TRACE',
}
export type KeyValue = { id: string; key?: string; value?: string }
export type VariableForTest = { export type VariableForTest = {
id: string id: string
variableId?: string variableId?: string
value?: string value?: string
} }
export type Webhook = {
id: string
url?: string
method: HttpMethod
queryParams: KeyValue[]
headers: KeyValue[]
body?: string
}
export type WebhookResponse = {
statusCode: number
data?: unknown
}
export const defaultGoogleSheetsOptions: GoogleSheetsOptions = {} export const defaultGoogleSheetsOptions: GoogleSheetsOptions = {}
export const defaultGoogleAnalyticsOptions: GoogleAnalyticsOptions = {} export const defaultGoogleAnalyticsOptions: GoogleAnalyticsOptions = {}
@@ -160,12 +133,6 @@ export const defaultWebhookOptions: Omit<WebhookOptions, 'webhookId'> = {
variablesForTest: [], variablesForTest: [],
} }
export const defaultWebhookAttributes: Omit<Webhook, 'id'> = {
method: HttpMethod.GET,
headers: [],
queryParams: [],
}
export const defaultSendEmailOptions: SendEmailOptions = { export const defaultSendEmailOptions: SendEmailOptions = {
credentialsId: 'default', credentialsId: 'default',
recipients: [], recipients: [],

View File

@@ -0,0 +1,38 @@
import { Webhook as WebhookFromPrisma } from 'db'
export enum HttpMethod {
POST = 'POST',
GET = 'GET',
PUT = 'PUT',
DELETE = 'DELETE',
PATCH = 'PATCH',
HEAD = 'HEAD',
CONNECT = 'CONNECT',
OPTIONS = 'OPTIONS',
TRACE = 'TRACE',
}
export type KeyValue = { id: string; key?: string; value?: string }
export type Webhook = Omit<
WebhookFromPrisma,
'queryParams' | 'headers' | 'method'
> & {
queryParams: KeyValue[]
headers: KeyValue[]
method: HttpMethod
}
export type WebhookResponse = {
statusCode: number
data?: unknown
}
export const defaultWebhookAttributes: Omit<
Webhook,
'id' | 'body' | 'url' | 'typebotId'
> = {
method: HttpMethod.GET,
headers: [],
queryParams: [],
}

View File

@@ -10,6 +10,8 @@
}, },
"devDependencies": { "devDependencies": {
"db": "*", "db": "*",
"models": "*",
"utils": "*",
"ts-node": "^10.5.0" "ts-node": "^10.5.0"
}, },
"dependencies": { "dependencies": {

View File

@@ -7,7 +7,7 @@ import {
Block, Block,
} from 'models' } from 'models'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { byId, isDefined } from '.' import { byId, isDefined } from './utils'
export const methodNotAllowed = (res: NextApiResponse) => export const methodNotAllowed = (res: NextApiResponse) =>
res.status(405).json({ message: 'Method Not Allowed' }) res.status(405).json({ message: 'Method Not Allowed' })

View File

@@ -15,7 +15,6 @@ import {
WebhookStep, WebhookStep,
StepType, StepType,
StepWithOptionsType, StepWithOptionsType,
PublicStep,
ImageBubbleStep, ImageBubbleStep,
VideoBubbleStep, VideoBubbleStep,
} from 'models' } from 'models'
@@ -62,50 +61,42 @@ export const isNotDefined = <T>(
value: T | undefined | null value: T | undefined | null
): value is undefined | null => value === undefined || value === null ): value is undefined | null => value === undefined || value === null
export const isInputStep = (step: Step | PublicStep): step is InputStep => export const isInputStep = (step: Step): step is InputStep =>
(Object.values(InputStepType) as string[]).includes(step.type) (Object.values(InputStepType) as string[]).includes(step.type)
export const isBubbleStep = (step: Step | PublicStep): step is BubbleStep => export const isBubbleStep = (step: Step): step is BubbleStep =>
(Object.values(BubbleStepType) as string[]).includes(step.type) (Object.values(BubbleStepType) as string[]).includes(step.type)
export const isLogicStep = (step: Step | PublicStep): step is LogicStep => export const isLogicStep = (step: Step): step is LogicStep =>
(Object.values(LogicStepType) as string[]).includes(step.type) (Object.values(LogicStepType) as string[]).includes(step.type)
export const isTextBubbleStep = ( export const isTextBubbleStep = (step: Step): step is TextBubbleStep =>
step: Step | PublicStep step.type === BubbleStepType.TEXT
): step is TextBubbleStep => step.type === BubbleStepType.TEXT
export const isMediaBubbleStep = ( export const isMediaBubbleStep = (
step: Step | PublicStep step: Step
): step is ImageBubbleStep | VideoBubbleStep => ): step is ImageBubbleStep | VideoBubbleStep =>
step.type === BubbleStepType.IMAGE || step.type === BubbleStepType.VIDEO step.type === BubbleStepType.IMAGE || step.type === BubbleStepType.VIDEO
export const isTextInputStep = ( export const isTextInputStep = (step: Step): step is TextInputStep =>
step: Step | PublicStep step.type === InputStepType.TEXT
): step is TextInputStep => step.type === InputStepType.TEXT
export const isChoiceInput = ( export const isChoiceInput = (step: Step): step is ChoiceInputStep =>
step: Step | PublicStep step.type === InputStepType.CHOICE
): step is ChoiceInputStep => step.type === InputStepType.CHOICE
export const isSingleChoiceInput = ( export const isSingleChoiceInput = (step: Step): step is ChoiceInputStep =>
step: Step | PublicStep
): step is ChoiceInputStep =>
step.type === InputStepType.CHOICE && step.type === InputStepType.CHOICE &&
'options' in step && 'options' in step &&
!step.options.isMultipleChoice !step.options.isMultipleChoice
export const isConditionStep = ( export const isConditionStep = (step: Step): step is ConditionStep =>
step: Step | PublicStep step.type === LogicStepType.CONDITION
): step is ConditionStep => step.type === LogicStepType.CONDITION
export const isIntegrationStep = ( export const isIntegrationStep = (step: Step): step is IntegrationStep =>
step: Step | PublicStep
): step is IntegrationStep =>
(Object.values(IntegrationStepType) as string[]).includes(step.type) (Object.values(IntegrationStepType) as string[]).includes(step.type)
export const isWebhookStep = (step: Step | PublicStep): step is WebhookStep => export const isWebhookStep = (step: Step): step is WebhookStep =>
'webhook' in step 'webhookId' in step
export const isBubbleStepType = (type: StepType): type is BubbleStepType => export const isBubbleStepType = (type: StepType): type is BubbleStepType =>
(Object.values(BubbleStepType) as string[]).includes(type) (Object.values(BubbleStepType) as string[]).includes(type)
@@ -132,9 +123,29 @@ export const stepTypeHasItems = (
type === LogicStepType.CONDITION || type === InputStepType.CHOICE type === LogicStepType.CONDITION || type === InputStepType.CHOICE
export const stepHasItems = ( export const stepHasItems = (
step: Step | PublicStep step: Step
): step is ConditionStep | ChoiceInputStep => 'items' in step ): step is ConditionStep | ChoiceInputStep => 'items' in step
export const byId = (id?: string) => (obj: { id: string }) => obj.id === id export const byId = (id?: string) => (obj: { id: string }) => obj.id === id
export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1) export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
interface Omit {
// eslint-disable-next-line @typescript-eslint/ban-types
<T extends object, K extends [...(keyof T)[]]>(obj: T, ...keys: K): {
[K2 in Exclude<keyof T, K[number]>]: T[K2]
}
}
export const omit: Omit = (obj, ...keys) => {
const ret = {} as {
[K in keyof typeof obj]: typeof obj[K]
}
let key: keyof typeof obj
for (key in obj) {
if (!keys.includes(key)) {
ret[key] = obj[key]
}
}
return ret
}