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,95 @@
import { Flex, FormLabel, Stack, Switch, useDisclosure } from '@chakra-ui/react'
import { useWorkspace } from '@/features/workspace'
import { Plan } from 'db'
import { GeneralSettings } from 'models'
import React from 'react'
import { isDefined } from 'utils'
import { ChangePlanModal, isFreePlan, LimitReached } from '@/features/billing'
import { SwitchWithLabel } from '@/components/SwitchWithLabel'
import { LockTag } from '@/features/billing'
type Props = {
generalSettings: GeneralSettings
onGeneralSettingsChange: (generalSettings: GeneralSettings) => void
}
export const GeneralSettingsForm = ({
generalSettings,
onGeneralSettingsChange,
}: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { workspace } = useWorkspace()
const isWorkspaceFreePlan = isFreePlan(workspace)
const handleSwitchChange = () => {
if (generalSettings?.isBrandingEnabled && isWorkspaceFreePlan) return
onGeneralSettingsChange({
...generalSettings,
isBrandingEnabled: !generalSettings?.isBrandingEnabled,
})
}
const handleNewResultOnRefreshChange = (isRememberSessionChecked: boolean) =>
onGeneralSettingsChange({
...generalSettings,
isNewResultOnRefreshEnabled: !isRememberSessionChecked,
})
const handleInputPrefillChange = (isInputPrefillEnabled: boolean) =>
onGeneralSettingsChange({
...generalSettings,
isInputPrefillEnabled,
})
const handleHideQueryParamsChange = (isHideQueryParamsEnabled: boolean) =>
onGeneralSettingsChange({
...generalSettings,
isHideQueryParamsEnabled,
})
return (
<Stack spacing={6}>
<ChangePlanModal
isOpen={isOpen}
onClose={onClose}
type={LimitReached.BRAND}
/>
<Flex
justifyContent="space-between"
align="center"
onClick={isWorkspaceFreePlan ? onOpen : undefined}
>
<FormLabel htmlFor="branding" mb="0">
Typebot.io branding{' '}
{isWorkspaceFreePlan && <LockTag plan={Plan.STARTER} />}
</FormLabel>
<Switch
id="branding"
isChecked={generalSettings.isBrandingEnabled}
onChange={handleSwitchChange}
/>
</Flex>
<SwitchWithLabel
label="Prefill input"
initialValue={generalSettings.isInputPrefillEnabled ?? true}
onCheckChange={handleInputPrefillChange}
moreInfoContent="Inputs are automatically pre-filled whenever their associated variable has a value"
/>
<SwitchWithLabel
label="Remember session"
initialValue={
isDefined(generalSettings.isNewResultOnRefreshEnabled)
? !generalSettings.isNewResultOnRefreshEnabled
: true
}
onCheckChange={handleNewResultOnRefreshChange}
moreInfoContent="If the user refreshes the page, its existing results will be overwritten. Disable this if you want to create a new results every time the user refreshes the page."
/>
<SwitchWithLabel
label="Hide query params on bot start"
initialValue={generalSettings.isHideQueryParamsEnabled ?? true}
onCheckChange={handleHideQueryParamsChange}
moreInfoContent="If your URL contains query params, they will be automatically hidden when the bot starts."
/>
</Stack>
)
}

View File

