♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
@ -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,
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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()
|
||||
})
|
||||
})
|
@ -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,
|
||||
}
|
||||
}
|
1
apps/builder/src/features/customDomains/index.ts
Normal file
1
apps/builder/src/features/customDomains/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { CustomDomainsDropdown } from './components/CustomDomainsDropdown'
|
@ -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,
|
||||
})
|
@ -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',
|
||||
})
|
Reference in New Issue
Block a user