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,191 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
Heading,
ModalCloseButton,
ModalBody,
Stack,
Input,
HStack,
Alert,
ModalFooter,
Button,
Text,
Tooltip,
} from '@chakra-ui/react'
import { useToast } from '@/hooks/useToast'
import { useEffect, useRef, useState } from 'react'
import { env, getViewerUrl } from 'utils'
import { createCustomDomainQuery } from '../queries/createCustomDomainQuery'
const hostnameRegex =
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/
type CustomDomainModalProps = {
workspaceId: string
isOpen: boolean
onClose: () => void
domain?: string
onNewDomain: (customDomain: string) => void
}
export const CustomDomainModal = ({
workspaceId,
isOpen,
onClose,
onNewDomain,
domain = '',
}: CustomDomainModalProps) => {
const inputRef = useRef<HTMLInputElement>(null)
const [isLoading, setIsLoading] = useState(false)
const [inputValue, setInputValue] = useState(domain)
const [hostname, setHostname] = useState({
domain: splitHostname(domain)?.domain ?? '',
subdomain: splitHostname(domain)?.subdomain ?? '',
})
const { showToast } = useToast()
useEffect(() => {
if (inputValue === '' || !isOpen) return
if (!hostnameRegex.test(inputValue))
return setHostname({ domain: '', subdomain: '' })
const hostnameDetails = splitHostname(inputValue)
if (!hostnameDetails) return setHostname({ domain: '', subdomain: '' })
setHostname({
domain: hostnameDetails.domain,
subdomain: hostnameDetails.subdomain,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputValue])
const onAddDomainClick = async () => {
if (!hostnameRegex.test(inputValue)) return
setIsLoading(true)
const { error } = await createCustomDomainQuery(workspaceId, {
name: inputValue,
})
setIsLoading(false)
if (error)
return showToast({ title: error.name, description: error.message })
onNewDomain(inputValue)
onClose()
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="xl"
initialFocusRef={inputRef}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>
<Heading size="md">Add a custom domain</Heading>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Stack>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="bot.my-domain.com"
/>
{hostname.domain !== '' && (
<>
<Text>
Add the following record in your DNS provider to continue:
</Text>
{hostname.subdomain ? (
<HStack
bgColor="gray.700"
color="white"
rounded="md"
p={4}
spacing={8}
>
<Stack>
<Text fontWeight="bold">Type</Text>
<Text>CNAME</Text>
</Stack>
<Stack>
<Text fontWeight="bold">Name</Text>
<Text>{hostname.subdomain}</Text>
</Stack>
<Stack>
<Text fontWeight="bold">Value</Text>
<Text>
{env('VIEWER_INTERNAL_URL') ??
getViewerUrl({ isBuilder: true })}
</Text>
</Stack>
</HStack>
) : (
<HStack
bgColor="gray.700"
color="white"
rounded="md"
p={4}
spacing={8}
>
<Stack>
<Text fontWeight="bold">Type</Text>
<Text>A</Text>
</Stack>
<Stack>
<Text fontWeight="bold">Name</Text>
<Text>@</Text>
</Stack>
<Stack>
<Text fontWeight="bold">Value</Text>
<Text>76.76.21.21</Text>
</Stack>
</HStack>
)}
<Alert rounded="md">
Depending on your provider, it might take some time for the
changes to apply
</Alert>
</>
)}
</Stack>
</ModalBody>
<ModalFooter as={HStack}>
<Tooltip
label="Domain is invalid"
isDisabled={hostname.domain !== ''}
>
<span>
<Button
onClick={onAddDomainClick}
isDisabled={hostname.domain === ''}
isLoading={isLoading}
colorScheme="blue"
>
Save
</Button>
</span>
</Tooltip>
</ModalFooter>
</ModalContent>
</Modal>
)
}
const splitHostname = (
hostname: string
): { domain: string; type: string; subdomain: string } | undefined => {
if (!hostname.includes('.')) return
const urlParts = /([a-z-0-9]{2,63}).([a-z.]{2,5})$/.exec(hostname)
if (!urlParts) return
const [, domain, type] = urlParts
const subdomain = hostname.replace(`${domain}.${type}`, '').slice(0, -1)
return {
domain,
type,
subdomain,
}
}

View File

@ -0,0 +1,132 @@
import {
Button,
IconButton,
Menu,
MenuButton,
MenuButtonProps,
MenuItem,
MenuList,
Stack,
Text,
useDisclosure,
} from '@chakra-ui/react'
import { ChevronLeftIcon, PlusIcon, TrashIcon } from '@/components/icons'
import React, { useState } from 'react'
import { CustomDomainModal } from './CustomDomainModal'
import { useWorkspace } from '@/features/workspace'
import { useToast } from '@/hooks/useToast'
import { useCustomDomains } from '../hooks/useCustomDomains'
import { deleteCustomDomainQuery } from '../queries/deleteCustomDomainQuery'
type Props = Omit<MenuButtonProps, 'type'> & {
currentCustomDomain?: string
onCustomDomainSelect: (domain: string) => void
}
export const CustomDomainsDropdown = ({
currentCustomDomain,
onCustomDomainSelect,
...props
}: Props) => {
const [isDeleting, setIsDeleting] = useState('')
const { isOpen, onOpen, onClose } = useDisclosure()
const { workspace } = useWorkspace()
const { showToast } = useToast()
const { customDomains, mutate } = useCustomDomains({
workspaceId: workspace?.id,
onError: (error) =>
showToast({ title: error.name, description: error.message }),
})
const handleMenuItemClick = (customDomain: string) => () =>
onCustomDomainSelect(customDomain)
const handleDeleteDomainClick =
(domainName: string) => async (e: React.MouseEvent) => {
if (!workspace) return
e.stopPropagation()
setIsDeleting(domainName)
const { error } = await deleteCustomDomainQuery(workspace.id, domainName)
setIsDeleting('')
if (error)
return showToast({ title: error.name, description: error.message })
mutate({
customDomains: (customDomains ?? []).filter(
(cd) => cd.name !== domainName
),
})
}
const handleNewDomain = (domain: string) => {
if (!workspace) return
mutate({
customDomains: [
...(customDomains ?? []),
{ name: domain, workspaceId: workspace?.id },
],
})
handleMenuItemClick(domain)()
}
return (
<Menu isLazy placement="bottom-start" matchWidth>
{workspace?.id && (
<CustomDomainModal
workspaceId={workspace.id}
isOpen={isOpen}
onClose={onClose}
onNewDomain={handleNewDomain}
/>
)}
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
colorScheme="gray"
justifyContent="space-between"
textAlign="left"
{...props}
>
<Text noOfLines={1} overflowY="visible" h="20px">
{currentCustomDomain ?? 'Add my domain'}
</Text>
</MenuButton>
<MenuList maxW="500px" shadow="lg">
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
{(customDomains ?? []).map((customDomain) => (
<Button
role="menuitem"
minH="40px"
key={customDomain.name}
onClick={handleMenuItemClick(customDomain.name)}
fontSize="16px"
fontWeight="normal"
rounded="none"
colorScheme="gray"
variant="ghost"
justifyContent="space-between"
>
{customDomain.name}
<IconButton
icon={<TrashIcon />}
aria-label="Remove domain"
size="xs"
onClick={handleDeleteDomainClick(customDomain.name)}
isLoading={isDeleting === customDomain.name}
/>
</Button>
))}
<MenuItem
maxW="500px"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
icon={<PlusIcon />}
onClick={onOpen}
>
Connect new
</MenuItem>
</Stack>
</MenuList>
</Menu>
)
}

View File

@ -0,0 +1,66 @@
import test, { expect } from '@playwright/test'
import { InputBlockType, defaultTextInputOptions } from 'models'
import cuid from 'cuid'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { starterWorkspaceId } from 'utils/playwright/databaseSetup'
test('should be able to connect custom domain', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/share`)
await page.click('text=Add my domain')
await page.click('text=Connect new')
await page.fill('input[placeholder="bot.my-domain.com"]', 'test')
await expect(page.locator('text=Save')).toBeDisabled()
await page.fill('input[placeholder="bot.my-domain.com"]', 'yolozeeer.com')
await expect(page.locator('text="A"')).toBeVisible()
await page.fill('input[placeholder="bot.my-domain.com"]', 'sub.yolozeeer.com')
await expect(page.locator('text="CNAME"')).toBeVisible()
await page.click('text=Save')
await expect(page.locator('text="https://sub.yolozeeer.com/"')).toBeVisible()
await page.click('text="Edit" >> nth=1')
await page.fill('text=https://sub.yolozeeer.com/Copy >> input', 'custom-path')
await page.press(
'text=https://sub.yolozeeer.com/custom-path >> input',
'Enter'
)
await expect(page.locator('text="custom-path"')).toBeVisible()
await page.click('[aria-label="Remove custom domain"]')
await expect(page.locator('text=sub.yolozeeer.com')).toBeHidden()
await page.click('button >> text=Add my domain')
await page.click('[aria-label="Remove domain"]')
await expect(page.locator('[aria-label="Remove domain"]')).toBeHidden()
})
test.describe('Starter workspace', () => {
test("Add my domain shouldn't be available", async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
workspaceId: starterWorkspaceId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/share`)
await expect(page.locator('[data-testid="pro-lock-tag"]')).toBeVisible()
await page.click('text=Add my domain')
await expect(
page.locator(
'text="You need to upgrade your plan in order to add custom domains"'
)
).toBeVisible()
})
})