@ -0,0 +1,133 @@
import React from 'react'
import { Metadata } from 'models'
import {
FormLabel,
Popover,
PopoverTrigger,
Stack,
Image,
PopoverContent,
HStack,
Text,
} from '@chakra-ui/react'
import { CodeEditor } from '@/components/CodeEditor'
import { ImageUploadContent } from '@/components/ImageUploadContent'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { Input, Textarea } from '@/components/inputs'
type Props = {
typebotId: string
typebotName: string
metadata: Metadata
onMetadataChange: (metadata: Metadata) => void
}
export const MetadataForm = ({
typebotId,
typebotName,
metadata,
onMetadataChange,
}: Props) => {
const handleTitleChange = (title: string) =>
onMetadataChange({ ...metadata, title })
const handleDescriptionChange = (description: string) =>
onMetadataChange({ ...metadata, description })
const handleFavIconSubmit = (favIconUrl: string) =>
onMetadataChange({ ...metadata, favIconUrl })
const handleImageSubmit = (imageUrl: string) =>
onMetadataChange({ ...metadata, imageUrl })
const handleHeadCodeChange = (customHeadCode: string) =>
onMetadataChange({ ...metadata, customHeadCode })
return (
<Stack spacing="6">
<Stack>
<FormLabel mb="0" htmlFor="icon">
Icon:
</FormLabel>
<Popover isLazy placement="top">
<PopoverTrigger>
<Image
src={metadata.favIconUrl ?? '/favicon.png'}
w="20px"
alt="Fav icon"
cursor="pointer"
_hover={{ filter: 'brightness(.9)' }}
transition="filter 200ms"
rounded="md"
/>
</PopoverTrigger>
<PopoverContent p="4">
<ImageUploadContent
filePath={`typebots/${typebotId}/favIcon`}
defaultUrl={metadata.favIconUrl ?? ''}
onSubmit={handleFavIconSubmit}
isGiphyEnabled={false}
/>
</PopoverContent>
</Popover>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="image">
Image:
</FormLabel>
<Popover isLazy placement="top">
<PopoverTrigger>
<Image
src={metadata.imageUrl ?? '/viewer-preview.png'}
alt="Website image"
cursor="pointer"
_hover={{ filter: 'brightness(.9)' }}
transition="filter 200ms"
rounded="md"
/>
</PopoverTrigger>
<PopoverContent p="4">
<ImageUploadContent
filePath={`typebots/${typebotId}/ogImage`}
defaultUrl={metadata.imageUrl}
onSubmit={handleImageSubmit}
isGiphyEnabled={false}
/>
</PopoverContent>
</Popover>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="title">
Title:
</FormLabel>
<Input
id="title"
defaultValue={metadata.title ?? typebotName}
onChange={handleTitleChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="description">
Description:
</FormLabel>
<Textarea
id="description"
defaultValue={metadata.description}
onChange={handleDescriptionChange}
/>
</Stack>
<Stack>
<HStack as={FormLabel} mb="0" htmlFor="head">
<Text>Custom head code:</Text>
<MoreInfoTooltip>
Will be pasted at the bottom of the header section, just above the
closing head tag. Only `meta` and `script` tags are allowed.
</MoreInfoTooltip>
</HStack>
<CodeEditor
id="head"
value={metadata.customHeadCode ?? ''}
onChange={handleHeadCodeChange}
lang="html"
withVariableButton={false}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,35 @@
import { Seo } from '@/components/Seo'
import { TypebotHeader, useTypebot } from '@/features/editor'
import { Flex } from '@chakra-ui/react'
import { TypebotViewer } from 'bot-engine'
import { useMemo } from 'react'
import { getViewerUrl } from 'utils'
import { SettingsSideMenu } from './SettingsSideMenu'
import { parseTypebotToPublicTypebot } from '@/features/publish'
export const SettingsPage = () => {
const { typebot } = useTypebot()
const publicTypebot = useMemo(
() => (typebot ? parseTypebotToPublicTypebot(typebot) : undefined),
// eslint-disable-next-line react-hooks/exhaustive-deps
[typebot?.settings]
)
return (
<Flex overflow="hidden" h="100vh" flexDir="column">
<Seo title="Settings" />
<TypebotHeader />
<Flex h="full" w="full">
<SettingsSideMenu />
<Flex flex="1">
{publicTypebot && (
<TypebotViewer
apiHost={getViewerUrl({ isBuilder: true })}
typebot={publicTypebot}
/>
)}
</Flex>
</Flex>
</Flex>
)
}

View File

@ -0,0 +1,103 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Heading,
HStack,
Stack,
} from '@chakra-ui/react'
import { ChatIcon, CodeIcon, MoreVerticalIcon } from '@/components/icons'
import { GeneralSettings, Metadata, TypingEmulation } from 'models'
import React from 'react'
import { GeneralSettingsForm } from './GeneralSettingsForm'
import { MetadataForm } from './MetadataForm'
import { TypingEmulationForm } from './TypingEmulationForm'
import { headerHeight, useTypebot } from '@/features/editor'
export const SettingsSideMenu = () => {
const { typebot, updateTypebot } = useTypebot()
const handleTypingEmulationChange = (typingEmulation: TypingEmulation) =>
typebot &&
updateTypebot({ settings: { ...typebot.settings, typingEmulation } })
const handleGeneralSettingsChange = (general: GeneralSettings) =>
typebot && updateTypebot({ settings: { ...typebot.settings, general } })
const handleMetadataChange = (metadata: Metadata) =>
typebot && updateTypebot({ settings: { ...typebot.settings, metadata } })
return (
<Stack
flex="1"
maxW="400px"
height={`calc(100vh - ${headerHeight}px)`}
borderRightWidth={1}
pt={10}
spacing={10}
overflowY="scroll"
pb="20"
>
<Heading fontSize="xl" textAlign="center">
Settings
</Heading>
<Accordion allowMultiple defaultIndex={[0]}>
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<MoreVerticalIcon transform={'rotate(90deg)'} />
<Heading fontSize="lg">General</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} px="6">
{typebot && (
<GeneralSettingsForm
generalSettings={typebot.settings.general}
onGeneralSettingsChange={handleGeneralSettingsChange}
/>
)}
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<ChatIcon />
<Heading fontSize="lg">Typing emulation</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} px="6">
{typebot && (
<TypingEmulationForm
typingEmulation={typebot.settings.typingEmulation}
onUpdate={handleTypingEmulationChange}
/>
)}
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<CodeIcon />
<Heading fontSize="lg">Metadata</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} px="6">
{typebot && (
<MetadataForm
typebotId={typebot.id}
typebotName={typebot.name}
metadata={typebot.settings.metadata}
onMetadataChange={handleMetadataChange}
/>
)}
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
)
}

