✨ (theme) Add theme templates
Allows you to save your themes and select a theme from Typebot's gallery Closes #275
This commit is contained in:
55
apps/builder/src/features/theme/api/deleteThemeTemplate.ts
Normal file
55
apps/builder/src/features/theme/api/deleteThemeTemplate.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { ThemeTemplate, themeTemplateSchema } from '@typebot.io/schemas'
|
||||
import { z } from 'zod'
|
||||
import { getUserRoleInWorkspace } from '@/features/workspace/helpers/getUserRoleInWorkspace'
|
||||
import { WorkspaceRole } from '@typebot.io/prisma'
|
||||
|
||||
export const deleteThemeTemplate = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'DELETE',
|
||||
path: '/themeTemplates/{themeTemplateId}',
|
||||
protect: true,
|
||||
summary: 'Delete a theme template',
|
||||
tags: ['Workspace', 'Theme'],
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspaceId: z.string(),
|
||||
themeTemplateId: z.string(),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
themeTemplate: themeTemplateSchema,
|
||||
})
|
||||
)
|
||||
.mutation(
|
||||
async ({ input: { themeTemplateId, workspaceId }, ctx: { user } }) => {
|
||||
const workspace = await prisma.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: {
|
||||
members: true,
|
||||
},
|
||||
})
|
||||
const userRole = getUserRoleInWorkspace(user.id, workspace?.members)
|
||||
if (userRole === undefined || userRole === WorkspaceRole.GUEST)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Workspace not found',
|
||||
})
|
||||
|
||||
const themeTemplate = (await prisma.themeTemplate.delete({
|
||||
where: {
|
||||
id: themeTemplateId,
|
||||
},
|
||||
})) as ThemeTemplate
|
||||
|
||||
return {
|
||||
themeTemplate,
|
||||
}
|
||||
}
|
||||
)
|
||||
56
apps/builder/src/features/theme/api/listThemeTemplates.ts
Normal file
56
apps/builder/src/features/theme/api/listThemeTemplates.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { ThemeTemplate, themeTemplateSchema } from '@typebot.io/schemas'
|
||||
import { z } from 'zod'
|
||||
import { getUserRoleInWorkspace } from '@/features/workspace/helpers/getUserRoleInWorkspace'
|
||||
import { WorkspaceRole } from '@typebot.io/prisma'
|
||||
|
||||
export const listThemeTemplates = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/themeTemplates',
|
||||
protect: true,
|
||||
summary: 'List theme templates',
|
||||
tags: ['Workspace', 'Theme'],
|
||||
},
|
||||
})
|
||||
.input(z.object({ workspaceId: z.string() }))
|
||||
.output(
|
||||
z.object({
|
||||
themeTemplates: z.array(
|
||||
themeTemplateSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
theme: true,
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
|
||||
const workspace = await prisma.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: {
|
||||
members: true,
|
||||
},
|
||||
})
|
||||
const userRole = getUserRoleInWorkspace(user.id, workspace?.members)
|
||||
if (userRole === undefined || userRole === WorkspaceRole.GUEST)
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' })
|
||||
const themeTemplates = (await prisma.themeTemplate.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
theme: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
})) as Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[]
|
||||
|
||||
return { themeTemplates }
|
||||
})
|
||||
10
apps/builder/src/features/theme/api/router.ts
Normal file
10
apps/builder/src/features/theme/api/router.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { router } from '@/helpers/server/trpc'
|
||||
import { deleteThemeTemplate } from './deleteThemeTemplate'
|
||||
import { listThemeTemplates } from './listThemeTemplates'
|
||||
import { saveThemeTemplate } from './saveThemeTemplate'
|
||||
|
||||
export const themeRouter = router({
|
||||
listThemeTemplates,
|
||||
saveThemeTemplate,
|
||||
deleteThemeTemplate,
|
||||
})
|
||||
63
apps/builder/src/features/theme/api/saveThemeTemplate.ts
Normal file
63
apps/builder/src/features/theme/api/saveThemeTemplate.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { ThemeTemplate, themeTemplateSchema } from '@typebot.io/schemas'
|
||||
import { z } from 'zod'
|
||||
import { getUserRoleInWorkspace } from '@/features/workspace/helpers/getUserRoleInWorkspace'
|
||||
import { WorkspaceRole } from '@typebot.io/prisma'
|
||||
|
||||
export const saveThemeTemplate = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'PUT',
|
||||
path: '/themeTemplates/{themeTemplateId}',
|
||||
protect: true,
|
||||
summary: 'Save theme template',
|
||||
tags: ['Workspace', 'Theme'],
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspaceId: z.string(),
|
||||
themeTemplateId: z.string(),
|
||||
name: themeTemplateSchema.shape.name,
|
||||
theme: themeTemplateSchema.shape.theme,
|
||||
})
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
themeTemplate: themeTemplateSchema,
|
||||
})
|
||||
)
|
||||
.mutation(
|
||||
async ({
|
||||
input: { themeTemplateId, workspaceId, ...data },
|
||||
ctx: { user },
|
||||
}) => {
|
||||
const workspace = await prisma.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: {
|
||||
members: true,
|
||||
},
|
||||
})
|
||||
const userRole = getUserRoleInWorkspace(user.id, workspace?.members)
|
||||
if (userRole === undefined || userRole === WorkspaceRole.GUEST)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Workspace not found',
|
||||
})
|
||||
|
||||
const themeTemplate = (await prisma.themeTemplate.upsert({
|
||||
where: { id: themeTemplateId },
|
||||
create: {
|
||||
...data,
|
||||
workspaceId,
|
||||
},
|
||||
update: data,
|
||||
})) as ThemeTemplate
|
||||
|
||||
return {
|
||||
themeTemplate,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -9,7 +9,7 @@ type Props = {
|
||||
export const CustomCssSettings = ({ customCss, onCustomCssChange }: Props) => {
|
||||
return (
|
||||
<CodeEditor
|
||||
defaultValue={customCss ?? ''}
|
||||
value={customCss ?? ''}
|
||||
lang="css"
|
||||
onChange={onCustomCssChange}
|
||||
/>
|
||||
|
||||
69
apps/builder/src/features/theme/components/MyTemplates.tsx
Normal file
69
apps/builder/src/features/theme/components/MyTemplates.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { SaveIcon } from '@/components/icons'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Button, SimpleGrid, Stack, useDisclosure } from '@chakra-ui/react'
|
||||
import { ThemeTemplate } from '@typebot.io/schemas'
|
||||
import { areThemesEqual } from '../helpers/areThemesEqual'
|
||||
import { SaveThemeModal } from './SaveThemeModal'
|
||||
import { ThemeTemplateCard } from './ThemeTemplateCard'
|
||||
|
||||
type Props = {
|
||||
selectedTemplateId: string | undefined
|
||||
currentTheme: ThemeTemplate['theme']
|
||||
workspaceId: string
|
||||
onTemplateSelect: (
|
||||
template: Partial<Pick<ThemeTemplate, 'id' | 'theme'>>
|
||||
) => void
|
||||
}
|
||||
|
||||
export const MyTemplates = ({
|
||||
selectedTemplateId,
|
||||
currentTheme,
|
||||
workspaceId,
|
||||
onTemplateSelect,
|
||||
}: Props) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { data } = trpc.theme.listThemeTemplates.useQuery({
|
||||
workspaceId,
|
||||
})
|
||||
const selectedTemplate = data?.themeTemplates.find(
|
||||
(themeTemplate) => themeTemplate.id === selectedTemplateId
|
||||
)
|
||||
|
||||
const closeModalAndSelectTemplate = (
|
||||
template?: Pick<ThemeTemplate, 'id' | 'theme'>
|
||||
) => {
|
||||
if (template) onTemplateSelect(template)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
{(!selectedTemplate ||
|
||||
!areThemesEqual(selectedTemplate?.theme, currentTheme)) && (
|
||||
<Button leftIcon={<SaveIcon />} onClick={onOpen} colorScheme="blue">
|
||||
Save current theme
|
||||
</Button>
|
||||
)}
|
||||
<SaveThemeModal
|
||||
workspaceId={workspaceId}
|
||||
selectedTemplate={selectedTemplate}
|
||||
isOpen={isOpen}
|
||||
onClose={closeModalAndSelectTemplate}
|
||||
theme={currentTheme}
|
||||
/>
|
||||
<SimpleGrid columns={2} spacing={4}>
|
||||
{data?.themeTemplates.map((themeTemplate) => (
|
||||
<ThemeTemplateCard
|
||||
key={themeTemplate.id}
|
||||
workspaceId={workspaceId}
|
||||
themeTemplate={themeTemplate}
|
||||
isSelected={themeTemplate.id === selectedTemplateId}
|
||||
onClick={() => onTemplateSelect(themeTemplate)}
|
||||
onRenameClick={onOpen}
|
||||
onDeleteSuccess={() => onTemplateSelect({ id: '' })}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
109
apps/builder/src/features/theme/components/SaveThemeModal.tsx
Normal file
109
apps/builder/src/features/theme/components/SaveThemeModal.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { TextInput } from '@/components/inputs'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import {
|
||||
Button,
|
||||
HStack,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@chakra-ui/react'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { ThemeTemplate } from '@typebot.io/schemas'
|
||||
import { FormEvent, useRef, useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
isOpen: boolean
|
||||
onClose: (template?: Pick<ThemeTemplate, 'id' | 'theme'>) => void
|
||||
selectedTemplate: Pick<ThemeTemplate, 'id' | 'name'> | undefined
|
||||
theme: ThemeTemplate['theme']
|
||||
}
|
||||
|
||||
export const SaveThemeModal = ({
|
||||
workspaceId,
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedTemplate,
|
||||
theme,
|
||||
}: Props) => {
|
||||
const { showToast } = useToast()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const {
|
||||
theme: {
|
||||
listThemeTemplates: { refetch: refetchThemeTemplates },
|
||||
},
|
||||
} = trpc.useContext()
|
||||
const { mutate } = trpc.theme.saveThemeTemplate.useMutation({
|
||||
onMutate: () => setIsSaving(true),
|
||||
onSettled: () => setIsSaving(false),
|
||||
onSuccess: ({ themeTemplate }) => {
|
||||
refetchThemeTemplates()
|
||||
onClose(themeTemplate)
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast({
|
||||
description: error.message,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const updateExistingTemplate = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
const newName = inputRef.current?.value
|
||||
if (!newName) return
|
||||
mutate({
|
||||
name: newName,
|
||||
theme,
|
||||
workspaceId,
|
||||
themeTemplateId: selectedTemplate?.id ?? createId(),
|
||||
})
|
||||
}
|
||||
|
||||
const saveNewTemplate = () => {
|
||||
const newName = inputRef.current?.value
|
||||
if (!newName) return
|
||||
mutate({
|
||||
name: newName,
|
||||
theme,
|
||||
workspaceId,
|
||||
themeTemplateId: createId(),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} initialFocusRef={inputRef}>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={updateExistingTemplate}>
|
||||
<ModalHeader>Save theme</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
label="Name:"
|
||||
defaultValue={selectedTemplate?.name}
|
||||
withVariableButton={false}
|
||||
placeholder="My template"
|
||||
isRequired
|
||||
/>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter as={HStack}>
|
||||
{selectedTemplate?.id && (
|
||||
<Button isLoading={isSaving} onClick={saveNewTemplate}>
|
||||
Save as new template
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" colorScheme="blue" isLoading={isSaving}>
|
||||
{selectedTemplate?.id ? 'Update' : 'Save'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SimpleGrid, Stack } from '@chakra-ui/react'
|
||||
import { ThemeTemplate } from '@typebot.io/schemas'
|
||||
import { galleryTemplates } from '../galleryTemplates'
|
||||
import { ThemeTemplateCard } from './ThemeTemplateCard'
|
||||
|
||||
type Props = {
|
||||
selectedTemplateId: string | undefined
|
||||
currentTheme: ThemeTemplate['theme']
|
||||
workspaceId: string
|
||||
onTemplateSelect: (
|
||||
template: Partial<Pick<ThemeTemplate, 'id' | 'theme'>>
|
||||
) => void
|
||||
}
|
||||
|
||||
export const TemplatesGallery = ({
|
||||
selectedTemplateId,
|
||||
workspaceId,
|
||||
onTemplateSelect,
|
||||
}: Props) => (
|
||||
<Stack spacing={4}>
|
||||
<SimpleGrid columns={2} spacing={4}>
|
||||
{galleryTemplates.map((themeTemplate) => (
|
||||
<ThemeTemplateCard
|
||||
key={themeTemplate.id}
|
||||
workspaceId={workspaceId}
|
||||
themeTemplate={themeTemplate}
|
||||
isSelected={themeTemplate.id === selectedTemplateId}
|
||||
onClick={() => onTemplateSelect(themeTemplate)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)
|
||||
@@ -7,28 +7,41 @@ import {
|
||||
Heading,
|
||||
HStack,
|
||||
Stack,
|
||||
Tag,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChatIcon, CodeIcon, PencilIcon } from '@/components/icons'
|
||||
import { ChatTheme, GeneralTheme } from '@typebot.io/schemas'
|
||||
import { ChatIcon, CodeIcon, DropletIcon, TableIcon } from '@/components/icons'
|
||||
import { ChatTheme, GeneralTheme, ThemeTemplate } from '@typebot.io/schemas'
|
||||
import React from 'react'
|
||||
import { CustomCssSettings } from './CustomCssSettings'
|
||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||
import { headerHeight } from '@/features/editor/constants'
|
||||
import { ChatThemeSettings } from './chat/ChatThemeSettings'
|
||||
import { GeneralSettings } from './general/GeneralSettings'
|
||||
import { ThemeTemplates } from './ThemeTemplates'
|
||||
|
||||
export const ThemeSideMenu = () => {
|
||||
const { typebot, updateTypebot } = useTypebot()
|
||||
|
||||
const handleChatThemeChange = (chat: ChatTheme) =>
|
||||
const updateChatTheme = (chat: ChatTheme) =>
|
||||
typebot && updateTypebot({ theme: { ...typebot.theme, chat } })
|
||||
|
||||
const handleGeneralThemeChange = (general: GeneralTheme) =>
|
||||
const updateGeneralTheme = (general: GeneralTheme) =>
|
||||
typebot && updateTypebot({ theme: { ...typebot.theme, general } })
|
||||
|
||||
const handleCustomCssChange = (customCss: string) =>
|
||||
const updateCustomCss = (customCss: string) =>
|
||||
typebot && updateTypebot({ theme: { ...typebot.theme, customCss } })
|
||||
|
||||
const selectedTemplate = (
|
||||
selectedTemplate: Partial<Pick<ThemeTemplate, 'id' | 'theme'>>
|
||||
) => {
|
||||
if (!typebot) return
|
||||
const { theme, id } = selectedTemplate
|
||||
updateTypebot({
|
||||
selectedThemeTemplateId: id,
|
||||
theme: theme ? { ...theme } : typebot.theme,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
flex="1"
|
||||
@@ -48,8 +61,33 @@ export const ThemeSideMenu = () => {
|
||||
<AccordionItem>
|
||||
<AccordionButton py={6}>
|
||||
<HStack flex="1" pl={2}>
|
||||
<PencilIcon />
|
||||
<Heading fontSize="lg">General</Heading>
|
||||
<TableIcon />
|
||||
<Heading fontSize="lg">
|
||||
<HStack>
|
||||
<span>Templates</span> <Tag colorScheme="orange">New!</Tag>
|
||||
</HStack>
|
||||
</Heading>
|
||||
</HStack>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
{typebot && (
|
||||
<ThemeTemplates
|
||||
selectedTemplateId={
|
||||
typebot.selectedThemeTemplateId ?? undefined
|
||||
}
|
||||
currentTheme={typebot.theme}
|
||||
workspaceId={typebot.workspaceId}
|
||||
onTemplateSelect={selectedTemplate}
|
||||
/>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem>
|
||||
<AccordionButton py={6}>
|
||||
<HStack flex="1" pl={2}>
|
||||
<DropletIcon />
|
||||
<Heading fontSize="lg">Font & Background</Heading>
|
||||
</HStack>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
@@ -57,7 +95,7 @@ export const ThemeSideMenu = () => {
|
||||
{typebot && (
|
||||
<GeneralSettings
|
||||
generalTheme={typebot.theme.general}
|
||||
onGeneralThemeChange={handleGeneralThemeChange}
|
||||
onGeneralThemeChange={updateGeneralTheme}
|
||||
/>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
@@ -75,7 +113,7 @@ export const ThemeSideMenu = () => {
|
||||
<ChatThemeSettings
|
||||
typebotId={typebot.id}
|
||||
chatTheme={typebot.theme.chat}
|
||||
onChatThemeChange={handleChatThemeChange}
|
||||
onChatThemeChange={updateChatTheme}
|
||||
/>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
@@ -92,7 +130,7 @@ export const ThemeSideMenu = () => {
|
||||
{typebot && (
|
||||
<CustomCssSettings
|
||||
customCss={typebot.theme.customCss}
|
||||
onCustomCssChange={handleCustomCssChange}
|
||||
onCustomCssChange={updateCustomCss}
|
||||
/>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
|
||||
223
apps/builder/src/features/theme/components/ThemeTemplateCard.tsx
Normal file
223
apps/builder/src/features/theme/components/ThemeTemplateCard.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { MoreHorizontalIcon, EditIcon, TrashIcon } from '@/components/icons'
|
||||
import { colors } from '@/lib/theme'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import {
|
||||
Stack,
|
||||
HStack,
|
||||
Flex,
|
||||
Menu,
|
||||
MenuButton,
|
||||
IconButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Box,
|
||||
Text,
|
||||
Image,
|
||||
} from '@chakra-ui/react'
|
||||
import { BackgroundType, ThemeTemplate } from '@typebot.io/schemas'
|
||||
import { useState } from 'react'
|
||||
import { DefaultAvatar } from './DefaultAvatar'
|
||||
|
||||
export const ThemeTemplateCard = ({
|
||||
workspaceId,
|
||||
themeTemplate,
|
||||
isSelected,
|
||||
onClick,
|
||||
onRenameClick,
|
||||
onDeleteSuccess,
|
||||
}: {
|
||||
workspaceId: string
|
||||
themeTemplate: Pick<ThemeTemplate, 'name' | 'theme' | 'id'>
|
||||
isSelected: boolean
|
||||
onRenameClick?: () => void
|
||||
onClick: () => void
|
||||
onDeleteSuccess?: () => void
|
||||
}) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const {
|
||||
theme: {
|
||||
listThemeTemplates: { refetch: refetchThemeTemplates },
|
||||
},
|
||||
} = trpc.useContext()
|
||||
const { mutate } = trpc.theme.deleteThemeTemplate.useMutation({
|
||||
onMutate: () => setIsDeleting(true),
|
||||
onSettled: () => setIsDeleting(false),
|
||||
onSuccess: () => {
|
||||
refetchThemeTemplates()
|
||||
if (onDeleteSuccess) onDeleteSuccess()
|
||||
},
|
||||
})
|
||||
|
||||
const deleteThemeTemplate = () => {
|
||||
mutate({ themeTemplateId: themeTemplate.id, workspaceId })
|
||||
}
|
||||
|
||||
const rounded =
|
||||
themeTemplate.theme.chat.roundness === 'large'
|
||||
? 'md'
|
||||
: themeTemplate.theme.chat.roundness === 'none'
|
||||
? 'none'
|
||||
: 'sm'
|
||||
|
||||
return (
|
||||
<Stack
|
||||
cursor="pointer"
|
||||
onClick={onClick}
|
||||
spacing={0}
|
||||
opacity={isDeleting ? 0.5 : 1}
|
||||
pointerEvents={isDeleting ? 'none' : undefined}
|
||||
rounded="md"
|
||||
boxShadow={
|
||||
isSelected
|
||||
? `${colors['blue']['400']} 0 0 0 4px`
|
||||
: `rgba(0, 0, 0, 0.08) 0px 2px 4px`
|
||||
}
|
||||
style={{
|
||||
willChange: 'box-shadow',
|
||||
transition: 'box-shadow 0.2s ease 0s',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
borderTopRadius="md"
|
||||
backgroundSize="cover"
|
||||
{...parseBackground(themeTemplate.theme.general.background)}
|
||||
borderColor={isSelected ? 'blue.400' : undefined}
|
||||
>
|
||||
<HStack mt="4" ml="4" spacing={0.5} alignItems="flex-end">
|
||||
<AvatarPreview avatar={themeTemplate.theme.chat.hostAvatar} />
|
||||
<Box
|
||||
rounded="sm"
|
||||
w="80px"
|
||||
h="16px"
|
||||
background={themeTemplate.theme.chat.hostBubbles.backgroundColor}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<HStack
|
||||
mt="1"
|
||||
mr="4"
|
||||
ml="auto"
|
||||
justifyContent="flex-end"
|
||||
alignItems="flex-end"
|
||||
>
|
||||
<Box
|
||||
rounded="sm"
|
||||
w="80px"
|
||||
h="16px"
|
||||
background={themeTemplate.theme.chat.guestBubbles.backgroundColor}
|
||||
/>
|
||||
<AvatarPreview avatar={themeTemplate.theme.chat.guestAvatar} />
|
||||
</HStack>
|
||||
|
||||
<HStack mt="1" ml="4" spacing={0.5} alignItems="flex-end">
|
||||
<AvatarPreview avatar={themeTemplate.theme.chat.hostAvatar} />
|
||||
<Box
|
||||
rounded="sm"
|
||||
w="80px"
|
||||
h="16px"
|
||||
background={themeTemplate.theme.chat.hostBubbles.backgroundColor}
|
||||
/>
|
||||
</HStack>
|
||||
<Flex
|
||||
mt="1"
|
||||
mb="4"
|
||||
pr="4"
|
||||
ml="auto"
|
||||
w="full"
|
||||
justifyContent="flex-end"
|
||||
gap="1"
|
||||
>
|
||||
<Box
|
||||
rounded={rounded}
|
||||
w="20px"
|
||||
h="10px"
|
||||
background={themeTemplate.theme.chat.buttons.backgroundColor}
|
||||
/>
|
||||
<Box
|
||||
rounded={rounded}
|
||||
w="20px"
|
||||
h="10px"
|
||||
background={themeTemplate.theme.chat.buttons.backgroundColor}
|
||||
/>
|
||||
<Box
|
||||
rounded={rounded}
|
||||
w="20px"
|
||||
h="10px"
|
||||
background={themeTemplate.theme.chat.buttons.backgroundColor}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
<HStack p="2" justifyContent="space-between">
|
||||
<Text fontSize="sm" noOfLines={1}>
|
||||
{themeTemplate.name}
|
||||
</Text>
|
||||
{onDeleteSuccess && onRenameClick && (
|
||||
<Menu isLazy>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={<MoreHorizontalIcon />}
|
||||
aria-label="Open template menu"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<MenuList onClick={(e) => e.stopPropagation()}>
|
||||
{isSelected && (
|
||||
<MenuItem icon={<EditIcon />} onClick={onRenameClick}>
|
||||
Rename
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={<TrashIcon />}
|
||||
color="red.500"
|
||||
onClick={deleteThemeTemplate}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const parseBackground = (background: {
|
||||
type: BackgroundType
|
||||
content?: string
|
||||
}) => {
|
||||
switch (background.type) {
|
||||
case BackgroundType.COLOR:
|
||||
return {
|
||||
backgroundColor: background.content,
|
||||
}
|
||||
case BackgroundType.IMAGE:
|
||||
return { backgroundImage: `url(${background.content})` }
|
||||
case BackgroundType.NONE:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const AvatarPreview = ({
|
||||
avatar,
|
||||
}: {
|
||||
avatar:
|
||||
| {
|
||||
isEnabled: boolean
|
||||
url?: string | undefined
|
||||
}
|
||||
| undefined
|
||||
}) => {
|
||||
if (!avatar?.isEnabled) return null
|
||||
return avatar.url ? (
|
||||
<Image
|
||||
src={avatar.url}
|
||||
alt="Avatar preview in theme template card"
|
||||
boxSize="12px"
|
||||
rounded="full"
|
||||
/>
|
||||
) : (
|
||||
<DefaultAvatar boxSize="12px" />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Button, HStack, Stack } from '@chakra-ui/react'
|
||||
import { ThemeTemplate } from '@typebot.io/schemas'
|
||||
import { useState } from 'react'
|
||||
import { MyTemplates } from './MyTemplates'
|
||||
import { TemplatesGallery } from './TemplatesGallery'
|
||||
|
||||
type Tab = 'my-templates' | 'gallery'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
selectedTemplateId: string | undefined
|
||||
currentTheme: ThemeTemplate['theme']
|
||||
onTemplateSelect: (
|
||||
template: Partial<Pick<ThemeTemplate, 'id' | 'theme'>>
|
||||
) => void
|
||||
}
|
||||
|
||||
export const ThemeTemplates = ({
|
||||
workspaceId,
|
||||
selectedTemplateId,
|
||||
currentTheme,
|
||||
onTemplateSelect,
|
||||
}: Props) => {
|
||||
const [selectedTab, setSelectedTab] = useState<Tab>('my-templates')
|
||||
|
||||
return (
|
||||
<Stack spacing={4} pb={12}>
|
||||
<HStack>
|
||||
<Button
|
||||
flex="1"
|
||||
variant="outline"
|
||||
colorScheme={selectedTab === 'my-templates' ? 'blue' : 'gray'}
|
||||
onClick={() => setSelectedTab('my-templates')}
|
||||
>
|
||||
My templates
|
||||
</Button>
|
||||
<Button
|
||||
flex="1"
|
||||
variant="outline"
|
||||
colorScheme={selectedTab === 'gallery' ? 'blue' : 'gray'}
|
||||
onClick={() => setSelectedTab('gallery')}
|
||||
>
|
||||
Gallery
|
||||
</Button>
|
||||
</HStack>
|
||||
<ThemeTemplatesBody
|
||||
tab={selectedTab}
|
||||
currentTheme={currentTheme}
|
||||
workspaceId={workspaceId}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
onTemplateSelect={onTemplateSelect}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const ThemeTemplatesBody = ({
|
||||
tab,
|
||||
workspaceId,
|
||||
selectedTemplateId,
|
||||
currentTheme,
|
||||
onTemplateSelect,
|
||||
}: {
|
||||
tab: Tab
|
||||
} & Props) => {
|
||||
switch (tab) {
|
||||
case 'my-templates':
|
||||
return (
|
||||
<MyTemplates
|
||||
onTemplateSelect={onTemplateSelect}
|
||||
currentTheme={currentTheme}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)
|
||||
case 'gallery':
|
||||
return (
|
||||
<TemplatesGallery
|
||||
onTemplateSelect={onTemplateSelect}
|
||||
currentTheme={currentTheme}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,16 +19,13 @@ export const ButtonsTheme = ({ buttons, onButtonsChange }: Props) => {
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Background:</Text>
|
||||
<ColorPicker
|
||||
initialColor={buttons.backgroundColor}
|
||||
value={buttons.backgroundColor}
|
||||
onColorChange={handleBackgroundChange}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Text:</Text>
|
||||
<ColorPicker
|
||||
initialColor={buttons.color}
|
||||
onColorChange={handleTextChange}
|
||||
/>
|
||||
<ColorPicker value={buttons.color} onColorChange={handleTextChange} />
|
||||
</Flex>
|
||||
</Stack>
|
||||
)
|
||||
|
||||
@@ -65,29 +65,7 @@ export const ChatThemeSettings = ({
|
||||
onHostBubblesChange={handleHostBubblesChange}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
|
||||
<Heading fontSize="lg">Corners roundness</Heading>
|
||||
<RadioButtons
|
||||
options={[
|
||||
{
|
||||
label: <NoRadiusIcon />,
|
||||
value: 'none',
|
||||
},
|
||||
{
|
||||
label: <MediumRadiusIcon />,
|
||||
value: 'medium',
|
||||
},
|
||||
{
|
||||
label: <LargeRadiusIcon />,
|
||||
value: 'large',
|
||||
},
|
||||
]}
|
||||
defaultValue={chatTheme.roundness ?? 'medium'}
|
||||
onSelect={(roundness) =>
|
||||
onChatThemeChange({ ...chatTheme, roundness })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
|
||||
<Heading fontSize="lg">User bubbles</Heading>
|
||||
<GuestBubbles
|
||||
@@ -109,6 +87,29 @@ export const ChatThemeSettings = ({
|
||||
onInputsChange={handleInputsChange}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
|
||||
<Heading fontSize="lg">Corners roundness</Heading>
|
||||
<RadioButtons
|
||||
options={[
|
||||
{
|
||||
label: <NoRadiusIcon />,
|
||||
value: 'none',
|
||||
},
|
||||
{
|
||||
label: <MediumRadiusIcon />,
|
||||
value: 'medium',
|
||||
},
|
||||
{
|
||||
label: <LargeRadiusIcon />,
|
||||
value: 'large',
|
||||
},
|
||||
]}
|
||||
value={chatTheme.roundness ?? 'medium'}
|
||||
onSelect={(roundness) =>
|
||||
onChatThemeChange({ ...chatTheme, roundness })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,14 +19,14 @@ export const GuestBubbles = ({ guestBubbles, onGuestBubblesChange }: Props) => {
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Background:</Text>
|
||||
<ColorPicker
|
||||
initialColor={guestBubbles.backgroundColor}
|
||||
value={guestBubbles.backgroundColor}
|
||||
onColorChange={handleBackgroundChange}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Text:</Text>
|
||||
<ColorPicker
|
||||
initialColor={guestBubbles.color}
|
||||
value={guestBubbles.color}
|
||||
onColorChange={handleTextChange}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -19,14 +19,14 @@ export const HostBubbles = ({ hostBubbles, onHostBubblesChange }: Props) => {
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Background:</Text>
|
||||
<ColorPicker
|
||||
initialColor={hostBubbles.backgroundColor}
|
||||
value={hostBubbles.backgroundColor}
|
||||
onColorChange={handleBackgroundChange}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Text:</Text>
|
||||
<ColorPicker
|
||||
initialColor={hostBubbles.color}
|
||||
value={hostBubbles.color}
|
||||
onColorChange={handleTextChange}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -21,21 +21,18 @@ export const InputsTheme = ({ inputs, onInputsChange }: Props) => {
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Background:</Text>
|
||||
<ColorPicker
|
||||
initialColor={inputs.backgroundColor}
|
||||
value={inputs.backgroundColor}
|
||||
onColorChange={handleBackgroundChange}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Text:</Text>
|
||||
<ColorPicker
|
||||
initialColor={inputs.color}
|
||||
onColorChange={handleTextChange}
|
||||
/>
|
||||
<ColorPicker value={inputs.color} onColorChange={handleTextChange} />
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Placeholder text:</Text>
|
||||
<ColorPicker
|
||||
initialColor={inputs.placeholderColor}
|
||||
value={inputs.placeholderColor}
|
||||
onColorChange={handlePlaceholderChange}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -35,7 +35,7 @@ export const BackgroundContent = ({
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Background color:</Text>
|
||||
<ColorPicker
|
||||
initialColor={background.content ?? defaultBackgroundColor}
|
||||
value={background.content ?? defaultBackgroundColor}
|
||||
onColorChange={handleContentChange}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const BackgroundSelector = ({
|
||||
BackgroundType.IMAGE,
|
||||
BackgroundType.NONE,
|
||||
]}
|
||||
defaultValue={background?.type ?? defaultBackgroundType}
|
||||
value={background?.type ?? defaultBackgroundType}
|
||||
onSelect={handleBackgroundTypeChange}
|
||||
/>
|
||||
<BackgroundContent
|
||||
|
||||
@@ -41,7 +41,7 @@ export const FontSelector = ({
|
||||
<HStack justify="space-between" align="center">
|
||||
<Text>Font</Text>
|
||||
<AutocompleteInput
|
||||
defaultValue={activeFont}
|
||||
value={activeFont}
|
||||
items={googleFonts}
|
||||
onChange={handleFontSelected}
|
||||
withVariableButton={false}
|
||||
|
||||
144
apps/builder/src/features/theme/galleryTemplates.ts
Normal file
144
apps/builder/src/features/theme/galleryTemplates.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { BackgroundType, ThemeTemplate } from '@typebot.io/schemas'
|
||||
|
||||
export const galleryTemplates: Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[] =
|
||||
[
|
||||
{
|
||||
id: 'typebot-light',
|
||||
name: 'Typebot Light',
|
||||
theme: {
|
||||
chat: {
|
||||
inputs: {
|
||||
color: '#303235',
|
||||
backgroundColor: '#FFFFFF',
|
||||
placeholderColor: '#9095A0',
|
||||
},
|
||||
buttons: { color: '#FFFFFF', backgroundColor: '#0042DA' },
|
||||
hostAvatar: {
|
||||
isEnabled: true,
|
||||
},
|
||||
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
|
||||
guestBubbles: { color: '#FFFFFF', backgroundColor: '#FF8E21' },
|
||||
},
|
||||
general: {
|
||||
font: 'Open Sans',
|
||||
background: { type: BackgroundType.COLOR, content: '#ffffff' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'typebot-dark',
|
||||
name: 'Typebot Dark',
|
||||
theme: {
|
||||
chat: {
|
||||
inputs: {
|
||||
color: '#ffffff',
|
||||
backgroundColor: '#1e293b',
|
||||
placeholderColor: '#9095A0',
|
||||
},
|
||||
buttons: { color: '#ffffff', backgroundColor: '#1a5fff' },
|
||||
hostAvatar: {
|
||||
isEnabled: true,
|
||||
},
|
||||
hostBubbles: { color: '#ffffff', backgroundColor: '#1e293b' },
|
||||
guestBubbles: { color: '#FFFFFF', backgroundColor: '#FF8E21' },
|
||||
},
|
||||
general: {
|
||||
font: 'Open Sans',
|
||||
background: { type: BackgroundType.COLOR, content: '#171923' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'minimalist-black',
|
||||
name: 'Minimalist Black',
|
||||
theme: {
|
||||
chat: {
|
||||
inputs: {
|
||||
color: '#303235',
|
||||
backgroundColor: '#FFFFFF',
|
||||
placeholderColor: '#9095A0',
|
||||
},
|
||||
buttons: { color: '#FFFFFF', backgroundColor: '#303235' },
|
||||
hostAvatar: { isEnabled: false },
|
||||
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
|
||||
guestBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
|
||||
},
|
||||
general: {
|
||||
font: 'Inter',
|
||||
background: { type: BackgroundType.COLOR, content: '#ffffff' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'minimalist-teal',
|
||||
name: 'Minimalist Teal',
|
||||
theme: {
|
||||
chat: {
|
||||
inputs: {
|
||||
color: '#303235',
|
||||
backgroundColor: '#FFFFFF',
|
||||
placeholderColor: '#9095A0',
|
||||
},
|
||||
buttons: { color: '#FFFFFF', backgroundColor: '#0d9488' },
|
||||
hostAvatar: { isEnabled: false },
|
||||
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
|
||||
guestBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
|
||||
},
|
||||
general: {
|
||||
font: 'Inter',
|
||||
background: { type: BackgroundType.COLOR, content: '#ffffff' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'bright-rain',
|
||||
name: 'Bright Rain',
|
||||
theme: {
|
||||
chat: {
|
||||
inputs: {
|
||||
color: '#303235',
|
||||
backgroundColor: '#FFFFFF',
|
||||
placeholderColor: '#9095A0',
|
||||
},
|
||||
buttons: { color: '#fff', backgroundColor: '#D27A7D' },
|
||||
hostAvatar: { isEnabled: true },
|
||||
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
|
||||
guestBubbles: { color: '#303235', backgroundColor: '#FDDDBF' },
|
||||
},
|
||||
general: {
|
||||
font: 'Montserrat',
|
||||
background: {
|
||||
type: BackgroundType.IMAGE,
|
||||
content:
|
||||
'https://s3.fr-par.scw.cloud/typebot/public/typebots/hlmywyje0sbz1lfogu86pyks/blocks/ssmyt084oosa17cggqd8kfg9',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ray-of-lights',
|
||||
name: 'Ray of Lights',
|
||||
theme: {
|
||||
chat: {
|
||||
inputs: {
|
||||
color: '#303235',
|
||||
backgroundColor: '#FFFFFF',
|
||||
placeholderColor: '#9095A0',
|
||||
},
|
||||
buttons: { color: '#fff', backgroundColor: '#1A2249' },
|
||||
hostAvatar: { isEnabled: true },
|
||||
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
|
||||
guestBubbles: { color: '#fff', backgroundColor: '#1A2249' },
|
||||
},
|
||||
general: {
|
||||
font: 'Raleway',
|
||||
background: {
|
||||
type: BackgroundType.IMAGE,
|
||||
content:
|
||||
'https://s3.fr-par.scw.cloud/typebot/public/typebots/hlmywyje0sbz1lfogu86pyks/blocks/uc2dyf63eeogaivqzm4z2hdb',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
11
apps/builder/src/features/theme/helpers/areThemesEqual.ts
Normal file
11
apps/builder/src/features/theme/helpers/areThemesEqual.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ThemeTemplate } from '@typebot.io/schemas'
|
||||
import { dequal } from 'dequal'
|
||||
|
||||
export const areThemesEqual = (
|
||||
selectedTemplate: ThemeTemplate['theme'],
|
||||
currentTheme: ThemeTemplate['theme']
|
||||
) =>
|
||||
dequal(
|
||||
JSON.parse(JSON.stringify(selectedTemplate)),
|
||||
JSON.parse(JSON.stringify(currentTheme))
|
||||
)
|
||||
@@ -21,6 +21,7 @@ test.describe.parallel('Theme page', () => {
|
||||
await expect(page.locator('button >> text="Go"')).toBeVisible()
|
||||
|
||||
// Font
|
||||
await page.getByRole('button', { name: 'Font & Background' }).click()
|
||||
await page.getByRole('textbox').fill('Roboto Slab')
|
||||
await expect(page.locator('.typebot-container')).toHaveCSS(
|
||||
'font-family',
|
||||
@@ -213,4 +214,40 @@ test.describe.parallel('Theme page', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Theme templates', () => {
|
||||
test('should reflect change in real-time', async ({ page }) => {
|
||||
const typebotId = createId()
|
||||
await importTypebotInDatabase(getTestAsset('typebots/theme.json'), {
|
||||
id: typebotId,
|
||||
})
|
||||
await page.goto(`/typebots/${typebotId}/theme`)
|
||||
await expect(page.locator('button >> text="Go"')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Save current theme' }).click()
|
||||
await page.getByPlaceholder('My template').fill('My awesome theme')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await page.getByRole('button', { name: 'Open template menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Rename' }).click()
|
||||
await expect(page.getByPlaceholder('My template')).toHaveValue(
|
||||
'My awesome theme'
|
||||
)
|
||||
await page.getByPlaceholder('My template').fill('My awesome theme 2')
|
||||
await page.getByRole('button', { name: 'Save as new template' }).click()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Open template menu' })
|
||||
).toHaveCount(2)
|
||||
await page
|
||||
.getByRole('button', { name: 'Open template menu' })
|
||||
.first()
|
||||
.click()
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click()
|
||||
await expect(page.getByText('My awesome theme 2')).toBeHidden()
|
||||
await page.getByRole('button', { name: 'Gallery' }).click()
|
||||
await page.getByText('Typebot Dark').click()
|
||||
await expect(page.getByTestId('host-bubble')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(30, 41, 59)'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user