View File

@ -0,0 +1,26 @@
import { CustomDomain } from 'db'
import { stringify } from 'qs'
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
export const useCustomDomains = ({
workspaceId,
onError,
}: {
workspaceId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<
{ customDomains: Omit<CustomDomain, 'createdAt'>[] },
Error
>(
workspaceId ? `/api/customDomains?${stringify({ workspaceId })}` : null,
fetcher
)
if (error) onError(error)
return {
customDomains: data?.customDomains,
isLoading: !error && !data,
mutate,
}
}

View File

@ -0,0 +1 @@
export { CustomDomainsDropdown } from './components/CustomDomainsDropdown'

View File

@ -0,0 +1,15 @@
import { CustomDomain, Credentials } from 'db'
import { stringify } from 'qs'
import { sendRequest } from 'utils'
export const createCustomDomainQuery = async (
workspaceId: string,
customDomain: Omit<CustomDomain, 'createdAt' | 'workspaceId'>
) =>
sendRequest<{
credentials: Credentials
}>({
url: `/api/customDomains?${stringify({ workspaceId })}`,
method: 'POST',
body: customDomain,
})

View File

@ -0,0 +1,14 @@
import { Credentials } from 'db'
import { stringify } from 'qs'
import { sendRequest } from 'utils'
export const deleteCustomDomainQuery = async (
workspaceId: string,
customDomain: string
) =>
sendRequest<{
credentials: Credentials
}>({
url: `/api/customDomains/${customDomain}?${stringify({ workspaceId })}`,
method: 'DELETE',
})