View File

@ -0,0 +1,69 @@
import { Flex, FormLabel, Stack, Switch } from '@chakra-ui/react'
import { TypingEmulation } from 'models'
import React from 'react'
import { isDefined } from 'utils'
import { SmartNumberInput } from '@/components/inputs'
type Props = {
typingEmulation: TypingEmulation
onUpdate: (typingEmulation: TypingEmulation) => void
}
export const TypingEmulationForm = ({ typingEmulation, onUpdate }: Props) => {
const handleSwitchChange = () =>
onUpdate({
...typingEmulation,
enabled: !typingEmulation.enabled,
})
const handleSpeedChange = (speed?: number) =>
isDefined(speed) && onUpdate({ ...typingEmulation, speed })
const handleMaxDelayChange = (maxDelay?: number) =>
isDefined(maxDelay) && onUpdate({ ...typingEmulation, maxDelay })
return (
<Stack spacing={6}>
<Flex justifyContent="space-between" align="center">
<FormLabel htmlFor="typing-emulation" mb="0">
Typing emulation
</FormLabel>
<Switch
id="typing-emulation"
isChecked={typingEmulation.enabled}
onChange={handleSwitchChange}
/>
</Flex>
{typingEmulation.enabled && (
<Stack pl={10}>
<Flex justify="space-between" align="center">
<FormLabel htmlFor="speed" mb="0">
Words per minutes:
</FormLabel>
<SmartNumberInput
id="speed"
data-testid="speed"
value={typingEmulation.speed}
onValueChange={handleSpeedChange}
maxW="100px"
step={30}
/>
</Flex>
<Flex justify="space-between" align="center">
<FormLabel htmlFor="max-delay" mb="0">
Max delay (in seconds):
</FormLabel>
<SmartNumberInput
id="max-delay"
data-testid="max-delay"
value={typingEmulation.maxDelay}
onValueChange={handleMaxDelayChange}
maxW="100px"
step={0.1}
/>
</Flex>
</Stack>
)}
</Stack>
)
}

View File

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

View File

