2
0

♻️ (builder) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 09:35:48 +01:00
committed by Baptiste Arnaud
parent 3686465a85
commit 643571fe7d
683 changed files with 3907 additions and 3643 deletions

View 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,
},
},
}
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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,
})
}

View 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()
})
})

View 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,
}
}

View 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'

View File

@ -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,
})
}

View File

@ -0,0 +1,7 @@
import { sendRequest } from 'utils'
export const deleteTypebotQuery = async (id: string) =>
sendRequest({
url: `/api/typebots/${id}`,
method: 'DELETE',
})

View File

@ -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',
})

View File

@ -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
}

View File

@ -0,0 +1,4 @@
export * from './createTypebotQuery'
export * from './deleteTypebotQuery'
export * from './getTypebotQuery'
export * from './importTypebotQuery'

View File

@ -0,0 +1,6 @@
import { Typebot } from 'models'
export type TypebotInDashboard = Pick<
Typebot,
'id' | 'name' | 'publishedTypebotId' | 'icon'
>