♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
70
apps/builder/src/features/dashboard/api/parseNewTypebot.ts
Normal file
70
apps/builder/src/features/dashboard/api/parseNewTypebot.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import cuid from 'cuid'
|
||||
import {
|
||||
defaultSettings,
|
||||
defaultTheme,
|
||||
Group,
|
||||
StartBlock,
|
||||
Typebot,
|
||||
} from 'models'
|
||||
|
||||
export const parseNewTypebot = ({
|
||||
folderId,
|
||||
name,
|
||||
ownerAvatarUrl,
|
||||
workspaceId,
|
||||
isBrandingEnabled = true,
|
||||
}: {
|
||||
folderId: string | null
|
||||
workspaceId: string
|
||||
name: string
|
||||
ownerAvatarUrl?: string
|
||||
isBrandingEnabled?: boolean
|
||||
}): Omit<
|
||||
Typebot,
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
| 'id'
|
||||
| 'publishedTypebotId'
|
||||
| 'publicId'
|
||||
| 'customDomain'
|
||||
| 'icon'
|
||||
| 'isArchived'
|
||||
| 'isClosed'
|
||||
> => {
|
||||
const startGroupId = cuid()
|
||||
const startBlockId = cuid()
|
||||
const startBlock: StartBlock = {
|
||||
groupId: startGroupId,
|
||||
id: startBlockId,
|
||||
label: 'Start',
|
||||
type: 'start',
|
||||
}
|
||||
const startGroup: Group = {
|
||||
id: startGroupId,
|
||||
title: 'Start',
|
||||
graphCoordinates: { x: 0, y: 0 },
|
||||
blocks: [startBlock],
|
||||
}
|
||||
return {
|
||||
folderId,
|
||||
name,
|
||||
workspaceId,
|
||||
groups: [startGroup],
|
||||
edges: [],
|
||||
variables: [],
|
||||
theme: {
|
||||
...defaultTheme,
|
||||
chat: {
|
||||
...defaultTheme.chat,
|
||||
hostAvatar: { isEnabled: true, url: ownerAvatarUrl },
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
...defaultSettings,
|
||||
general: {
|
||||
...defaultSettings.general,
|
||||
isBrandingEnabled,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import { CloseButton, Flex, HStack, StackProps } from '@chakra-ui/react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
type VerifyEmailBannerProps = { id: string } & StackProps
|
||||
|
||||
export const Banner = ({ id, ...props }: VerifyEmailBannerProps) => {
|
||||
const [show, setShow] = useState(false)
|
||||
const localStorageKey = `banner-${id}`
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem(localStorageKey)) setShow(true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleCloseClick = () => {
|
||||
localStorage.setItem(localStorageKey, 'hide')
|
||||
setShow(false)
|
||||
}
|
||||
|
||||
if (!show) return <></>
|
||||
return (
|
||||
<HStack
|
||||
h="50px"
|
||||
bgColor="blue.400"
|
||||
color="white"
|
||||
justifyContent="center"
|
||||
align="center"
|
||||
w="full"
|
||||
{...props}
|
||||
>
|
||||
<Flex maxW="1000px" justifyContent="space-between" w="full">
|
||||
<HStack>{props.children}</HStack>
|
||||
<CloseButton rounded="full" onClick={handleCloseClick} />
|
||||
</Flex>
|
||||
</HStack>
|
||||
)
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
Text,
|
||||
Stack,
|
||||
Link,
|
||||
} from '@chakra-ui/react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const localStorageKey = 'typebot-20-modal'
|
||||
export const AnnoucementModal = ({ isOpen, onClose }: Props) => {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem(localStorageKey)) setShow(true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleCloseClick = () => {
|
||||
localStorage.setItem(localStorageKey, 'hide')
|
||||
setShow(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!show) return <></>
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleCloseClick} size="2xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>What's new in Typebot 2.0?</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody as={Stack} spacing="6" pb="10">
|
||||
<Text>Typebo 2.0 has been launched February the 15th 🎉.</Text>
|
||||
<iframe
|
||||
width="620"
|
||||
height="315"
|
||||
src="https://www.youtube.com/embed/u8FZHvlYviw"
|
||||
title="YouTube video player"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
style={{ borderRadius: '5px' }}
|
||||
/>
|
||||
<Text>
|
||||
Most questions are answered in this{' '}
|
||||
<Link
|
||||
href="https://docs.typebot.io"
|
||||
color="blue.500"
|
||||
textDecor="underline"
|
||||
>
|
||||
FAQ
|
||||
</Link>
|
||||
. If you have other questions, open up the bot on the bottom right
|
||||
corner. 😃
|
||||
</Text>
|
||||
<Text>Baptiste.</Text>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
@ -0,0 +1,135 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Text,
|
||||
HStack,
|
||||
Flex,
|
||||
SkeletonCircle,
|
||||
Button,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
HardDriveIcon,
|
||||
LogOutIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
} from '@/components/icons'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { useUser } from '@/features/account'
|
||||
import { useWorkspace } from '@/features/workspace'
|
||||
import { isNotDefined } from 'utils'
|
||||
import Link from 'next/link'
|
||||
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
|
||||
import { TypebotLogo } from '@/components/TypebotLogo'
|
||||
import { PlanTag } from '@/features/billing'
|
||||
import { WorkspaceSettingsModal } from '@/features/workspace'
|
||||
|
||||
export const DashboardHeader = () => {
|
||||
const { user } = useUser()
|
||||
|
||||
const { workspace, workspaces, switchWorkspace, createWorkspace } =
|
||||
useWorkspace()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
|
||||
const handleLogOut = () => {
|
||||
localStorage.removeItem('workspaceId')
|
||||
signOut()
|
||||
}
|
||||
|
||||
const handleCreateNewWorkspace = () =>
|
||||
createWorkspace(user?.name ?? undefined)
|
||||
|
||||
return (
|
||||
<Flex w="full" borderBottomWidth="1px" justify="center">
|
||||
<Flex
|
||||
justify="space-between"
|
||||
alignItems="center"
|
||||
h="16"
|
||||
maxW="1000px"
|
||||
flex="1"
|
||||
>
|
||||
<Link href="/typebots" data-testid="typebot-logo">
|
||||
<TypebotLogo w="30px" />
|
||||
</Link>
|
||||
<HStack>
|
||||
{user && workspace && (
|
||||
<WorkspaceSettingsModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
user={user}
|
||||
workspace={workspace}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
leftIcon={<SettingsIcon />}
|
||||
onClick={onOpen}
|
||||
isLoading={isNotDefined(workspace)}
|
||||
>
|
||||
Settings & Members
|
||||
</Button>
|
||||
<Menu placement="bottom-end">
|
||||
<MenuButton as={Button} variant="outline" px="2">
|
||||
<HStack>
|
||||
<SkeletonCircle
|
||||
isLoaded={workspace !== undefined}
|
||||
alignItems="center"
|
||||
display="flex"
|
||||
boxSize="20px"
|
||||
>
|
||||
<EmojiOrImageIcon
|
||||
boxSize="20px"
|
||||
icon={workspace?.icon}
|
||||
defaultIcon={HardDriveIcon}
|
||||
/>
|
||||
</SkeletonCircle>
|
||||
{workspace && (
|
||||
<>
|
||||
<Text noOfLines={1} maxW="200px">
|
||||
{workspace.name}
|
||||
</Text>
|
||||
<PlanTag plan={workspace.plan} />
|
||||
</>
|
||||
)}
|
||||
<ChevronLeftIcon transform="rotate(-90deg)" />
|
||||
</HStack>
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{workspaces
|
||||
?.filter((w) => w.id !== workspace?.id)
|
||||
.map((workspace) => (
|
||||
<MenuItem
|
||||
key={workspace.id}
|
||||
onClick={() => switchWorkspace(workspace.id)}
|
||||
>
|
||||
<HStack>
|
||||
<EmojiOrImageIcon
|
||||
icon={workspace.icon}
|
||||
boxSize="16px"
|
||||
defaultIcon={HardDriveIcon}
|
||||
/>
|
||||
<Text>{workspace.name}</Text>
|
||||
<PlanTag plan={workspace.plan} />
|
||||
</HStack>
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem onClick={handleCreateNewWorkspace} icon={<PlusIcon />}>
|
||||
New workspace
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleLogOut}
|
||||
icon={<LogOutIcon />}
|
||||
color="orange.500"
|
||||
>
|
||||
Log out
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
import { Seo } from '@/components/Seo'
|
||||
import { useUser } from '@/features/account'
|
||||
import { upgradePlanQuery } from '@/features/billing'
|
||||
import { TypebotDndProvider, FolderContent } from '@/features/folders'
|
||||
import { useWorkspace } from '@/features/workspace'
|
||||
import { Stack, VStack, Spinner, Text } from '@chakra-ui/react'
|
||||
import { Plan } from 'db'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { DashboardHeader } from './DashboardHeader'
|
||||
|
||||
export const DashboardPage = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { query } = useRouter()
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
|
||||
useEffect(() => {
|
||||
const { subscribePlan, chats, storage } = query as {
|
||||
subscribePlan: Plan | undefined
|
||||
chats: string | undefined
|
||||
storage: string | undefined
|
||||
}
|
||||
if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
|
||||
setIsLoading(true)
|
||||
upgradePlanQuery({
|
||||
user,
|
||||
plan: subscribePlan,
|
||||
workspaceId: workspace.id,
|
||||
additionalChats: chats ? parseInt(chats) : 0,
|
||||
additionalStorage: storage ? parseInt(storage) : 0,
|
||||
})
|
||||
}
|
||||
}, [query, user, workspace])
|
||||
|
||||
return (
|
||||
<Stack minH="100vh">
|
||||
<Seo title="My typebots" />
|
||||
<DashboardHeader />
|
||||
<TypebotDndProvider>
|
||||
{isLoading ? (
|
||||
<VStack w="full" justifyContent="center" pt="10" spacing={6}>
|
||||
<Text>You are being redirected...</Text>
|
||||
<Spinner />
|
||||
</VStack>
|
||||
) : (
|
||||
<FolderContent folder={null} />
|
||||
)}
|
||||
</TypebotDndProvider>
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -0,0 +1,180 @@
|
||||
import {
|
||||
chakra,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalOverlay,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { TypebotViewer } from 'bot-engine'
|
||||
import { useUser } from '@/features/account'
|
||||
import { Answer, Typebot } from 'models'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { getViewerUrl, sendRequest } from 'utils'
|
||||
import confetti from 'canvas-confetti'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { parseTypebotToPublicTypebot } from '@/features/publish'
|
||||
|
||||
type Props = { totalTypebots: number }
|
||||
|
||||
export const OnboardingModal = ({ totalTypebots }: Props) => {
|
||||
const { user, saveUser } = useUser()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const [typebot, setTypebot] = useState<Typebot>()
|
||||
const confettiCanvaContainer = useRef<HTMLCanvasElement | null>(null)
|
||||
const confettiCanon = useRef<confetti.CreateTypes>()
|
||||
const [chosenCategories, setChosenCategories] = useState<string[]>([])
|
||||
const [openedOnce, setOpenedOnce] = useState(false)
|
||||
|
||||
const { showToast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplate()
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (openedOnce) return
|
||||
const isNewUser =
|
||||
user &&
|
||||
new Date(user?.createdAt as unknown as string).toDateString() ===
|
||||
new Date().toDateString() &&
|
||||
totalTypebots === 0
|
||||
if (isNewUser) {
|
||||
onOpen()
|
||||
setOpenedOnce(true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
initConfettis()
|
||||
return () => {
|
||||
window.removeEventListener('message', handleIncomingMessage)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [confettiCanvaContainer.current])
|
||||
|
||||
const initConfettis = () => {
|
||||
if (!confettiCanvaContainer.current || confettiCanon.current) return
|
||||
confettiCanon.current = confetti.create(confettiCanvaContainer.current, {
|
||||
resize: true,
|
||||
useWorker: true,
|
||||
})
|
||||
window.addEventListener('message', handleIncomingMessage)
|
||||
}
|
||||
|
||||
const handleIncomingMessage = (message: MessageEvent) => {
|
||||
if (message.data.from === 'typebot') {
|
||||
if (message.data.action === 'shootConfettis' && confettiCanon.current)
|
||||
shootConfettis(confettiCanon.current)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTemplate = async () => {
|
||||
const { data, error } = await sendRequest(`/bots/onboarding.json`)
|
||||
if (error)
|
||||
return showToast({ title: error.name, description: error.message })
|
||||
setTypebot(data as Typebot)
|
||||
}
|
||||
|
||||
const handleNewAnswer = async (answer: Answer) => {
|
||||
const isName = answer.variableId === 'cl126f4hf000i2e6d8zvzc3t1'
|
||||
const isCompany = answer.variableId === 'cl126jqww000w2e6dq9yv4ifq'
|
||||
const isCategories = answer.variableId === 'cl126mo3t001b2e6dvyi16bkd'
|
||||
const isOtherCategories = answer.variableId === 'cl126q38p001q2e6d0hj23f6b'
|
||||
if (isName) saveUser({ name: answer.content })
|
||||
if (isCompany) saveUser({ company: answer.content })
|
||||
if (isCategories) {
|
||||
const onboardingCategories = answer.content.split(', ')
|
||||
saveUser({ onboardingCategories })
|
||||
setChosenCategories(onboardingCategories)
|
||||
}
|
||||
if (isOtherCategories)
|
||||
saveUser({
|
||||
onboardingCategories: [...chosenCategories, answer.content],
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="3xl"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
blockScrollOnMount={false}
|
||||
>
|
||||
<chakra.canvas
|
||||
ref={confettiCanvaContainer}
|
||||
pos="fixed"
|
||||
top="0"
|
||||
left="0"
|
||||
w="full"
|
||||
h="full"
|
||||
zIndex={9999}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<ModalOverlay />
|
||||
<ModalContent h="85vh">
|
||||
<ModalBody>
|
||||
{typebot && (
|
||||
<TypebotViewer
|
||||
apiHost={getViewerUrl({ isBuilder: true })}
|
||||
typebot={parseTypebotToPublicTypebot(typebot)}
|
||||
predefinedVariables={{
|
||||
Name: user?.name?.split(' ')[0] ?? undefined,
|
||||
}}
|
||||
onNewAnswer={handleNewAnswer}
|
||||
/>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const shootConfettis = (confettiCanon: confetti.CreateTypes) => {
|
||||
const count = 200
|
||||
const defaults = {
|
||||
origin: { y: 0.7 },
|
||||
}
|
||||
|
||||
const fire = (
|
||||
particleRatio: number,
|
||||
opts: {
|
||||
spread: number
|
||||
startVelocity?: number
|
||||
decay?: number
|
||||
scalar?: number
|
||||
}
|
||||
) => {
|
||||
confettiCanon(
|
||||
Object.assign({}, defaults, opts, {
|
||||
particleCount: Math.floor(count * particleRatio),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fire(0.25, {
|
||||
spread: 26,
|
||||
startVelocity: 55,
|
||||
})
|
||||
fire(0.2, {
|
||||
spread: 60,
|
||||
})
|
||||
fire(0.35, {
|
||||
spread: 100,
|
||||
decay: 0.91,
|
||||
scalar: 0.8,
|
||||
})
|
||||
fire(0.1, {
|
||||
spread: 120,
|
||||
startVelocity: 25,
|
||||
decay: 0.92,
|
||||
scalar: 1.2,
|
||||
})
|
||||
fire(0.1, {
|
||||
spread: 120,
|
||||
startVelocity: 45,
|
||||
})
|
||||
}
|
83
apps/builder/src/features/dashboard/dashboard.spec.ts
Normal file
83
apps/builder/src/features/dashboard/dashboard.spec.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { createFolders } from '@/test/utils/databaseActions'
|
||||
import { deleteButtonInConfirmDialog } from '@/test/utils/selectorUtils'
|
||||
import test, { expect } from '@playwright/test'
|
||||
import cuid from 'cuid'
|
||||
import { createTypebots } from 'utils/playwright/databaseActions'
|
||||
|
||||
test('folders navigation should work', async ({ page }) => {
|
||||
await page.goto('/typebots')
|
||||
const createFolderButton = page.locator('button:has-text("Create a folder")')
|
||||
await expect(createFolderButton).not.toBeDisabled()
|
||||
await createFolderButton.click()
|
||||
await page.click('text="New folder"')
|
||||
await page.fill('input[value="New folder"]', 'My folder #1')
|
||||
await page.press('input[value="My folder #1"]', 'Enter'),
|
||||
await page.click('li:has-text("My folder #1")')
|
||||
await expect(page.locator('h1:has-text("My folder #1")')).toBeVisible()
|
||||
await createFolderButton.click()
|
||||
await page.click('text="New folder"')
|
||||
await page.fill('input', 'My folder #2')
|
||||
await page.press('input', 'Enter')
|
||||
|
||||
await page.click('li:has-text("My folder #2")')
|
||||
await expect(page.locator('h1 >> text="My folder #2"')).toBeVisible()
|
||||
|
||||
await page.click('text="Back"')
|
||||
await expect(page.locator('span >> text="My folder #2"')).toBeVisible()
|
||||
|
||||
await page.click('text="Back"')
|
||||
await expect(page.locator('span >> text=My folder #1')).toBeVisible()
|
||||
})
|
||||
|
||||
test('folders and typebots should be deletable', async ({ page }) => {
|
||||
await createFolders([{ name: 'Folder #1' }, { name: 'Folder #2' }])
|
||||
await createTypebots([{ id: 'deletable-typebot', name: 'Typebot #1' }])
|
||||
await page.goto('/typebots')
|
||||
await page.click('button[aria-label="Show Folder #1 menu"]')
|
||||
await page.click('li:has-text("Folder #1") >> button:has-text("Delete")')
|
||||
await deleteButtonInConfirmDialog(page).click()
|
||||
await expect(page.locator('span >> text="Folder #1"')).not.toBeVisible()
|
||||
await page.click('button[aria-label="Show Typebot #1 menu"]')
|
||||
await page.click('li:has-text("Typebot #1") >> button:has-text("Delete")')
|
||||
await deleteButtonInConfirmDialog(page).click()
|
||||
await expect(page.locator('span >> text="Typebot #1"')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('folders and typebots should be movable', async ({ page }) => {
|
||||
const droppableFolderId = cuid()
|
||||
await createFolders([{ id: droppableFolderId, name: 'Droppable folder' }])
|
||||
await createTypebots([{ name: 'Draggable typebot' }])
|
||||
await page.goto('/typebots')
|
||||
const typebotButton = page.locator('li:has-text("Draggable typebot")')
|
||||
const folderButton = page.locator('li:has-text("Droppable folder")')
|
||||
await page.dragAndDrop(
|
||||
'li:has-text("Draggable typebot")',
|
||||
'li:has-text("Droppable folder")'
|
||||
)
|
||||
await expect(typebotButton).toBeHidden()
|
||||
await folderButton.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/folders/${droppableFolderId}`))
|
||||
await expect(typebotButton).toBeVisible()
|
||||
await page.dragAndDrop(
|
||||
'li:has-text("Draggable typebot")',
|
||||
'a:has-text("Back")'
|
||||
)
|
||||
await expect(typebotButton).toBeHidden()
|
||||
await page.click('a:has-text("Back")')
|
||||
await expect(typebotButton).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Free user', () => {
|
||||
test("create folder shouldn't be available", async ({ page }) => {
|
||||
await page.goto('/typebots')
|
||||
await page.click('text="Pro workspace"')
|
||||
await page.click('text="Free workspace"')
|
||||
await expect(page.locator('[data-testid="starter-lock-tag"]')).toBeVisible()
|
||||
await page.click('text=Create a folder')
|
||||
await expect(
|
||||
page.locator(
|
||||
'text="You need to upgrade your plan in order to create folders"'
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
31
apps/builder/src/features/dashboard/hooks/useTypebots.ts
Normal file
31
apps/builder/src/features/dashboard/hooks/useTypebots.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { fetcher } from '@/utils/helpers'
|
||||
import { stringify } from 'qs'
|
||||
import useSWR from 'swr'
|
||||
import { env } from 'utils'
|
||||
import { TypebotInDashboard } from '../types'
|
||||
|
||||
export const useTypebots = ({
|
||||
folderId,
|
||||
workspaceId,
|
||||
allFolders,
|
||||
onError,
|
||||
}: {
|
||||
workspaceId?: string
|
||||
folderId?: string
|
||||
allFolders?: boolean
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const params = stringify({ folderId, allFolders, workspaceId })
|
||||
const { data, error, mutate } = useSWR<
|
||||
{ typebots: TypebotInDashboard[] },
|
||||
Error
|
||||
>(workspaceId ? `/api/typebots?${params}` : null, fetcher, {
|
||||
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
|
||||
})
|
||||
if (error) onError(error)
|
||||
return {
|
||||
typebots: data?.typebots,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
}
|
||||
}
|
11
apps/builder/src/features/dashboard/index.ts
Normal file
11
apps/builder/src/features/dashboard/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export { DashboardPage } from './components/DashboardPage'
|
||||
export { DashboardHeader } from './components/DashboardHeader'
|
||||
export { parseNewTypebot } from './api/parseNewTypebot'
|
||||
export {
|
||||
createTypebotQuery,
|
||||
importTypebotQuery,
|
||||
getTypebotQuery,
|
||||
deleteTypebotQuery,
|
||||
} from './queries'
|
||||
export type { TypebotInDashboard } from './types'
|
||||
export { useTypebots } from './hooks/useTypebots'
|
@ -0,0 +1,18 @@
|
||||
import { Typebot } from 'models'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const createTypebotQuery = async ({
|
||||
folderId,
|
||||
workspaceId,
|
||||
}: Pick<Typebot, 'folderId' | 'workspaceId'>) => {
|
||||
const typebot = {
|
||||
folderId,
|
||||
name: 'My typebot',
|
||||
workspaceId,
|
||||
}
|
||||
return sendRequest<Typebot>({
|
||||
url: `/api/typebots`,
|
||||
method: 'POST',
|
||||
body: typebot,
|
||||
})
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const deleteTypebotQuery = async (id: string) =>
|
||||
sendRequest({
|
||||
url: `/api/typebots/${id}`,
|
||||
method: 'DELETE',
|
||||
})
|
@ -0,0 +1,8 @@
|
||||
import { Typebot } from 'models'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const getTypebotQuery = (typebotId: string) =>
|
||||
sendRequest<{ typebot: Typebot }>({
|
||||
url: `/api/typebots/${typebotId}`,
|
||||
method: 'GET',
|
||||
})
|
@ -0,0 +1,134 @@
|
||||
import { duplicateWebhookQueries } from '@/features/blocks/integrations/webhook'
|
||||
import cuid from 'cuid'
|
||||
import { Plan } from 'db'
|
||||
import {
|
||||
ChoiceInputBlock,
|
||||
ConditionBlock,
|
||||
LogicBlockType,
|
||||
Typebot,
|
||||
} from 'models'
|
||||
import { blockHasItems, isDefined, isWebhookBlock, sendRequest } from 'utils'
|
||||
|
||||
export const importTypebotQuery = async (typebot: Typebot, userPlan: Plan) => {
|
||||
const { typebot: newTypebot, webhookIdsMapping } = duplicateTypebot(
|
||||
typebot,
|
||||
userPlan
|
||||
)
|
||||
const { data, error } = await sendRequest<Typebot>({
|
||||
url: `/api/typebots`,
|
||||
method: 'POST',
|
||||
body: newTypebot,
|
||||
})
|
||||
if (!data) return { data, error }
|
||||
const webhookBlocks = typebot.groups
|
||||
.flatMap((b) => b.blocks)
|
||||
.filter(isWebhookBlock)
|
||||
await Promise.all(
|
||||
webhookBlocks.map((s) =>
|
||||
duplicateWebhookQueries(
|
||||
newTypebot.id,
|
||||
s.webhookId,
|
||||
webhookIdsMapping.get(s.webhookId) as string
|
||||
)
|
||||
)
|
||||
)
|
||||
return { data, error }
|
||||
}
|
||||
|
||||
const duplicateTypebot = (
|
||||
typebot: Typebot,
|
||||
userPlan: Plan
|
||||
): { typebot: Typebot; webhookIdsMapping: Map<string, string> } => {
|
||||
const groupIdsMapping = generateOldNewIdsMapping(typebot.groups)
|
||||
const edgeIdsMapping = generateOldNewIdsMapping(typebot.edges)
|
||||
const webhookIdsMapping = generateOldNewIdsMapping(
|
||||
typebot.groups
|
||||
.flatMap((b) => b.blocks)
|
||||
.filter(isWebhookBlock)
|
||||
.map((s) => ({ id: s.webhookId }))
|
||||
)
|
||||
const id = cuid()
|
||||
return {
|
||||
typebot: {
|
||||
...typebot,
|
||||
id,
|
||||
name: `${typebot.name} copy`,
|
||||
publishedTypebotId: null,
|
||||
publicId: null,
|
||||
customDomain: null,
|
||||
groups: typebot.groups.map((b) => ({
|
||||
...b,
|
||||
id: groupIdsMapping.get(b.id) as string,
|
||||
blocks: b.blocks.map((s) => {
|
||||
const newIds = {
|
||||
groupId: groupIdsMapping.get(s.groupId) as string,
|
||||
outgoingEdgeId: s.outgoingEdgeId
|
||||
? edgeIdsMapping.get(s.outgoingEdgeId)
|
||||
: undefined,
|
||||
}
|
||||
if (
|
||||
s.type === LogicBlockType.TYPEBOT_LINK &&
|
||||
s.options.typebotId === 'current' &&
|
||||
isDefined(s.options.groupId)
|
||||
)
|
||||
return {
|
||||
...s,
|
||||
options: {
|
||||
...s.options,
|
||||
groupId: groupIdsMapping.get(s.options.groupId as string),
|
||||
},
|
||||
}
|
||||
if (blockHasItems(s))
|
||||
return {
|
||||
...s,
|
||||
items: s.items.map((item) => ({
|
||||
...item,
|
||||
outgoingEdgeId: item.outgoingEdgeId
|
||||
? (edgeIdsMapping.get(item.outgoingEdgeId) as string)
|
||||
: undefined,
|
||||
})),
|
||||
...newIds,
|
||||
} as ChoiceInputBlock | ConditionBlock
|
||||
if (isWebhookBlock(s)) {
|
||||
return {
|
||||
...s,
|
||||
webhookId: webhookIdsMapping.get(s.webhookId) as string,
|
||||
...newIds,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...s,
|
||||
...newIds,
|
||||
}
|
||||
}),
|
||||
})),
|
||||
edges: typebot.edges.map((e) => ({
|
||||
...e,
|
||||
id: edgeIdsMapping.get(e.id) as string,
|
||||
from: {
|
||||
...e.from,
|
||||
groupId: groupIdsMapping.get(e.from.groupId) as string,
|
||||
},
|
||||
to: { ...e.to, groupId: groupIdsMapping.get(e.to.groupId) as string },
|
||||
})),
|
||||
settings:
|
||||
typebot.settings.general.isBrandingEnabled === false &&
|
||||
userPlan === Plan.FREE
|
||||
? {
|
||||
...typebot.settings,
|
||||
general: { ...typebot.settings.general, isBrandingEnabled: true },
|
||||
}
|
||||
: typebot.settings,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
resultsTablePreferences: typebot.resultsTablePreferences ?? undefined,
|
||||
},
|
||||
webhookIdsMapping,
|
||||
}
|
||||
}
|
||||
|
||||
const generateOldNewIdsMapping = (itemWithId: { id: string }[]) => {
|
||||
const idsMapping: Map<string, string> = new Map()
|
||||
itemWithId.forEach((item) => idsMapping.set(item.id, cuid()))
|
||||
return idsMapping
|
||||
}
|
4
apps/builder/src/features/dashboard/queries/index.ts
Normal file
4
apps/builder/src/features/dashboard/queries/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './createTypebotQuery'
|
||||
export * from './deleteTypebotQuery'
|
||||
export * from './getTypebotQuery'
|
||||
export * from './importTypebotQuery'
|
6
apps/builder/src/features/dashboard/types.ts
Normal file
6
apps/builder/src/features/dashboard/types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Typebot } from 'models'
|
||||
|
||||
export type TypebotInDashboard = Pick<
|
||||
Typebot,
|
||||
'id' | 'name' | 'publishedTypebotId' | 'icon'
|
||||
>
|
Reference in New Issue
Block a user