@ -0,0 +1,135 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { defaultTextInputOptions } from 'models'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { freeWorkspaceId } from 'utils/playwright/databaseSetup'
import { typebotViewer } from 'utils/playwright/testHelpers'
test.describe.parallel('Settings page', () => {
test.describe('General', () => {
test('should reflect change in real-time', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(getTestAsset('typebots/settings.json'), {
id: typebotId,
})
await page.goto(`/typebots/${typebotId}/settings`)
await expect(
typebotViewer(page).locator('a:has-text("Made with Typebot")')
).toHaveAttribute('href', 'https://www.typebot.io/?utm_source=litebadge')
await page.click('text="Typebot.io branding"')
await expect(
typebotViewer(page).locator('a:has-text("Made with Typebot")')
).toBeHidden()
await page.click('text="Remember session"')
await expect(
page.locator('input[type="checkbox"] >> nth=-1')
).toHaveAttribute('checked', '')
await expect(
typebotViewer(page).locator('input[value="Baptiste"]')
).toBeVisible()
await page.click('text=Prefill input')
await page.click('text=Theme')
await expect(
typebotViewer(page).locator(
`input[placeholder="${defaultTextInputOptions.labels.placeholder}"]`
)
).toHaveValue('')
})
})
test.describe('Typing emulation', () => {
test('should be fillable', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(getTestAsset('typebots/settings.json'), {
id: typebotId,
})
await page.goto(`/typebots/${typebotId}/settings`)
await expect(
typebotViewer(page).locator('a:has-text("Made with Typebot")')
).toHaveAttribute('href', 'https://www.typebot.io/?utm_source=litebadge')
await page.click('button:has-text("Typing emulation")')
await page.fill('[data-testid="speed"] input', '350')
await page.fill('[data-testid="max-delay"] input', '1.5')
await page.click('text="Typing emulation" >> nth=-1')
await expect(page.locator('[data-testid="speed"]')).toBeHidden()
await expect(page.locator('[data-testid="max-delay"]')).toBeHidden()
})
})
test.describe('Metadata', () => {
test('should be fillable', async ({ page }) => {
const favIconUrl = 'https://www.baptistearno.com/favicon.png'
const imageUrl = 'https://www.baptistearno.com/images/site-preview.png'
const typebotId = cuid()
await importTypebotInDatabase(getTestAsset('typebots/settings.json'), {
id: typebotId,
})
await page.goto(`/typebots/${typebotId}/settings`)
await expect(
typebotViewer(page).locator(
`input[placeholder="${defaultTextInputOptions.labels.placeholder}"]`
)
).toHaveValue('Baptiste')
await page.click('button:has-text("Metadata")')
// Fav icon
const favIconImg = page.locator('img >> nth=0')
await expect(favIconImg).toHaveAttribute('src', '/favicon.png')
await favIconImg.click()
await expect(page.locator('text=Giphy')).toBeHidden()
await page.click('button:has-text("Embed link")')
await page.fill(
'input[placeholder="Paste the image link..."]',
favIconUrl
)
await expect(favIconImg).toHaveAttribute('src', favIconUrl)
// Website image
const websiteImg = page.locator('img >> nth=1')
await expect(websiteImg).toHaveAttribute('src', '/viewer-preview.png')
await websiteImg.click({ position: { x: 0, y: 160 }, force: true })
await expect(page.locator('text=Giphy')).toBeHidden()
await page.click('button >> text="Embed link"')
await page.fill('input[placeholder="Paste the image link..."]', imageUrl)
await expect(websiteImg).toHaveAttribute('src', imageUrl)
// Title
await page.fill('input#title', 'Awesome typebot')
// Description
await page.fill('textarea#description', 'Lorem ipsum')
// Custom head code
await page.fill(
'div[contenteditable=true]',
'<script>Lorem ipsum</script>'
)
})
})
test.describe('Free workspace', () => {
test("can't remove branding", async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(getTestAsset('typebots/settings.json'), {
id: typebotId,
workspaceId: freeWorkspaceId,
})
await page.goto(`/typebots/${typebotId}/settings`)
await expect(
typebotViewer(page).locator('text="What\'s your name?"')
).toBeVisible()
await expect(
page.locator('[data-testid="starter-lock-tag"]')
).toBeVisible()
await page.click('text=Typebot.io branding')
await expect(
page.locator(
'text="You need to upgrade your plan in order to remove branding"'
)
).toBeVisible()
})
})
})