2
0

perf(e2e): ️ Migrate to Playwright

This commit is contained in:
Baptiste Arnaud
2022-01-28 09:42:31 +01:00
parent c5aaa323d1
commit 73f277fce7
145 changed files with 3104 additions and 2346 deletions

View File

@ -37,3 +37,6 @@ STRIPE_WEBHOOK_SECRET=
# (Optional) Used for GIF search
NEXT_PUBLIC_GIPHY_API_KEY=
# (Optional) for e2e tests
GOOGLE_REFRESH_TOKEN_TEST=

View File

@ -0,0 +1,26 @@
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '14.x'
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v2
if: always()
with:
name: playwright-test-results
path: test-results/

View File

@ -1,3 +1,10 @@
cypress/videos
cypress/screenshots
cypress/downloads
cypress/downloads
node_modules/
test-results/
playwright-report/
authenticatedState.json
.env

View File

@ -286,3 +286,14 @@ export const WebhookIcon = (props: IconProps) => (
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</Icon>
)
export const GripIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<circle cx="12" cy="9" r="1"></circle>
<circle cx="19" cy="9" r="1"></circle>
<circle cx="5" cy="9" r="1"></circle>
<circle cx="12" cy="15" r="1"></circle>
<circle cx="19" cy="15" r="1"></circle>
<circle cx="5" cy="15" r="1"></circle>
</Icon>
)

View File

@ -0,0 +1,3 @@
.grabbing * {
cursor: grabbing !important;
}

View File

@ -1,7 +1,7 @@
import { Flex } from '@chakra-ui/react'
import React from 'react'
import Graph from './graph/Graph'
import { DndContext } from 'contexts/DndContext'
import { StepDndContext } from 'contexts/StepDndContext'
import { StepTypesList } from './StepTypesList'
import { PreviewDrawer } from './preview/PreviewDrawer'
import { RightPanel, useEditor } from 'contexts/EditorContext'
@ -12,14 +12,14 @@ export const Board = () => {
const { rightPanel } = useEditor()
return (
<Flex flex="1" pos="relative" bgColor="gray.50" h="full">
<DndContext>
<StepDndContext>
<StepTypesList />
<GraphProvider>
<Graph flex="1" />
<BoardMenuButton pos="absolute" right="40px" top="20px" />
{rightPanel === RightPanel.PREVIEW && <PreviewDrawer />}
</GraphProvider>
</DndContext>
</StepDndContext>
</Flex>
)
}

View File

@ -1,6 +1,6 @@
import { Flex, HStack, StackProps, Text } from '@chakra-ui/react'
import { StepType, DraggableStepType } from 'models'
import { useDnd } from 'contexts/DndContext'
import { useStepDnd } from 'contexts/StepDndContext'
import React, { useEffect, useState } from 'react'
import { StepIcon } from './StepIcon'
import { StepTypeLabel } from './StepTypeLabel'
@ -12,7 +12,7 @@ export const StepCard = ({
type: DraggableStepType
onMouseDown: (e: React.MouseEvent, type: DraggableStepType) => void
}) => {
const { draggedStepType } = useDnd()
const { draggedStepType } = useStepDnd()
const [isMouseDown, setIsMouseDown] = useState(false)
useEffect(() => {

View File

@ -13,12 +13,12 @@ import {
IntegrationStepType,
LogicStepType,
} from 'models'
import { useDnd } from 'contexts/DndContext'
import { useStepDnd } from 'contexts/StepDndContext'
import React, { useState } from 'react'
import { StepCard, StepCardOverlay } from './StepCard'
export const StepTypesList = () => {
const { setDraggedStepType, draggedStepType } = useDnd()
const { setDraggedStepType, draggedStepType } = useStepDnd()
const [position, setPosition] = useState({
x: 0,
y: 0,

View File

@ -8,7 +8,7 @@ import {
import React, { useEffect, useMemo, useState } from 'react'
import { Block } from 'models'
import { useGraph } from 'contexts/GraphContext'
import { useDnd } from 'contexts/DndContext'
import { useStepDnd } from 'contexts/StepDndContext'
import { StepsList } from './StepsList'
import { filterTable, isDefined } from 'utils'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
@ -22,8 +22,8 @@ type Props = {
export const BlockNode = ({ block }: Props) => {
const { connectingIds, setConnectingIds, previewingEdgeId } = useGraph()
const { typebot, updateBlock } = useTypebot()
const { setMouseOverBlockId } = useDnd()
const { draggedStep, draggedStepType } = useDnd()
const { setMouseOverBlockId } = useStepDnd()
const { draggedStep, draggedStepType } = useStepDnd()
const [isMouseDown, setIsMouseDown] = useState(false)
const [isConnecting, setIsConnecting] = useState(false)
const isPreviewing = useMemo(() => {

View File

@ -1,5 +1,5 @@
import { Flex, Portal, Stack, Text, useEventListener } from '@chakra-ui/react'
import { useDnd } from 'contexts/DndContext'
import { useStepDnd } from 'contexts/StepDndContext'
import { Coordinates } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext'
import { ChoiceInputStep, ChoiceItem } from 'models'
@ -19,7 +19,7 @@ export const ChoiceItemsList = ({ step }: ChoiceItemsListProps) => {
mouseOverBlockId,
setDraggedChoiceItem,
setMouseOverBlockId,
} = useDnd()
} = useStepDnd()
const showSortPlaceholders = useMemo(
() => mouseOverBlockId === step.blockId && draggedChoiceItem,
[draggedChoiceItem, mouseOverBlockId, step.blockId]

View File

@ -28,7 +28,6 @@ export const VideoUploadContent = ({ content, onSubmit }: Props) => {
placeholder="Paste the video link..."
initialValue={content?.url ?? ''}
onChange={handleUrlChange}
delay={100}
/>
<Text fontSize="sm" color="gray.400" textAlign="center">
Works with Youtube, Vimeo and others

View File

@ -31,13 +31,12 @@ export const ChoiceInputSettingsBody = ({
/>
{options?.isMultipleChoice && (
<Stack>
<FormLabel mb="0" htmlFor="send">
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<DebouncedInput
id="send"
id="button"
initialValue={options?.buttonLabel ?? 'Send'}
delay={100}
onChange={handleButtonLabelChange}
/>
</Stack>

View File

@ -40,7 +40,6 @@ export const ComparisonItem = ({
/>
{item.comparisonOperator !== ComparisonOperators.IS_SET && (
<InputWithVariableButton
delay={100}
initialValue={item.value ?? ''}
onChange={handleChangeValue}
placeholder="Type a value..."

View File

@ -21,6 +21,7 @@ export const ConditionSettingsBody = ({
return (
<TableList<Comparison>
initialItems={options.comparisons}
onItemsChange={handleComparisonsChange}
Item={ComparisonItem}
ComponentBetweenItems={() => (

View File

@ -49,7 +49,6 @@ export const DateInputSettingsBody = ({
<DebouncedInput
id="from"
initialValue={options.labels.from}
delay={100}
onChange={handleFromChange}
/>
</Stack>
@ -62,7 +61,6 @@ export const DateInputSettingsBody = ({
<DebouncedInput
id="to"
initialValue={options.labels.to}
delay={100}
onChange={handleToChange}
/>
</Stack>
@ -74,7 +72,6 @@ export const DateInputSettingsBody = ({
<DebouncedInput
id="button"
initialValue={options.labels.button}
delay={100}
onChange={handleButtonLabelChange}
/>
</Stack>

View File

@ -29,7 +29,6 @@ export const EmailInputSettingsBody = ({
<DebouncedInput
id="placeholder"
initialValue={options.labels.placeholder}
delay={100}
onChange={handlePlaceholderChange}
/>
</Stack>
@ -40,7 +39,6 @@ export const EmailInputSettingsBody = ({
<DebouncedInput
id="button"
initialValue={options.labels.button}
delay={100}
onChange={handleButtonLabelChange}
/>
</Stack>

View File

@ -51,7 +51,6 @@ export const GoogleAnalyticsSettings = ({
id="tracking-id"
initialValue={options?.trackingId ?? ''}
placeholder="G-123456..."
delay={100}
onChange={handleTrackingIdChange}
/>
</Stack>
@ -63,7 +62,6 @@ export const GoogleAnalyticsSettings = ({
id="category"
initialValue={options?.category ?? ''}
placeholder="Example: Typebot"
delay={100}
onChange={handleCategoryChange}
/>
</Stack>
@ -75,7 +73,6 @@ export const GoogleAnalyticsSettings = ({
id="action"
initialValue={options?.action ?? ''}
placeholder="Example: Submit email"
delay={100}
onChange={handleActionChange}
/>
</Stack>
@ -98,7 +95,6 @@ export const GoogleAnalyticsSettings = ({
id="label"
initialValue={options?.label ?? ''}
placeholder="Example: Campaign Z"
delay={100}
onChange={handleLabelChange}
/>
</Stack>

View File

@ -149,7 +149,7 @@ const ActionOptions = ({
case GoogleSheetsAction.INSERT_ROW:
return (
<TableList<Cell>
initialItems={options.cellsToInsert}
initialItems={options.cellsToInsert ?? { byId: {}, allIds: [] }}
onItemsChange={handleInsertColumnsChange}
Item={UpdatingCellItem}
addLabel="Add a value"
@ -167,7 +167,7 @@ const ActionOptions = ({
/>
<Text>Cells to update</Text>
<TableList<Cell>
initialItems={options.cellsToUpsert}
initialItems={options.cellsToUpsert ?? { byId: {}, allIds: [] }}
onItemsChange={handleUpsertColumnsChange}
Item={UpdatingCellItem}
addLabel="Add a value"
@ -186,7 +186,7 @@ const ActionOptions = ({
/>
<Text>Cells to extract</Text>
<TableList<ExtractingCell>
initialItems={options.cellsToExtract}
initialItems={options.cellsToExtract ?? { byId: {}, allIds: [] }}
onItemsChange={handleExtractingCellsChange}
Item={ExtractingCellItem}
addLabel="Add a value"

View File

@ -30,8 +30,8 @@ export const SheetsDropdown = ({
selectedItem={currentSheet?.name}
items={(sheets ?? []).map((s) => s.name)}
onValueChange={handleSpreadsheetSelect}
placeholder={isLoading ? 'Loading...' : 'Select the sheet'}
isDisabled={isLoading}
placeholder={'Select the sheet'}
isLoading={isLoading}
/>
)
}

View File

@ -28,8 +28,8 @@ export const SpreadsheetsDropdown = ({
selectedItem={currentSpreadsheet?.name}
items={(spreadsheets ?? []).map((s) => s.name)}
onValueChange={handleSpreadsheetSelect}
placeholder={isLoading ? 'Loading...' : 'Search for spreadsheet'}
isDisabled={isLoading}
placeholder={'Search for spreadsheet'}
isLoading={isLoading}
/>
)
}

View File

@ -37,7 +37,6 @@ export const NumberInputSettingsBody = ({
<DebouncedInput
id="placeholder"
initialValue={options.labels.placeholder}
delay={100}
onChange={handlePlaceholderChange}
/>
</Stack>
@ -48,7 +47,6 @@ export const NumberInputSettingsBody = ({
<DebouncedInput
id="button"
initialValue={options?.labels?.button ?? 'Send'}
delay={100}
onChange={handleButtonLabelChange}
/>
</Stack>

View File

@ -29,7 +29,6 @@ export const PhoneNumberSettingsBody = ({
<DebouncedInput
id="placeholder"
initialValue={options.labels.placeholder}
delay={100}
onChange={handlePlaceholderChange}
/>
</Stack>
@ -40,7 +39,6 @@ export const PhoneNumberSettingsBody = ({
<DebouncedInput
id="button"
initialValue={options.labels.button}
delay={100}
onChange={handleButtonLabelChange}
/>
</Stack>

View File

@ -1,6 +1,7 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { InputWithVariableButton } from 'components/shared/TextboxWithVariableButton'
import { RedirectOptions } from 'models'
import React from 'react'
@ -21,11 +22,10 @@ export const RedirectSettings = ({ options, onOptionsChange }: Props) => {
<FormLabel mb="0" htmlFor="tracking-id">
Url:
</FormLabel>
<DebouncedInput
<InputWithVariableButton
id="tracking-id"
initialValue={options.url ?? ''}
placeholder="Type a URL..."
delay={100}
onChange={handleUrlChange}
/>
</Stack>

View File

@ -37,7 +37,6 @@ export const SetVariableSettingsBody = ({
<DebouncedTextarea
id="expression"
initialValue={options.expressionToEvaluate ?? ''}
delay={100}
onChange={handleExpressionChange}
/>
</Stack>

View File

@ -38,7 +38,6 @@ export const TextInputSettingsBody = ({
<DebouncedInput
id="placeholder"
initialValue={options.labels.placeholder}
delay={100}
onChange={handlePlaceholderChange}
/>
</Stack>
@ -49,7 +48,6 @@ export const TextInputSettingsBody = ({
<DebouncedInput
id="button"
initialValue={options.labels.button}
delay={100}
onChange={handleButtonLabelChange}
/>
</Stack>

View File

@ -29,7 +29,6 @@ export const UrlInputSettingsBody = ({
<DebouncedInput
id="placeholder"
initialValue={options.labels.placeholder}
delay={100}
onChange={handlePlaceholderChange}
/>
</Stack>
@ -40,7 +39,6 @@ export const UrlInputSettingsBody = ({
<DebouncedInput
id="button"
initialValue={options.labels.button}
delay={100}
onChange={handleButtonLabelChange}
/>
</Stack>

View File

@ -29,7 +29,6 @@ export const VariableForTestInputs = ({
<FormLabel htmlFor={'value' + id}>Test value:</FormLabel>
<DebouncedInput
id={'value' + id}
delay={100}
initialValue={item.value ?? ''}
onChange={handleValueChange}
/>

View File

@ -130,7 +130,7 @@ export const WebhookSettings = ({
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<KeyValue>
initialItems={webhook?.queryParams}
initialItems={webhook?.queryParams ?? { byId: {}, allIds: [] }}
onItemsChange={handleQueryParamsChange}
Item={QueryParamsInputs}
addLabel="Add a param"
@ -144,7 +144,7 @@ export const WebhookSettings = ({
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<KeyValue>
initialItems={webhook?.headers}
initialItems={webhook?.headers ?? { byId: {}, allIds: [] }}
onItemsChange={handleHeadersChange}
Item={HeadersInputs}
addLabel="Add a value"
@ -171,7 +171,9 @@ export const WebhookSettings = ({
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<VariableForTest>
initialItems={options?.variablesForTest}
initialItems={
options?.variablesForTest ?? { byId: {}, allIds: [] }
}
onItemsChange={handleVariablesChange}
Item={VariableForTestInputs}
addLabel="Add an entry"
@ -194,7 +196,9 @@ export const WebhookSettings = ({
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<ResponseVariableMapping>
initialItems={options?.responseVariableMapping}
initialItems={
options?.responseVariableMapping ?? { byId: {}, allIds: [] }
}
onItemsChange={handleResponseMappingChange}
Item={ResponseMappingInputs}
/>

View File

@ -8,10 +8,9 @@ import {
} from '@chakra-ui/react'
import React, { useEffect, useState } from 'react'
import { BubbleStep, DraggableStep, Step, TextBubbleStep } from 'models'
import { useGraph } from 'contexts/GraphContext'
import { Coordinates, useGraph } from 'contexts/GraphContext'
import { StepIcon } from 'components/board/StepTypesList/StepIcon'
import { isBubbleStep, isTextBubbleStep } from 'utils'
import { Coordinates } from '@dnd-kit/core/dist/types'
import { TextEditor } from './TextEditor/TextEditor'
import { StepNodeContent } from './StepNodeContent/StepNodeContent'
import { useTypebot } from 'contexts/TypebotContext'
@ -43,7 +42,8 @@ export const StepNode = ({
) => void
}) => {
const { query } = useRouter()
const { setConnectingIds, connectingIds } = useGraph()
const { setConnectingIds, connectingIds, openedStepId, setOpenedStepId } =
useGraph()
const { moveStep } = useTypebot()
const [isConnecting, setIsConnecting] = useState(false)
const [mouseDownEvent, setMouseDownEvent] =
@ -57,6 +57,11 @@ export const StepNode = ({
onClose: onModalClose,
} = useDisclosure()
useEffect(() => {
if (query.stepId?.toString() === step.id) setOpenedStepId(step.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
useEffect(() => {
setIsConnecting(
connectingIds?.target?.blockId === step.blockId &&
@ -126,6 +131,16 @@ export const StepNode = ({
setIsEditing(false)
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
setOpenedStepId(step.id)
}
const handleExpandClick = () => {
setOpenedStepId(undefined)
onModalOpen()
}
return isEditing && isTextBubbleStep(step) ? (
<TextEditor
stepId={step.id}
@ -137,11 +152,7 @@ export const StepNode = ({
renderMenu={() => <StepNodeContextMenu stepId={step.id} />}
>
{(ref, isOpened) => (
<Popover
placement="left"
isLazy
defaultIsOpen={query.stepId?.toString() === step.id}
>
<Popover placement="left" isLazy isOpen={openedStepId === step.id}>
<PopoverTrigger>
<Flex
pos="relative"
@ -151,6 +162,7 @@ export const StepNode = ({
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onClick={handleClick}
data-testid={`step-${step.id}`}
w="full"
>
@ -166,7 +178,11 @@ export const StepNode = ({
align="flex-start"
w="full"
>
<StepIcon type={step.type} mt="1" />
<StepIcon
type={step.type}
mt="1"
data-testid={`${step.id}-icon`}
/>
<StepNodeContent step={step} />
<TargetEndpoint
pos="absolute"
@ -189,7 +205,10 @@ export const StepNode = ({
</Flex>
</PopoverTrigger>
{hasSettingsPopover(step) && (
<SettingsPopoverContent step={step} onExpandClick={onModalOpen} />
<SettingsPopoverContent
step={step}
onExpandClick={handleExpandClick}
/>
)}
{hasContentPopover(step) && <ContentPopover step={step} />}
<SettingsModal isOpen={isModalOpen} onClose={onModalClose}>

View File

@ -7,26 +7,31 @@ export const ConditionNodeContent = ({ step }: { step: ConditionStep }) => {
const { typebot } = useTypebot()
return (
<Flex>
<Stack color={'gray.500'}>
{step.options?.comparisons.allIds.map((comparisonId, idx) => {
const comparison = step.options?.comparisons.byId[comparisonId]
const variable = typebot?.variables.byId[comparison?.variableId ?? '']
return (
<HStack key={comparisonId} spacing={1}>
{idx > 0 && <Text>{step.options?.logicalOperator ?? ''}</Text>}
{variable?.name && (
<Tag bgColor="orange.400">{variable.name}</Tag>
)}
{comparison.comparisonOperator && (
<Text>{comparison?.comparisonOperator}</Text>
)}
{comparison?.value && (
<Tag bgColor={'green.400'}>{comparison.value}</Tag>
)}
</HStack>
)
})}
</Stack>
{step.options?.comparisons.allIds.length === 0 ? (
<Text color={'gray.500'}>Configure...</Text>
) : (
<Stack>
{step.options?.comparisons.allIds.map((comparisonId, idx) => {
const comparison = step.options?.comparisons.byId[comparisonId]
const variable =
typebot?.variables.byId[comparison?.variableId ?? '']
return (
<HStack key={comparisonId} spacing={1}>
{idx > 0 && <Text>{step.options?.logicalOperator ?? ''}</Text>}
{variable?.name && (
<Tag bgColor="orange.400">{variable.name}</Tag>
)}
{comparison.comparisonOperator && (
<Text>{comparison?.comparisonOperator}</Text>
)}
{comparison?.value && (
<Tag bgColor={'green.400'}>{comparison.value}</Tag>
)}
</HStack>
)
})}
</Stack>
)}
<SourceEndpoint
source={{
blockId: step.blockId,

View File

@ -80,11 +80,7 @@ export const StepNodeContent = ({ step }: Props) => {
)
}
case InputStepType.DATE: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.from ?? 'Pick a date...'}
</Text>
)
return <Text color={'gray.500'}>Pick a date...</Text>
}
case InputStepType.PHONE: {
return (

View File

@ -1,6 +1,6 @@
import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react'
import { DraggableStep, Step, Table } from 'models'
import { useDnd } from 'contexts/DndContext'
import { useStepDnd } from 'contexts/StepDndContext'
import { Coordinates } from 'contexts/GraphContext'
import { useMemo, useState } from 'react'
import { StepNode, StepNodeOverlay } from './StepNode'
@ -20,7 +20,7 @@ export const StepsList = ({
mouseOverBlockId,
setDraggedStepType,
setMouseOverBlockId,
} = useDnd()
} = useStepDnd()
const { createStep } = useTypebot()
const [expandedPlaceholderIndex, setExpandedPlaceholderIndex] = useState<
number | undefined

View File

@ -1,8 +1,8 @@
import { Flex, FlexProps, useEventListener } from '@chakra-ui/react'
import React, { useRef, useMemo } from 'react'
import React, { useRef, useMemo, useEffect } from 'react'
import { blockWidth, useGraph } from 'contexts/GraphContext'
import { BlockNode } from './BlockNode/BlockNode'
import { useDnd } from 'contexts/DndContext'
import { useStepDnd } from 'contexts/StepDndContext'
import { Edges } from './Edges'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { headerHeight } from 'components/shared/TypebotHeader/TypebotHeader'
@ -10,16 +10,23 @@ import { DraggableStepType } from 'models'
const Graph = ({ ...props }: FlexProps) => {
const { draggedStepType, setDraggedStepType, draggedStep, setDraggedStep } =
useDnd()
useStepDnd()
const graphContainerRef = useRef<HTMLDivElement | null>(null)
const editorContainerRef = useRef<HTMLDivElement | null>(null)
const { createBlock, typebot } = useTypebot()
const { graphPosition, setGraphPosition } = useGraph()
const { graphPosition, setGraphPosition, setOpenedStepId } = useGraph()
const transform = useMemo(
() =>
`translate(${graphPosition.x}px, ${graphPosition.y}px) scale(${graphPosition.scale})`,
[graphPosition]
)
useEffect(() => {
editorContainerRef.current = document.getElementById(
'editor-container'
) as HTMLDivElement
}, [])
const handleMouseWheel = (e: WheelEvent) => {
e.preventDefault()
const isPinchingTrackpad = e.ctrlKey
@ -57,6 +64,9 @@ const Graph = ({ ...props }: FlexProps) => {
}
useEventListener('mousedown', handleMouseDown, undefined, { capture: true })
const handleClick = () => setOpenedStepId(undefined)
useEventListener('click', handleClick, editorContainerRef.current)
if (!typebot) return <></>
return (
<Flex ref={graphContainerRef} {...props}>

View File

@ -1,26 +1,20 @@
import { DashboardFolder } from '.prisma/client'
import { Typebot } from 'models'
import {
Button,
Flex,
Heading,
HStack,
Portal,
Skeleton,
Stack,
useEventListener,
useToast,
Wrap,
} from '@chakra-ui/react'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import { FolderPlusIcon } from 'assets/icons'
import React, { useState } from 'react'
import { useTypebotDnd } from 'contexts/TypebotDndContext'
import { Typebot } from 'models'
import React, { useEffect, useState } from 'react'
import { createFolder, useFolders } from 'services/folders'
import { patchTypebot, useTypebots } from 'services/typebots'
import { BackButton } from './FolderContent/BackButton'
@ -31,16 +25,24 @@ import { TypebotCardOverlay } from './FolderContent/TypebotButtonOverlay'
type Props = { folder: DashboardFolder | null }
const dragDistanceTolerance = 20
export const FolderContent = ({ folder }: Props) => {
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
const [draggedTypebot, setDraggedTypebot] = useState<Typebot | undefined>()
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
distance: 20,
},
})
)
const {
setDraggedTypebot,
draggedTypebot,
mouseOverFolderId,
setMouseOverFolderId,
} = useTypebotDnd()
const [mouseDownPosition, setMouseDownPosition] = useState({ x: 0, y: 0 })
const [draggablePosition, setDraggablePosition] = useState({ x: 0, y: 0 })
const [relativeDraggablePosition, setRelativeDraggablePosition] = useState({
x: 0,
y: 0,
})
const [typebotDragCandidate, setTypebotDragCandidate] = useState<Typebot>()
const toast = useToast({
position: 'top-right',
status: 'error',
@ -67,19 +69,6 @@ export const FolderContent = ({ folder }: Props) => {
},
})
const handleDragStart = (event: DragStartEvent) => {
if (!typebots) return
setDraggedTypebot(typebots.find((c) => c.id === event.active.id))
}
const handleDragEnd = async (event: DragEndEvent) => {
if (!typebots) return
const { over } = event
if (over?.id && draggedTypebot?.id)
await moveTypebotToFolder(draggedTypebot.id, over.id)
setDraggedTypebot(undefined)
}
const moveTypebotToFolder = async (typebotId: string, folderId: string) => {
if (!typebots) return
const { error } = await patchTypebot(typebotId, {
@ -118,63 +107,103 @@ export const FolderContent = ({ folder }: Props) => {
})
}
const handleMouseUp = async () => {
if (mouseOverFolderId !== undefined && draggedTypebot)
await moveTypebotToFolder(draggedTypebot.id, mouseOverFolderId ?? 'root')
setTypebotDragCandidate(undefined)
setMouseOverFolderId(undefined)
setDraggedTypebot(undefined)
}
useEventListener('mouseup', handleMouseUp)
const handleMouseDown = (typebot: Typebot) => (e: React.MouseEvent) => {
const element = e.currentTarget as HTMLDivElement
const rect = element.getBoundingClientRect()
setDraggablePosition({ x: rect.left, y: rect.top })
const x = e.clientX - rect.left
const y = e.clientY - rect.top
setRelativeDraggablePosition({ x, y })
setMouseDownPosition({ x: e.screenX, y: e.screenY })
setTypebotDragCandidate(typebot)
}
const handleMouseMove = (e: MouseEvent) => {
if (!typebotDragCandidate) return
const { clientX, clientY, screenX, screenY } = e
if (
Math.abs(mouseDownPosition.x - screenX) > dragDistanceTolerance ||
Math.abs(mouseDownPosition.y - screenY) > dragDistanceTolerance
)
setDraggedTypebot(typebotDragCandidate)
setDraggablePosition({
...draggablePosition,
x: clientX - relativeDraggablePosition.x,
y: clientY - relativeDraggablePosition.y,
})
}
useEventListener('mousemove', handleMouseMove)
return (
<Flex w="full" justify="center" align="center" pt={4}>
<DndContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<Stack w="1000px" spacing={6}>
<Skeleton isLoaded={folder?.name !== undefined}>
<Heading as="h1">{folder?.name}</Heading>
</Skeleton>
<Stack>
<HStack>
{folder && <BackButton id={folder.parentFolderId} />}
<Button
leftIcon={<FolderPlusIcon />}
onClick={handleCreateFolder}
isLoading={isCreatingFolder || isFolderLoading}
>
Create a folder
</Button>
</HStack>
<Wrap spacing={4}>
<CreateBotButton
folderId={folder?.id}
isLoading={isTypebotLoading}
/>
{isFolderLoading && <ButtonSkeleton />}
{folders &&
folders.map((folder) => (
<FolderButton
key={folder.id.toString()}
folder={folder}
onFolderDeleted={() => handleFolderDeleted(folder.id)}
onFolderRenamed={(newName: string) =>
handleFolderRenamed(folder.id, newName)
}
/>
))}
{isTypebotLoading && <ButtonSkeleton />}
{typebots &&
typebots.map((typebot) => (
<TypebotButton
key={typebot.id.toString()}
typebot={typebot}
onTypebotDeleted={() => handleTypebotDeleted(typebot.id)}
/>
))}
<DragOverlay dropAnimation={null}>
{draggedTypebot && (
<TypebotCardOverlay typebot={draggedTypebot} />
)}
</DragOverlay>
</Wrap>
</Stack>
<Flex w="full" flex="1" justify="center" pt={4}>
<Stack w="1000px" spacing={6}>
<Skeleton isLoaded={folder?.name !== undefined}>
<Heading as="h1">{folder?.name}</Heading>
</Skeleton>
<Stack>
<HStack>
{folder && <BackButton id={folder.parentFolderId} />}
<Button
leftIcon={<FolderPlusIcon />}
onClick={handleCreateFolder}
isLoading={isCreatingFolder || isFolderLoading}
>
Create a folder
</Button>
</HStack>
<Wrap spacing={4}>
<CreateBotButton
folderId={folder?.id}
isLoading={isTypebotLoading}
/>
{isFolderLoading && <ButtonSkeleton />}
{folders &&
folders.map((folder) => (
<FolderButton
key={folder.id.toString()}
folder={folder}
onFolderDeleted={() => handleFolderDeleted(folder.id)}
onFolderRenamed={(newName: string) =>
handleFolderRenamed(folder.id, newName)
}
/>
))}
{isTypebotLoading && <ButtonSkeleton />}
{typebots &&
typebots.map((typebot) => (
<TypebotButton
key={typebot.id.toString()}
typebot={typebot}
onTypebotDeleted={() => handleTypebotDeleted(typebot.id)}
onMouseDown={handleMouseDown(typebot)}
/>
))}
</Wrap>
</Stack>
</DndContext>
</Stack>
{draggedTypebot && (
<Portal>
<TypebotCardOverlay
typebot={draggedTypebot}
onMouseUp={handleMouseUp}
pos="fixed"
top="0"
left="0"
style={{
transform: `translate(${draggablePosition.x}px, ${draggablePosition.y}px) rotate(-2deg)`,
}}
/>
</Portal>
)}
</Flex>
)
}

View File

@ -1,22 +1,30 @@
import { Button } from '@chakra-ui/react'
import { useDroppable } from '@dnd-kit/core'
import { ChevronLeftIcon } from 'assets/icons'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import React from 'react'
import { useTypebotDnd } from 'contexts/TypebotDndContext'
import React, { useMemo } from 'react'
export const BackButton = ({ id }: { id: string | null }) => {
const { setNodeRef, isOver } = useDroppable({
id: id?.toString() ?? 'root',
})
const { draggedTypebot, setMouseOverFolderId, mouseOverFolderId } =
useTypebotDnd()
const isTypebotOver = useMemo(
() => draggedTypebot && mouseOverFolderId === id,
[draggedTypebot, id, mouseOverFolderId]
)
const handleMouseEnter = () => setMouseOverFolderId(id)
const handleMouseLeave = () => setMouseOverFolderId(undefined)
return (
<Button
as={NextChakraLink}
href={id ? `/typebots/folders/${id}` : '/typebots'}
leftIcon={<ChevronLeftIcon />}
variant={'outline'}
colorScheme={isOver ? 'blue' : 'gray'}
borderWidth={isOver ? '3px' : '1px'}
ref={setNodeRef}
colorScheme={isTypebotOver ? 'blue' : 'gray'}
borderWidth={isTypebotOver ? '3px' : '1px'}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
Back
</Button>

View File

@ -17,11 +17,11 @@ import {
SkeletonCircle,
WrapItem,
} from '@chakra-ui/react'
import { useDroppable } from '@dnd-kit/core'
import { FolderIcon, MoreVerticalIcon } from 'assets/icons'
import { ConfirmModal } from 'components/modals/ConfirmModal'
import { useTypebotDnd } from 'contexts/TypebotDndContext'
import { useRouter } from 'next/router'
import React from 'react'
import React, { useMemo } from 'react'
import { deleteFolder, updateFolder } from 'services/folders'
export const FolderButton = ({
@ -34,9 +34,12 @@ export const FolderButton = ({
onFolderRenamed: (newName: string) => void
}) => {
const router = useRouter()
const { setNodeRef, isOver } = useDroppable({
id: folder.id.toString(),
})
const { draggedTypebot, setMouseOverFolderId, mouseOverFolderId } =
useTypebotDnd()
const isTypebotOver = useMemo(
() => draggedTypebot && mouseOverFolderId === folder.id,
[draggedTypebot, folder.id, mouseOverFolderId]
)
const { isOpen, onOpen, onClose } = useDisclosure()
const toast = useToast({
position: 'top-right',
@ -65,31 +68,33 @@ export const FolderButton = ({
router.push(`/typebots/folders/${folder.id}`)
}
const handleMouseEnter = () => setMouseOverFolderId(folder.id)
const handleMouseLeave = () => setMouseOverFolderId(undefined)
return (
<Button
as={WrapItem}
ref={setNodeRef}
style={{ width: '225px', height: '270px' }}
paddingX={6}
whiteSpace={'normal'}
pos="relative"
cursor="pointer"
variant="outline"
colorScheme={isOver ? 'blue' : 'gray'}
borderWidth={isOver ? '3px' : '1px'}
colorScheme={isTypebotOver ? 'blue' : 'gray'}
borderWidth={isTypebotOver ? '3px' : '1px'}
justifyContent="center"
onClick={handleClick}
data-testid="folder-button"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Menu>
<MenuButton
as={IconButton}
icon={<MoreVerticalIcon />}
aria-label="Show folder menu"
aria-label={`Show ${folder.name} menu`}
onClick={(e) => e.stopPropagation()}
colorScheme="blue"
variant="ghost"
size="lg"
colorScheme="gray"
variant="outline"
size="sm"
pos="absolute"
top="5"
right="5"

View File

@ -16,10 +16,11 @@ export const MoreButton = ({ children, ...props }: Props) => {
<MenuButton
as={IconButton}
icon={<MoreVerticalIcon />}
onClick={(e) => e.stopPropagation()}
colorScheme="blue"
variant="ghost"
size="lg"
onMouseUp={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
colorScheme="gray"
variant="outline"
size="sm"
{...props}
/>
<MenuList>{children}</MenuList>

View File

@ -2,6 +2,7 @@ import React from 'react'
import {
Button,
Flex,
IconButton,
MenuItem,
Text,
useDisclosure,
@ -9,28 +10,28 @@ import {
VStack,
WrapItem,
} from '@chakra-ui/react'
import { useDraggable } from '@dnd-kit/core'
import { useRouter } from 'next/router'
import { isMobile } from 'services/utils'
import { MoreButton } from 'components/MoreButton'
import { MoreButton } from 'components/dashboard/FolderContent/MoreButton'
import { ConfirmModal } from 'components/modals/ConfirmModal'
import { GlobeIcon, ToolIcon } from 'assets/icons'
import { GlobeIcon, GripIcon, ToolIcon } from 'assets/icons'
import { deleteTypebot, duplicateTypebot } from 'services/typebots'
import { Typebot } from 'models'
import { useTypebotDnd } from 'contexts/TypebotDndContext'
type ChatbotCardProps = {
typebot: Typebot
onTypebotDeleted: () => void
onMouseDown: (e: React.MouseEvent<HTMLButtonElement>) => void
}
export const TypebotButton = ({
typebot,
onTypebotDeleted,
onMouseDown,
}: ChatbotCardProps) => {
const router = useRouter()
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: typebot.id.toString(),
})
const { draggedTypebot } = useTypebotDnd()
const {
isOpen: isDeleteOpen,
onOpen: onDeleteOpen,
@ -43,6 +44,7 @@ export const TypebotButton = ({
})
const handleTypebotClick = () => {
if (draggedTypebot) return
router.push(
isMobile
? `/typebots/${typebot.id}/results/responses`
@ -73,7 +75,7 @@ export const TypebotButton = ({
return (
<Button
as={WrapItem}
onClick={handleTypebotClick}
onMouseUp={handleTypebotClick}
display="flex"
flexDir="column"
variant="outline"
@ -84,17 +86,26 @@ export const TypebotButton = ({
mb={6}
rounded="lg"
whiteSpace="normal"
data-testid={`typebot-button-${typebot.id}`}
opacity={isDragging ? 0.2 : 1}
ref={setNodeRef}
{...listeners}
{...attributes}
opacity={draggedTypebot?.id === typebot.id ? 0.2 : 1}
onMouseDown={onMouseDown}
cursor="pointer"
>
<IconButton
icon={<GripIcon />}
pos="absolute"
top="20px"
left="20px"
aria-label="Drag"
cursor="grab"
variant="ghost"
colorScheme="blue"
size="sm"
/>
<MoreButton
pos="absolute"
top="10px"
right="10px"
aria-label="Show typebot menu"
top="20px"
right="20px"
aria-label={`Show ${typebot.name} menu`}
>
<MenuItem onClick={handleDuplicateClick}>Duplicate</MenuItem>
<MenuItem

View File

@ -1,43 +1,47 @@
import { Button, Flex, Text, VStack } from '@chakra-ui/react'
import { Box, BoxProps, Flex, Text, VStack } from '@chakra-ui/react'
import { GlobeIcon, ToolIcon } from 'assets/icons'
import { Typebot } from 'models'
type Props = {
typebot: Typebot
}
} & BoxProps
export const TypebotCardOverlay = ({ typebot }: Props) => {
export const TypebotCardOverlay = ({ typebot, ...props }: Props) => {
return (
<div
className="sm:mr-6 mb-6 focus:outline-none rounded-lg "
style={{ width: '225px', height: '270px' }}
<Box
display="flex"
flexDir="column"
variant="outline"
justifyContent="center"
w="225px"
h="270px"
whiteSpace="normal"
transition="none"
pointerEvents="none"
borderWidth={1}
rounded="md"
bgColor="white"
shadow="lg"
opacity={0.7}
{...props}
>
<Button
display="flex"
flexDir="column"
variant="outline"
w="full"
h="full"
whiteSpace="normal"
>
<VStack spacing={4}>
<Flex
boxSize="45px"
rounded="full"
justifyContent="center"
alignItems="center"
bgColor={typebot.publishedTypebotId ? 'blue.500' : 'gray.400'}
color="white"
>
{typebot.publishedTypebotId ? (
<GlobeIcon fill="white" fontSize="20px" />
) : (
<ToolIcon fill="white" fontSize="20px" />
)}
</Flex>
<Text>{typebot.name}</Text>
</VStack>
</Button>
</div>
<VStack spacing={4}>
<Flex
boxSize="45px"
rounded="full"
justifyContent="center"
alignItems="center"
bgColor={typebot.publishedTypebotId ? 'blue.500' : 'gray.400'}
color="white"
>
{typebot.publishedTypebotId ? (
<GlobeIcon fill="white" fontSize="20px" />
) : (
<ToolIcon fill="white" fontSize="20px" />
)}
</Flex>
<Text>{typebot.name}</Text>
</VStack>
</Box>
)
}

View File

@ -92,7 +92,6 @@ export const MetadataForm = ({
<InputWithVariableButton
id="title"
initialValue={metadata.title ?? typebotName}
delay={100}
onChange={handleTitleChange}
/>
</Stack>
@ -103,7 +102,6 @@ export const MetadataForm = ({
<TextareaWithVariableButton
id="description"
initialValue={metadata.description}
delay={100}
onChange={handleDescriptionChange}
/>
</Stack>

View File

@ -6,27 +6,24 @@ import {
useEffect,
useState,
} from 'react'
import { useDebounce } from 'use-debounce'
type Props = Omit<InputProps, 'onChange' | 'value'> & {
delay: number
initialValue: string
onChange: (debouncedValue: string) => void
}
export const DebouncedInput = forwardRef(
(
{ delay, onChange, initialValue, ...props }: Props,
{ onChange, initialValue, ...props }: Props,
ref: ForwardedRef<HTMLInputElement>
) => {
const [currentValue, setCurrentValue] = useState(initialValue)
const [currentValueDebounced] = useDebounce(currentValue, delay)
useEffect(() => {
if (currentValueDebounced === initialValue) return
onChange(currentValueDebounced)
if (currentValue === initialValue) return
onChange(currentValue)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentValueDebounced])
}, [currentValue])
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setCurrentValue(e.target.value)

View File

@ -1,27 +1,23 @@
import { Textarea, TextareaProps } from '@chakra-ui/react'
import { ChangeEvent, useEffect, useState } from 'react'
import { useDebounce } from 'use-debounce'
type Props = Omit<TextareaProps, 'onChange' | 'value'> & {
delay: number
initialValue: string
onChange: (debouncedValue: string) => void
}
export const DebouncedTextarea = ({
delay,
onChange,
initialValue,
...props
}: Props) => {
const [currentValue, setCurrentValue] = useState(initialValue)
const [currentValueDebounced] = useDebounce(currentValue, delay)
useEffect(() => {
if (currentValueDebounced === initialValue) return
onChange(currentValueDebounced)
if (currentValue === initialValue) return
onChange(currentValue)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentValueDebounced])
}, [currentValue])
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setCurrentValue(e.target.value)

View File

@ -5,6 +5,7 @@ import {
MenuButtonProps,
MenuItem,
MenuList,
Portal,
Stack,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from 'assets/icons'
@ -41,22 +42,24 @@ export const DropdownList = <T,>({
>
{currentItem ?? placeholder}
</MenuButton>
<MenuList maxW="500px" shadow="lg">
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
{items.map((item) => (
<MenuItem
key={item as unknown as string}
maxW="500px"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
onClick={handleMenuItemClick(item)}
>
{item}
</MenuItem>
))}
</Stack>
</MenuList>
<Portal>
<MenuList maxW="500px" shadow="lg" zIndex={1500}>
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
{items.map((item) => (
<MenuItem
key={item as unknown as string}
maxW="500px"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
onClick={handleMenuItemClick(item)}
>
{item}
</MenuItem>
))}
</Stack>
</MenuList>
</Portal>
</Menu>
</>
)

View File

@ -17,12 +17,14 @@ type Props = {
selectedItem?: string
items: string[]
onValueChange?: (value: string) => void
isLoading?: boolean
} & InputProps
export const SearchableDropdown = ({
selectedItem,
items,
onValueChange,
isLoading = false,
...inputProps
}: Props) => {
const { onOpen, onClose, isOpen } = useDisclosure()
@ -47,6 +49,7 @@ export const SearchableDropdown = ({
)
.slice(0, 50),
])
if (inputRef.current === document.activeElement) onOpen()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items])
@ -97,6 +100,7 @@ export const SearchableDropdown = ({
value={inputValue}
onChange={onInputChange}
onClick={onOpen}
type="text"
{...inputProps}
/>
</PopoverTrigger>
@ -130,7 +134,9 @@ export const SearchableDropdown = ({
})}
</>
) : (
<Text p={4}>Not found.</Text>
<Text p={4} color="gray.500">
{isLoading ? 'Loading...' : 'Not found.'}
</Text>
)}
</PopoverContent>
</Popover>

View File

@ -13,7 +13,7 @@ export type TableListItemProps<T> = {
}
type Props<T> = {
initialItems?: Table<T>
initialItems: Table<T>
onItemsChange: (items: Table<T>) => void
addLabel?: string
Item: (props: TableListItemProps<T>) => JSX.Element
@ -27,7 +27,7 @@ export const TableList = <T,>({
Item,
ComponentBetweenItems = () => <></>,
}: Props<T>) => {
const [items, setItems] = useImmer(initialItems ?? { byId: {}, allIds: [] })
const [items, setItems] = useImmer(initialItems)
const [showDeleteId, setShowDeleteId] = useState<string | undefined>()
useEffect(() => {
@ -80,6 +80,7 @@ export const TableList = <T,>({
pos="relative"
onMouseEnter={handleMouseEnter(itemId)}
onMouseLeave={handleMouseLeave}
mt={idx !== 0 && ComponentBetweenItems ? 4 : 0}
>
<Item
id={itemId}

View File

@ -13,13 +13,11 @@ import {
import { UserIcon } from 'assets/icons'
import { Variable } from 'models'
import React, { useEffect, useRef, useState } from 'react'
import { useDebounce } from 'use-debounce'
import { VariableSearchInput } from '../VariableSearchInput'
export type TextBoxWithVariableButtonProps = {
initialValue: string
onChange: (value: string) => void
delay?: number
TextBox:
| ComponentWithAs<'textarea', TextareaProps>
| ComponentWithAs<'input', InputProps>
@ -28,7 +26,6 @@ export type TextBoxWithVariableButtonProps = {
export const TextBoxWithVariableButton = ({
initialValue,
onChange,
delay,
TextBox,
...props
}: TextBoxWithVariableButtonProps) => {
@ -36,13 +33,12 @@ export const TextBoxWithVariableButton = ({
null
)
const [value, setValue] = useState(initialValue)
const [debouncedValue] = useDebounce(value, delay ?? 100)
const [carretPosition, setCarretPosition] = useState<number>(0)
useEffect(() => {
if (debouncedValue !== initialValue) onChange(debouncedValue)
if (value !== initialValue) onChange(value)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedValue])
}, [value])
const handleVariableSelected = (variable?: Variable) => {
if (!textBoxRef.current || !variable) return

View File

@ -118,6 +118,7 @@ export const VariableSearchInput = ({
value={inputValue}
onChange={onInputChange}
onClick={onOpen}
placeholder={inputProps.placeholder ?? 'Select a variable'}
{...inputProps}
/>
</PopoverTrigger>

View File

@ -42,7 +42,7 @@ export const ColorPicker = ({ initialColor, onColorChange }: Props) => {
setColor(e.target.value)
return (
<Popover variant="picker">
<Popover variant="picker" placement="right" isLazy>
<PopoverTrigger>
<Button
aria-label={'Pick a color'}

View File

@ -65,6 +65,8 @@ const graphContext = createContext<{
addSourceEndpoint: (endpoint: Endpoint) => void
targetEndpoints: Table<Endpoint>
addTargetEndpoint: (endpoint: Endpoint) => void
openedStepId?: string
setOpenedStepId: Dispatch<SetStateAction<string | undefined>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({
@ -84,6 +86,7 @@ export const GraphProvider = ({ children }: { children: ReactNode }) => {
byId: {},
allIds: [],
})
const [openedStepId, setOpenedStepId] = useState<string>()
const addSourceEndpoint = (endpoint: Endpoint) => {
setSourceEndpoints((endpoints) => ({
@ -112,6 +115,8 @@ export const GraphProvider = ({ children }: { children: ReactNode }) => {
targetEndpoints,
addSourceEndpoint,
addTargetEndpoint,
openedStepId,
setOpenedStepId,
}}
>
{children}

View File

@ -8,7 +8,7 @@ import {
useState,
} from 'react'
const dndContext = createContext<{
const stepDndContext = createContext<{
draggedStepType?: DraggableStepType
setDraggedStepType: Dispatch<SetStateAction<DraggableStepType | undefined>>
draggedStep?: DraggableStep
@ -21,7 +21,7 @@ const dndContext = createContext<{
//@ts-ignore
}>({})
export const DndContext = ({ children }: { children: ReactNode }) => {
export const StepDndContext = ({ children }: { children: ReactNode }) => {
const [draggedStep, setDraggedStep] = useState<DraggableStep>()
const [draggedStepType, setDraggedStepType] = useState<
DraggableStepType | undefined
@ -32,7 +32,7 @@ export const DndContext = ({ children }: { children: ReactNode }) => {
const [mouseOverBlockId, setMouseOverBlockId] = useState<string>()
return (
<dndContext.Provider
<stepDndContext.Provider
value={{
draggedStep,
setDraggedStep,
@ -45,8 +45,8 @@ export const DndContext = ({ children }: { children: ReactNode }) => {
}}
>
{children}
</dndContext.Provider>
</stepDndContext.Provider>
)
}
export const useDnd = () => useContext(dndContext)
export const useStepDnd = () => useContext(stepDndContext)

View File

@ -0,0 +1,45 @@
import { Typebot } from 'models'
import {
createContext,
Dispatch,
ReactNode,
SetStateAction,
useContext,
useEffect,
useState,
} from 'react'
const typebotDndContext = createContext<{
draggedTypebot?: Typebot
setDraggedTypebot: Dispatch<SetStateAction<Typebot | undefined>>
mouseOverFolderId?: string | null
setMouseOverFolderId: Dispatch<SetStateAction<string | undefined | null>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
export const TypebotDndContext = ({ children }: { children: ReactNode }) => {
const [draggedTypebot, setDraggedTypebot] = useState<Typebot>()
const [mouseOverFolderId, setMouseOverFolderId] = useState<string | null>()
useEffect(() => {
draggedTypebot
? document.body.classList.add('grabbing')
: document.body.classList.remove('grabbing')
}, [draggedTypebot])
return (
<typebotDndContext.Provider
value={{
draggedTypebot,
setDraggedTypebot,
mouseOverFolderId,
setMouseOverFolderId,
}}
>
{children}
</typebotDndContext.Provider>
)
}
export const useTypebotDnd = () => useContext(typebotDndContext)

View File

@ -82,11 +82,6 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
setIsSaving(false)
}
const refreshUser = async () => {
await fetch('/api/auth/session?update')
reloadSession()
}
return (
<userContext.Provider
value={{
@ -105,6 +100,11 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
)
}
export const refreshUser = async () => {
await fetch('/api/auth/session?update')
reloadSession()
}
const reloadSession = () => {
const event = new Event('visibilitychange')
document.dispatchEvent(event)

View File

@ -1,8 +0,0 @@
{
"baseUrl": "http://localhost:3000",
"chromeWebSecurity": false,
"integrationFolder": "cypress/tests",
"viewportWidth": 1400,
"viewportHeight": 800,
"video": false
}

View File

@ -1,45 +0,0 @@
import { Step } from 'models'
import { parseTestTypebot } from './utils'
export const users = [
{ id: 'user1', email: 'test1@gmail.com' },
{ id: 'user2', email: 'test2@gmail.com' },
]
export const createTypebotWithStep = (step: Omit<Step, 'id' | 'blockId'>) => {
cy.task(
'createTypebot',
parseTestTypebot({
id: 'typebot3',
name: 'Typebot #3',
ownerId: users[1].id,
steps: {
byId: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
step1: {
...step,
id: 'step1',
blockId: 'block1',
},
},
allIds: ['step1'],
},
blocks: {
byId: {
block1: {
id: 'block1',
graphCoordinates: { x: 400, y: 200 },
title: 'Block #1',
stepIds: ['step1'],
},
},
allIds: ['block1'],
},
choiceItems: {
byId: { item1: { stepId: 'step1', id: 'item1' } },
allIds: ['item1'],
},
})
)
}

View File

@ -1,164 +0,0 @@
import {
defaultTextInputOptions,
InputStepType,
PublicTypebot,
TextInputStep,
Typebot,
} from 'models'
import { CredentialsType, Plan, PrismaClient } from 'db'
import { parseTestTypebot } from './utils'
import { users } from './data'
const prisma = new PrismaClient()
const teardownTestData = async () => prisma.user.deleteMany()
export const seedDb = async (googleRefreshToken: string) => {
await teardownTestData()
await createUsers()
await createCredentials(googleRefreshToken)
await createFolders()
await createTypebots()
await createResults()
return createAnswers()
}
export const createTypebot = (typebot: Typebot) =>
prisma.typebot.create({ data: typebot as any })
const createUsers = () =>
prisma.user.createMany({
data: [
{ ...users[0], emailVerified: new Date() },
{
...users[1],
emailVerified: new Date(),
plan: Plan.PRO,
stripeId: 'stripe-test2',
},
],
})
const createCredentials = (refresh_token: string) =>
prisma.credentials.createMany({
data: [
{
name: 'test2@gmail.com',
ownerId: users[1].id,
type: CredentialsType.GOOGLE_SHEETS,
data: {
expiry_date: 1642441058842,
access_token:
'ya29.A0ARrdaM--PV_87ebjywDJpXKb77NBFJl16meVUapYdfNv6W6ZzqqC47fNaPaRjbDbOIIcp6f49cMaX5ndK9TAFnKwlVqz3nrK9nLKqgyDIhYsIq47smcAIZkK56SWPx3X3DwAFqRu2UPojpd2upWwo-3uJrod',
refresh_token,
},
},
],
})
const createFolders = () =>
prisma.dashboardFolder.createMany({
data: [{ ownerId: users[1].id, name: 'Folder #1', id: 'folder1' }],
})
const createTypebots = async () => {
const typebot2 = {
...parseTestTypebot({
id: 'typebot2',
name: 'Typebot #2',
ownerId: users[1].id,
blocks: {
byId: {
block1: {
id: 'block1',
title: 'Block #1',
stepIds: ['step1'],
graphCoordinates: { x: 200, y: 200 },
},
},
allIds: ['block1'],
},
steps: {
byId: {
step1: {
id: 'step1',
type: InputStepType.TEXT,
blockId: 'block1',
options: defaultTextInputOptions,
} as TextInputStep,
},
allIds: ['step1'],
},
}),
}
await prisma.typebot.createMany({
data: [
{
...parseTestTypebot({
id: 'typebot1',
name: 'Typebot #1',
ownerId: users[1].id,
blocks: { byId: {}, allIds: [] },
steps: { byId: {}, allIds: [] },
}),
},
typebot2,
] as any,
})
return prisma.publicTypebot.createMany({
data: [parseTypebotToPublicTypebot('publictypebot2', typebot2)] as any,
})
}
const createResults = () => {
return prisma.result.createMany({
data: [
...Array.from(Array(200)).map((_, idx) => {
const today = new Date()
return {
id: `result${idx}`,
typebotId: 'typebot2',
createdAt: new Date(
today.setTime(today.getTime() + 1000 * 60 * 60 * 24 * idx)
),
isCompleted: false,
}
}),
],
})
}
const createAnswers = () => {
return prisma.answer.createMany({
data: [
...Array.from(Array(200)).map((_, idx) => ({
resultId: `result${idx}`,
content: `content${idx}`,
stepId: 'step1',
blockId: 'block1',
})),
],
})
}
const parseTypebotToPublicTypebot = (
id: string,
typebot: Typebot
): PublicTypebot => ({
id,
blocks: typebot.blocks,
steps: typebot.steps,
name: typebot.name,
typebotId: typebot.id,
theme: typebot.theme,
settings: typebot.settings,
publicId: typebot.publicId,
choiceItems: typebot.choiceItems,
variables: typebot.variables,
edges: typebot.edges,
})
export const loadRawTypebotInDatabase = (typebot: Typebot) =>
prisma.typebot.create({
data: { ...typebot, id: 'typebot4', ownerId: users[1].id } as any,
})

View File

@ -1,24 +0,0 @@
import {
GitHubSocialLogin,
FacebookSocialLogin,
GoogleSocialLogin,
} from 'cypress-social-logins/src/Plugins'
import { createTypebot, loadRawTypebotInDatabase, seedDb } from './database'
/// <reference types="cypress" />
/**
* @type {Cypress.PluginConfig}
*/
const handler = (on: any) => {
on('task', {
GoogleSocialLogin: GoogleSocialLogin,
FacebookSocialLogin: FacebookSocialLogin,
GitHubSocialLogin: GitHubSocialLogin,
seed: seedDb,
createTypebot,
loadRawTypebotInDatabase,
})
}
export default handler

View File

@ -1,81 +0,0 @@
import {
Block,
Typebot,
Table,
Step,
ChoiceItem,
defaultTheme,
defaultSettings,
} from 'models'
export const parseTestTypebot = ({
id,
ownerId,
name,
blocks,
steps,
choiceItems,
}: {
id: string
ownerId: string
name: string
blocks: Table<Block>
steps: Table<Step>
choiceItems?: Table<ChoiceItem>
}): Typebot => {
return {
id,
folderId: null,
name,
ownerId,
theme: defaultTheme,
settings: defaultSettings,
createdAt: new Date(),
blocks: {
byId: {
block0: {
id: 'block0',
title: 'Block #0',
stepIds: ['step0'],
graphCoordinates: { x: 0, y: 0 },
},
...blocks.byId,
},
allIds: ['block0', ...blocks.allIds],
},
steps: {
byId: {
step0: {
id: 'step0',
type: 'start',
blockId: 'block0',
label: 'Start',
edgeId: 'edge1',
},
...steps.byId,
},
allIds: ['step0', ...steps.allIds],
},
choiceItems: choiceItems ?? { byId: {}, allIds: [] },
publicId: null,
publishedTypebotId: null,
updatedAt: new Date(),
variables: { byId: {}, allIds: [] },
webhooks: { byId: {}, allIds: [] },
edges: {
byId: {
edge1: {
id: 'edge1',
from: { blockId: 'block0', stepId: 'step0' },
to: { blockId: 'block1' },
},
},
allIds: ['edge1'],
},
}
}
export const preventUserFromRefreshing = (e: BeforeUnloadEvent) => {
e.preventDefault()
e.returnValue = ''
}

View File

@ -1,104 +0,0 @@
import { signIn, signOut } from 'next-auth/react'
Cypress.Commands.add('signOut', () => {
cy.log(`🔐 Sign out`)
return cy.wrap(signOut({ redirect: false }), { log: false })
})
Cypress.Commands.add('signIn', (email: string) => {
cy.log(`🔐 Sign in as ${email}`)
return cy.wrap(signIn('credentials', { redirect: false, email }), {
log: false,
})
})
Cypress.Commands.add('loadTypebotFixtureInDatabase', (path: string) => {
return cy.fixture(path).then((typebot) => {
cy.task('loadRawTypebotInDatabase', typebot)
})
})
Cypress.Commands.add(
'mouseMoveBy',
{
prevSubject: 'element',
},
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(
subject: JQuery<HTMLElement>,
x: number,
y: number,
options?: { delay: number }
) => {
cy.wrap(subject, { log: false })
.then((subject) => {
const initialRect = subject.get(0).getBoundingClientRect()
const windowScroll = getDocumentScroll()
return [subject, initialRect, windowScroll] as const
})
.then(([subject, initialRect, initialWindowScroll]) => {
cy.wrap(subject)
.trigger('mousedown', { force: true })
.wait(options?.delay || 0, { log: Boolean(options?.delay) })
.trigger('mousemove', {
force: true,
clientX: Math.floor(
initialRect.left + initialRect.width / 2 + x / 2
),
clientY: Math.floor(
initialRect.top + initialRect.height / 2 + y / 2
),
})
.trigger('mousemove', {
force: true,
clientX: Math.floor(initialRect.left + initialRect.width / 2 + x),
clientY: Math.floor(initialRect.top + initialRect.height / 2 + y),
})
// .wait(1000)
.trigger('mouseup', { force: true })
.wait(250)
.then((subject: any) => {
const finalRect = subject.get(0).getBoundingClientRect()
const windowScroll = getDocumentScroll()
const windowScrollDelta = {
x: windowScroll.x - initialWindowScroll.x,
y: windowScroll.y - initialWindowScroll.y,
}
const delta = {
x: Math.round(
finalRect.left - initialRect.left - windowScrollDelta.x
),
y: Math.round(
finalRect.top - initialRect.top - windowScrollDelta.y
),
}
return [subject, { initialRect, finalRect, delta }] as const
})
})
}
)
Cypress.Commands.add('createVariable', (name: string) => {
cy.findByTestId('variables-input').type(name)
cy.findByRole('menuitem', { name: `Create "${name}"` }).click()
})
const getDocumentScroll = () => {
if (document.scrollingElement) {
const { scrollTop, scrollLeft } = document.scrollingElement
return {
x: scrollTop,
y: scrollLeft,
}
}
return {
x: 0,
y: 0,
}
}

View File

@ -1,78 +0,0 @@
/* eslint-disable @typescript-eslint/no-namespace */
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
/// <reference types="cypress" />
declare global {
namespace Cypress {
interface Chainable {
signOut(): Chainable<any>
signIn(email: string): Chainable<any>
loadTypebotFixtureInDatabase(path: string): Chainable<any>
createVariable(name: string): Chainable<any>
mouseMoveBy(
x: number,
y: number,
options?: { delay: number }
): Chainable<
[
Element,
{
initialRect: ClientRect
finalRect: ClientRect
delta: { x: number; y: number }
}
]
>
}
}
}
const resizeObserverLoopErrRe = /^[^(ResizeObserver loop limit exceeded)]/
Cypress.on('uncaught:exception', (err) => {
if (resizeObserverLoopErrRe.test(err.message)) {
return false
}
})
export const prepareDbAndSignIn = () => {
cy.signOut()
cy.task('seed')
cy.signIn(users[1].email)
}
export const removePreventReload = () => {
cy.window().then((win) => {
win.removeEventListener('beforeunload', preventUserFromRefreshing)
})
}
export const getIframeBody = () => {
return cy
.get('#typebot-iframe')
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
}
// Import commands.js using ES2015 syntax:
import '@testing-library/cypress/add-commands'
import 'cypress-file-upload'
import { users } from 'cypress/plugins/data'
import { preventUserFromRefreshing } from 'cypress/plugins/utils'
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

View File

@ -1,65 +0,0 @@
import { users } from 'cypress/plugins/data'
import { prepareDbAndSignIn, removePreventReload } from 'cypress/support'
describe('Account page', () => {
before(() => {
cy.intercept({
url: 'https://s3.eu-west-3.amazonaws.com/typebot',
method: 'POST',
}).as('postImage')
cy.intercept({ url: '/api/auth/session?update', method: 'GET' }).as(
'getUpdatedSession'
)
})
beforeEach(prepareDbAndSignIn)
afterEach(removePreventReload)
it('should edit user info properly', () => {
cy.signIn(users[0].email)
cy.visit('/account')
cy.findByRole('button', { name: 'Save' }).should('not.exist')
cy.findByRole('textbox', { name: 'Email address' }).should(
'have.attr',
'disabled'
)
cy.findByRole('textbox', { name: 'Name' })
.should('have.value', '')
.type('John Doe')
cy.findByRole('img').should('not.have.attr', 'src')
cy.findByLabelText('Change photo').attachFile('avatar.jpg')
cy.wait('@postImage')
cy.findByRole('img')
.should('have.attr', 'src')
.should(
'include',
`https://s3.eu-west-3.amazonaws.com/typebot/users/${users[0].id}/avatar`
)
cy.findByRole('button', { name: 'Save' }).should('exist').click()
cy.wait('@getUpdatedSession')
.then((interception) => {
return interception.response?.statusCode
})
.should('eq', 200)
cy.findByRole('button', { name: 'Save' }).should('not.exist')
})
it('should display valid plans', () => {
cy.signIn(users[0].email)
cy.visit('/account')
cy.findByText('Free plan').should('exist')
cy.findByRole('link', { name: 'Manage my subscription' }).should(
'not.exist'
)
cy.findByRole('button', { name: 'Upgrade' }).should('exist')
cy.signOut()
cy.signIn(users[1].email)
cy.visit('/account')
cy.findByText('Pro plan').should('exist')
cy.findByRole('link', { name: 'Manage my subscription' })
.should('have.attr', 'href')
.should('include', 'customer-portal')
})
})

View File

@ -1,81 +0,0 @@
import { createTypebotWithStep } from 'cypress/plugins/data'
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
import { BubbleStepType, defaultImageBubbleContent, Step } from 'models'
const unsplashImageSrc =
'https://images.unsplash.com/photo-1504297050568-910d24c426d3?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1287&q=80'
describe('Image bubbles', () => {
before(() => {
cy.intercept({
url: 'https://s3.eu-west-3.amazonaws.com/typebot',
method: 'POST',
}).as('postImage')
})
describe('Content settings', () => {
beforeEach(() => {
prepareDbAndSignIn()
createTypebotWithStep({
type: BubbleStepType.IMAGE,
content: defaultImageBubbleContent,
} as Step)
cy.visit('/typebots/typebot3/edit')
cy.findByText('Click to edit...').click()
})
it('upload image file correctly', () => {
cy.findByTestId('file-upload-input').attachFile('avatar.jpg')
cy.wait('@postImage')
cy.findByRole('img')
.should('have.attr', 'src')
.should(
'include',
`https://s3.eu-west-3.amazonaws.com/typebot/typebots/typebot3/avatar.jpg`
)
})
it('should import image links correctly', () => {
cy.findByRole('button', { name: 'Embed link' }).click()
cy.findByPlaceholderText('Paste the image link...')
.clear()
.type(unsplashImageSrc)
cy.findByRole('img')
.should('have.attr', 'src')
.should('include', unsplashImageSrc)
})
it('should import giphy gifs correctly', () => {
cy.findByRole('button', { name: 'Giphy' }).click()
cy.findAllByRole('img').eq(3).click()
cy.findAllByRole('img')
.first()
.should('have.attr', 'src')
.should('contain', `giphy.com/media`)
})
})
describe('Preview', () => {
before(() => {
prepareDbAndSignIn()
createTypebotWithStep({
type: BubbleStepType.IMAGE,
content: {
url: unsplashImageSrc,
},
} as Omit<Step, 'id' | 'blockId'>)
})
it('should display correctly', () => {
cy.visit('/typebots/typebot3/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.findByRole('img')
.should('have.attr', 'src')
.should('eq', unsplashImageSrc)
})
})
})

View File

@ -1,48 +0,0 @@
import { createTypebotWithStep } from 'cypress/plugins/data'
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
import { BubbleStepType, defaultTextBubbleContent, Step } from 'models'
describe('Text bubbles', () => {
beforeEach(() => {
prepareDbAndSignIn()
createTypebotWithStep({
type: BubbleStepType.TEXT,
content: defaultTextBubbleContent,
} as Omit<Step, 'id' | 'blockId'>)
cy.visit('/typebots/typebot3/edit')
})
afterEach(removePreventReload)
it('rich text features should work', () => {
cy.findByTestId('bold-button').click()
cy.findByRole('textbox', { name: 'Text editor' }).type('Bold text{enter}')
cy.findByTestId('bold-button').click()
cy.findByTestId('italic-button').click()
cy.findByRole('textbox', { name: 'Text editor' }).type('Italic text{enter}')
cy.findByTestId('italic-button').click()
cy.findByTestId('underline-button').click()
cy.findByRole('textbox', { name: 'Text editor' }).type(
'Underlined text{enter}'
)
cy.findByTestId('bold-button').click()
cy.findByTestId('italic-button').click()
cy.findByRole('textbox', { name: 'Text editor' }).type('Everything text')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.get('span.slate-bold')
.should('exist')
.should('contain.text', 'Bold text')
getIframeBody()
.get('span.slate-italic')
.should('exist')
.should('contain.text', 'Italic text')
getIframeBody()
.get('span.slate-underline')
.should('exist')
.should('contain.text', 'Underlined text')
})
})

View File

@ -1,114 +0,0 @@
import { createTypebotWithStep } from 'cypress/plugins/data'
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
import {
BubbleStepType,
defaultVideoBubbleContent,
Step,
VideoBubbleContentType,
} from 'models'
const videoSrc =
'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4'
const youtubeVideoSrc = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
const vimeoVideoSrc = 'https://vimeo.com/649301125'
describe('Video bubbles', () => {
describe('Content settings', () => {
beforeEach(() => {
prepareDbAndSignIn()
createTypebotWithStep({
type: BubbleStepType.VIDEO,
content: defaultVideoBubbleContent,
} as Omit<Step, 'id' | 'blockId'>)
})
afterEach(removePreventReload)
it('upload image file correctly', () => {
cy.visit('/typebots/typebot3/edit')
cy.findByText('Click to edit...').click()
cy.findByPlaceholderText('Paste the video link...').type(videoSrc, {
waitForAnimations: false,
})
cy.get('video > source').should('have.attr', 'src').should('eq', videoSrc)
cy.findByPlaceholderText('Paste the video link...')
.clear()
.type(youtubeVideoSrc, {
waitForAnimations: false,
})
cy.get('iframe')
.should('have.attr', 'src')
.should('eq', 'https://www.youtube.com/embed/dQw4w9WgXcQ')
cy.findByPlaceholderText('Paste the video link...')
.clear()
.type(vimeoVideoSrc, {
waitForAnimations: false,
})
cy.get('iframe')
.should('have.attr', 'src')
.should('eq', 'https://player.vimeo.com/video/649301125')
})
})
describe('Preview', () => {
beforeEach(prepareDbAndSignIn)
afterEach(removePreventReload)
it('should display video correctly', () => {
createTypebotWithStep({
type: BubbleStepType.VIDEO,
content: {
type: VideoBubbleContentType.URL,
url: videoSrc,
},
} as Omit<Step, 'id' | 'blockId'>)
cy.visit('/typebots/typebot3/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.get('video > source')
.should('have.attr', 'src')
.should('eq', videoSrc)
})
it('should display youtube iframe correctly', () => {
createTypebotWithStep({
type: BubbleStepType.VIDEO,
content: {
type: VideoBubbleContentType.YOUTUBE,
url: youtubeVideoSrc,
id: 'dQw4w9WgXcQ',
},
} as Omit<Step, 'id' | 'blockId'>)
cy.visit('/typebots/typebot3/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.get('iframe')
.first()
.should('have.attr', 'src')
.should('eq', 'https://www.youtube.com/embed/dQw4w9WgXcQ')
})
it('should display vimeo iframe correctly', () => {
createTypebotWithStep({
type: BubbleStepType.VIDEO,
content: {
type: VideoBubbleContentType.VIMEO,
url: vimeoVideoSrc,
id: '649301125',
},
} as Omit<Step, 'id' | 'blockId'>)
cy.visit('/typebots/typebot3/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.get('iframe')
.first()
.should('have.attr', 'src')
.should('eq', 'https://player.vimeo.com/video/649301125')
})
})
})

View File

@ -1,64 +0,0 @@
import { users } from 'cypress/plugins/data'
import { prepareDbAndSignIn, removePreventReload } from 'cypress/support'
describe('Dashboard page', () => {
beforeEach(prepareDbAndSignIn)
afterEach(removePreventReload)
it('folders navigation should work', () => {
cy.signIn(users[0].email)
cy.visit('/typebots')
createFolder('My folder #1')
cy.findByTestId('folder-button').click()
cy.findByRole('heading', { name: 'My folder #1' }).should('exist')
createFolder('My folder #2')
cy.findByTestId('folder-button').click()
cy.findByRole('heading', { name: 'My folder #2' }).should('exist')
cy.findByRole('link', { name: 'Back' }).click()
cy.findByRole('heading', { name: 'My folder #1' }).should('exist')
cy.findByRole('link', { name: 'Back' }).click()
cy.findByRole('button', { name: 'Show folder menu' }).click()
cy.findByRole('menuitem', { name: 'Delete' }).click()
cy.findByRole('button', { name: 'Delete' }).click()
cy.findByDisplayValue('My folder #2').should('exist')
cy.findByRole('button', { name: 'Show folder menu' }).click()
cy.findByRole('menuitem', { name: 'Delete' }).click()
cy.findByRole('button', { name: 'Delete' }).click()
cy.findByDisplayValue('My folder #2').should('not.exist')
})
it('folders and typebots should be deletable', () => {
cy.visit('/typebots')
cy.findByText('Folder #1').should('exist')
cy.findAllByRole('button', { name: 'Show folder menu' }).first().click()
cy.findByRole('menuitem', { name: 'Delete' }).click()
cy.findByRole('button', { name: 'Delete' }).click()
cy.findByText('Folder #1').should('not.exist')
cy.findByText('Typebot #1').should('exist')
cy.findAllByRole('button', { name: 'Show typebot menu' }).first().click()
cy.findByRole('menuitem', { name: 'Delete' }).click()
cy.findByRole('button', { name: 'Delete' }).click()
cy.findByText('Typebot #1').should('not.exist')
})
it('folders should be draggable and droppable', () => {
cy.visit('/typebots')
cy.findByTestId('typebot-button-typebot1').mouseMoveBy(-100, 0, {
delay: 120,
})
cy.visit('/typebots/folders/folder1')
cy.findByTestId('typebot-button-typebot1').mouseMoveBy(-300, -100, {
delay: 120,
})
cy.visit('/typebots')
cy.findByDisplayValue('Folder #1').should('exist')
cy.findByText('Typebot #1').should('exist')
})
})
const createFolder = (folderName: string) => {
cy.findByRole('button', { name: 'Create a folder' }).click({ force: true })
cy.findByText('New folder').click({ force: true })
cy.findByDisplayValue('New folder').type(`${folderName}{enter}`)
}

View File

@ -1,63 +0,0 @@
import { createTypebotWithStep } from 'cypress/plugins/data'
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
import { defaultChoiceInputOptions, InputStepType, Step } from 'models'
describe('Button input', () => {
beforeEach(() => {
prepareDbAndSignIn()
createTypebotWithStep({
type: InputStepType.CHOICE,
options: { ...defaultChoiceInputOptions, itemIds: ['item1'] },
} as Step)
})
afterEach(removePreventReload)
it('Can edit choice items', () => {
cy.visit('/typebots/typebot3/edit')
cy.findByDisplayValue('Click to edit').type('Item 1{enter}')
cy.findByText('Item 1').trigger('mouseover')
cy.findByRole('button', { name: 'Add item' }).click()
cy.findByDisplayValue('Click to edit').type('Item 2{enter}')
cy.findByRole('button', { name: 'Add item' }).click()
cy.findByDisplayValue('Click to edit').type('Item 3{enter}')
cy.findByText('Item 2').rightclick()
cy.findByRole('menuitem', { name: 'Delete' }).click()
cy.findByText('Item 2').should('not.exist')
cy.findByTestId('step-step1').click({ force: true })
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody().findByRole('button', { name: 'Item 3' }).click()
getIframeBody().findByRole('button', { name: 'Item 3' }).should('not.exist')
getIframeBody().findByText('Item 3')
cy.findByRole('button', { name: 'Close' }).click()
cy.findByTestId('step-step1').click({ force: true })
cy.findByRole('checkbox', { name: 'Multiple choice?' }).check({
force: true,
})
cy.findByRole('textbox', { name: 'Button label:' }).clear().type('Go')
cy.wait(200)
cy.findByTestId('step-step1').click({ force: true })
cy.findByText('Item 1').trigger('mouseover')
cy.findByRole('button', { name: 'Add item' }).click()
cy.findByDisplayValue('Click to edit').type('Item 2{enter}')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody().findByRole('checkbox', { name: 'Item 3' }).click()
getIframeBody().findByRole('checkbox', { name: 'Item 1' }).click()
getIframeBody().findByRole('button', { name: 'Go' }).click()
getIframeBody().findByText('Item 3, Item 1').should('exist')
})
it('Single choice targets should work', () => {
cy.loadTypebotFixtureInDatabase('typebots/singleChoiceTarget.json')
cy.visit('/typebots/typebot4/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody().findByRole('button', { name: 'Burgers' }).click()
getIframeBody().findByText('I love burgers!').should('exist')
cy.findByRole('button', { name: 'Restart' }).click()
getIframeBody().findByRole('button', { name: 'Carpaccio' }).click()
getIframeBody().findByText('Cool!').should('exist')
})
})

View File

@ -1,44 +0,0 @@
import { createTypebotWithStep } from 'cypress/plugins/data'
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
import { defaultDateInputOptions, InputStepType, Step } from 'models'
describe('Date input', () => {
beforeEach(() => {
prepareDbAndSignIn()
createTypebotWithStep({
type: InputStepType.DATE,
options: defaultDateInputOptions,
} as Step)
})
afterEach(removePreventReload)
it('options should work', () => {
cy.visit('/typebots/typebot3/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.findByTestId('from-date')
.should('have.attr', 'type')
.should('eq', 'date')
getIframeBody().findByRole('button', { name: 'Send' }).should('be.disabled')
cy.findByTestId('step-step1').click({ force: true })
cy.findByRole('checkbox', { name: 'Is range?' }).check({ force: true })
cy.findByRole('textbox', { name: 'From label:' }).clear().type('Previous:')
cy.findByRole('textbox', { name: 'To label:' }).clear().type('After:')
cy.findByRole('checkbox', { name: 'With time?' }).check({ force: true })
cy.findByRole('textbox', { name: 'Button label:' }).clear().type('Go')
cy.findByRole('button', { name: 'Restart' }).click()
getIframeBody()
.findByTestId('from-date')
.should('have.attr', 'type')
.should('eq', 'datetime-local')
getIframeBody()
.findByTestId('to-date')
.should('have.attr', 'type')
.should('eq', 'datetime-local')
getIframeBody().findByRole('button', { name: 'Go' })
})
})

View File

@ -1,41 +0,0 @@
import { createTypebotWithStep } from 'cypress/plugins/data'
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
import { defaultEmailInputOptions, InputStepType, Step } from 'models'
describe('Email input', () => {
beforeEach(() => {
prepareDbAndSignIn()
createTypebotWithStep({
type: InputStepType.EMAIL,
options: defaultEmailInputOptions,
} as Step)
})
afterEach(removePreventReload)
it('options should work', () => {
cy.visit('/typebots/typebot3/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.findByPlaceholderText('Type your email...')
.should('have.attr', 'type')
.should('equal', 'email')
getIframeBody().findByRole('button', { name: 'Send' })
getIframeBody()
.findByPlaceholderText(defaultEmailInputOptions.labels.placeholder)
.should('exist')
getIframeBody().findByRole('button', { name: 'Send' }).should('be.disabled')
cy.findByTestId('step-step1').click({ force: true })
cy.findByRole('textbox', { name: 'Placeholder:' })
.clear()
.type('Your email...')
cy.findByRole('textbox', { name: 'Button label:' }).clear().type('Go')
cy.findByTestId('step-step1').should('contain.text', 'Your email...')
cy.findByRole('button', { name: 'Restart' }).click()
getIframeBody().findByPlaceholderText('Your email...').should('exist')
getIframeBody().findByRole('button', { name: 'Go' })
})
})

View File

@ -1,46 +0,0 @@
import { createTypebotWithStep } from 'cypress/plugins/data'
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
import { defaultNumberInputOptions, InputStepType, Step } from 'models'
describe('Number input', () => {
beforeEach(() => {
prepareDbAndSignIn()
createTypebotWithStep({
type: InputStepType.NUMBER,
options: defaultNumberInputOptions,
} as Step)
})
afterEach(removePreventReload)
it('options should work', () => {
cy.visit('/typebots/typebot3/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.findByPlaceholderText(defaultNumberInputOptions.labels.placeholder)
.should('have.attr', 'type')
.should('equal', 'number')
getIframeBody().findByRole('button', { name: 'Send' }).should('be.disabled')
cy.findByTestId('step-step1').click({ force: true })
cy.findByRole('textbox', { name: 'Placeholder:' })
.clear()
.type('Your name...')
cy.findByRole('textbox', { name: 'Button label:' }).clear().type('Go')
cy.findByRole('spinbutton', { name: 'Min:' }).type('0')
cy.findByRole('spinbutton', { name: 'Max:' }).type('100')
cy.findByRole('spinbutton', { name: 'Step:' }).type('10')
cy.findByRole('button', { name: 'Restart' }).click()
cy.findByTestId('step-step1').should('contain.text', 'Your name...')
getIframeBody()
.findByPlaceholderText('Your name...')
.should('exist')
.type('-1{enter}')
.clear()
.type('150{enter}')
getIframeBody().findByRole('button', { name: 'Go' })
cy.findByTestId('step-step1').click({ force: true })
})
})

View File

@ -1,43 +0,0 @@
import { createTypebotWithStep } from 'cypress/plugins/data'
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
import { defaultPhoneInputOptions, InputStepType, Step } from 'models'
describe('Phone number input', () => {
beforeEach(() => {
prepareDbAndSignIn()
createTypebotWithStep({
type: InputStepType.PHONE,
options: defaultPhoneInputOptions,
} as Step)
})
afterEach(removePreventReload)
it('options should work', () => {
cy.visit('/typebots/typebot3/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.findByPlaceholderText(defaultPhoneInputOptions.labels.placeholder)
.should('have.attr', 'type')
.should('eq', 'tel')
getIframeBody().findByRole('button', { name: 'Send' }).should('be.disabled')
cy.findByTestId('step-step1').click({ force: true })
cy.findByRole('textbox', { name: 'Placeholder:' })
.clear()
.type('+33 XX XX XX XX')
cy.findByRole('textbox', { name: 'Button label:' }).clear().type('Go')
cy.findByRole('button', { name: 'Restart' }).click()
getIframeBody()
.findByPlaceholderText('+33 XX XX XX XX')
.type('+33 6 73 18 45 36')
getIframeBody()
.findByRole('img')
.should('have.attr', 'alt')
.should('eq', 'France')
getIframeBody().findByRole('button', { name: 'Go' }).click()
getIframeBody().findByText('+33673184536').should('exist')
})
})

View File

@ -1,41 +0,0 @@
import { createTypebotWithStep } from 'cypress/plugins/data'
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
import { defaultTextInputOptions, InputStepType, Step } from 'models'
describe('Text input', () => {
beforeEach(() => {
prepareDbAndSignIn()
createTypebotWithStep({
type: InputStepType.TEXT,
options: defaultTextInputOptions,
} as Step)
})
afterEach(removePreventReload)
it('options should work', () => {
cy.visit('/typebots/typebot3/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.findByPlaceholderText(defaultTextInputOptions.labels.placeholder)
.should('have.attr', 'type')
.should('equal', 'text')
getIframeBody().findByRole('button', { name: 'Send' }).should('be.disabled')
cy.findByTestId('step-step1').click({ force: true })
cy.findByRole('textbox', { name: 'Placeholder:' })
.clear()
.type('Your name...')
cy.findByRole('textbox', { name: 'Button label:' }).clear().type('Go')
cy.findByRole('button', { name: 'Restart' }).click()
cy.findByTestId('step-step1').should('contain.text', 'Your name...')
getIframeBody().findByPlaceholderText('Your name...').should('exist')
getIframeBody().findByRole('button', { name: 'Go' })
cy.findByTestId('step-step1').click({ force: true })
cy.findByRole('checkbox', { name: 'Long text?' }).check({ force: true })
cy.findByRole('button', { name: 'Restart' }).click()
getIframeBody().findByTestId('textarea').should('exist')
})
})

View File

@ -1,38 +0,0 @@
import { createTypebotWithStep } from 'cypress/plugins/data'
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
import { defaultUrlInputOptions, InputStepType, Step } from 'models'
describe('URL input', () => {
afterEach(removePreventReload)
beforeEach(() => {
prepareDbAndSignIn()
createTypebotWithStep({
type: InputStepType.URL,
options: defaultUrlInputOptions,
} as Step)
})
it('options should work', () => {
cy.visit('/typebots/typebot3/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.findByPlaceholderText(defaultUrlInputOptions.labels.placeholder)
.should('have.attr', 'type')
.should('eq', 'url')
getIframeBody().findByRole('button', { name: 'Send' }).should('be.disabled')
cy.findByTestId('step-step1').click({ force: true })
cy.findByRole('textbox', { name: 'Placeholder:' })
.clear()
.type('Your URL...')
cy.findByRole('textbox', { name: 'Button label:' }).clear().type('Go')
cy.findByTestId('step-step1').should('contain.text', 'Your URL...')
cy.findByRole('button', { name: 'Restart' }).click()
getIframeBody().findByPlaceholderText('Your URL...').should('exist')
getIframeBody().findByRole('button', { name: 'Go' })
})
})

View File

@ -1,37 +0,0 @@
import { createTypebotWithStep } from 'cypress/plugins/data'
import { prepareDbAndSignIn, removePreventReload } from 'cypress/support'
import {
defaultGoogleAnalyticsOptions,
IntegrationStepType,
Step,
} from 'models'
describe('Google Analytics', () => {
afterEach(removePreventReload)
beforeEach(() => {
prepareDbAndSignIn()
createTypebotWithStep({
type: IntegrationStepType.GOOGLE_ANALYTICS,
options: defaultGoogleAnalyticsOptions,
} as Step)
})
it('can be filled correctly', () => {
cy.visit('/typebots/typebot3/edit')
cy.intercept({
url: '/g/collect',
method: 'POST',
}).as('gaRequest')
cy.findByTestId('step-step1').click()
cy.findByRole('textbox', { name: 'Tracking ID:' }).type('G-VWX9WG1TNS')
cy.findByRole('textbox', { name: 'Event category:' }).type('Typebot')
cy.findByRole('textbox', { name: 'Event action:' }).type('Submit email')
cy.findByRole('button', { name: 'Advanced' }).click()
cy.findByRole('textbox', { name: 'Event label Optional :' }).type(
'Campaign Z'
)
cy.findByRole('textbox', { name: 'Event value Optional :' }).type('20')
// Not sure how to test if GA integration works correctly in the preview tab
})
})

View File

@ -1,133 +0,0 @@
import { users } from 'cypress/plugins/data'
import { getIframeBody, removePreventReload } from 'cypress/support'
describe('Google sheets', () => {
afterEach(removePreventReload)
beforeEach(() => {
cy.signOut()
cy.task('seed', Cypress.env('GOOGLE_SHEETS_REFRESH_TOKEN'))
cy.signIn(users[1].email)
})
it('Insert row should work', () => {
cy.intercept({
url: '/api/integrations/google-sheets/spreadsheets/1k_pIDw3YHl9tlZusbBVSBRY0PeRPd2H6t4Nj7rwnOtM/sheets/0',
method: 'POST',
}).as('insertRowInGoogleSheets')
cy.loadTypebotFixtureInDatabase('typebots/integrations/googleSheets.json')
cy.visit('/typebots/typebot4/edit')
fillInSpreadsheetInfo()
cy.findByRole('button', { name: 'Select an operation' }).click()
cy.findByRole('menuitem', { name: 'Insert a row' }).click({ force: true })
cy.findByRole('button', { name: 'Select a column' }).click()
cy.findByRole('menuitem', { name: 'Email' }).click()
cy.findByRole('button', { name: 'Insert a variable' }).click()
cy.findByRole('menuitem', { name: 'Email' }).click()
cy.findByRole('button', { name: 'Add a value' }).click()
cy.findByRole('button', { name: 'Select a column' }).click()
cy.findByRole('menuitem', { name: 'First name' }).click()
cy.findAllByPlaceholderText('Type a value...').last().type('Georges')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.findByPlaceholderText('Type your email...')
.type('georges@gmail.com{enter}')
cy.wait('@insertRowInGoogleSheets')
.then((interception) => {
return interception.response?.statusCode
})
.should('eq', 200)
})
it('Update row should work', () => {
cy.intercept({
url: '/api/integrations/google-sheets/spreadsheets/1k_pIDw3YHl9tlZusbBVSBRY0PeRPd2H6t4Nj7rwnOtM/sheets/0',
method: 'PATCH',
}).as('updateRowInGoogleSheets')
cy.loadTypebotFixtureInDatabase('typebots/integrations/googleSheets.json')
cy.visit('/typebots/typebot4/edit')
fillInSpreadsheetInfo()
cy.findByRole('button', { name: 'Select an operation' }).click()
cy.findByRole('menuitem', { name: 'Update a row' }).click({ force: true })
cy.findAllByRole('button', { name: 'Select a column' }).first().click()
cy.findByRole('menuitem', { name: 'Email' }).click()
cy.findAllByRole('button', { name: 'Insert a variable' }).first().click()
cy.findByRole('menuitem', { name: 'Email' }).click()
cy.findByRole('button', { name: 'Select a column' }).click()
cy.findByRole('menuitem', { name: 'Last name' }).click()
cy.findAllByPlaceholderText('Type a value...').last().type('Last name')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.findByPlaceholderText('Type your email...')
.type('test@test.com{enter}')
cy.wait('@updateRowInGoogleSheets')
.then((interception) => {
return interception.response?.statusCode
})
.should('eq', 200)
})
it('Get row should work', () => {
cy.loadTypebotFixtureInDatabase(
'typebots/integrations/googleSheetsGet.json'
)
cy.visit('/typebots/typebot4/edit')
fillInSpreadsheetInfo()
cy.findByRole('button', { name: 'Select an operation' }).click()
cy.findByRole('menuitem', { name: 'Get data from sheet' }).click({
force: true,
})
cy.findAllByRole('button', { name: 'Select a column' }).first().click()
cy.findByRole('menuitem', { name: 'Email' }).click()
cy.findByRole('button', { name: 'Insert a variable' }).click()
cy.findByRole('menuitem', { name: 'Email' }).click()
cy.findByRole('button', { name: 'Select a column' }).click()
cy.findByRole('menuitem', { name: 'First name' }).click()
createNewVar('First name')
cy.findByRole('button', { name: 'Add a value' }).click()
cy.findByRole('button', { name: 'Select a column' }).click()
cy.findByRole('menuitem', { name: 'Last name' }).click()
createNewVar('Last name')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.findByPlaceholderText('Type your email...')
.type('test2@test.com{enter}')
getIframeBody().findByText('Your name is: John Smith').should('exist')
})
})
const fillInSpreadsheetInfo = () => {
cy.findByText('Configure...').click()
cy.findByRole('button', { name: 'Select an account' }).click()
cy.findByRole('menuitem', { name: 'test2@gmail.com' }).click()
cy.findByPlaceholderText('Search for spreadsheet').type('CR')
cy.findByRole('menuitem', { name: 'CRM' }).click()
cy.findByPlaceholderText('Select the sheet').type('Sh')
cy.findByRole('menuitem', { name: 'Sheet1' }).click()
}
const createNewVar = (name: string) => {
cy.findAllByTestId('variables-input').last().type(name)
cy.findByRole('menuitem', { name: `Create "${name}"` }).click()
}

View File

@ -1,90 +0,0 @@
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
describe('Webhook step', () => {
beforeEach(prepareDbAndSignIn)
afterEach(removePreventReload)
describe('Configuration', () => {
it('configuration is working', () => {
cy.loadTypebotFixtureInDatabase('typebots/integrations/webhook.json')
cy.visit('/typebots/typebot4/edit')
cy.findByText('Configure...').click()
cy.findByRole('button', { name: 'GET' }).click()
cy.findByRole('menuitem', { name: 'POST' }).click({ force: true })
cy.findByPlaceholderText('Your Webhook URL...').type(
`${Cypress.env('SITE_NAME')}/api/mock/webhook`
)
cy.findByRole('button', { name: 'Query params' }).click()
cy.findByRole('textbox', { name: 'Key:' }).type('firstParam')
cy.findByRole('textbox', { name: 'Value:' }).type('{{secret 1}}', {
parseSpecialCharSequences: false,
})
cy.findByRole('button', { name: 'Add a param' }).click()
cy.findAllByRole('textbox', { name: 'Key:' }).last().type('secondParam')
cy.findAllByRole('textbox', { name: 'Value:' })
.last()
.type('{{secret 2}}', {
parseSpecialCharSequences: false,
})
cy.findByRole('button', { name: 'Headers' }).click()
cy.findAllByRole('textbox', { name: 'Key:' })
.last()
.type('Custom-Typebot')
cy.findAllByRole('textbox', { name: 'Value:' })
.last()
.type('{{secret 3}}', {
parseSpecialCharSequences: false,
})
cy.findByRole('button', { name: 'Body' }).click()
cy.findByTestId('code-editor').type('{ "customField": "{{secret 4}}" }', {
parseSpecialCharSequences: false,
waitForAnimations: false,
})
cy.findByRole('button', { name: 'Variable values for test' }).click()
addTestVariable('secret 1', 'secret1')
cy.findByRole('button', { name: 'Add an entry' }).click()
addTestVariable('secret 2', 'secret2')
cy.findByRole('button', { name: 'Add an entry' }).click()
addTestVariable('secret 3', 'secret3')
cy.findByRole('button', { name: 'Add an entry' }).click()
addTestVariable('secret 4', 'secret4')
cy.findByRole('button', { name: 'Test the request' }).click()
cy.findAllByTestId('code-editor')
.should('have.length', 2)
.last()
.should('contain.text', '"statusCode": 200')
cy.findByRole('button', { name: 'Save in variables' }).click()
cy.findByPlaceholderText('Select the data').click()
cy.findByRole('menuitem', { name: 'data[0].name' }).click()
})
})
describe('Preview', () => {
it('should correctly send the request', () => {
cy.loadTypebotFixtureInDatabase(
'typebots/integrations/webhookPreview.json'
)
cy.visit('/typebots/typebot4/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody().findByRole('button', { name: 'Go' }).click()
getIframeBody().findByText('His name is John').should('exist')
})
})
})
const addTestVariable = (name: string, value: string) => {
cy.findAllByTestId('variables-input').last().click()
cy.findByRole('menuitem', { name }).click()
cy.findAllByRole('textbox', { name: 'Test value:' }).last().type(value)
}

View File

@ -1,57 +0,0 @@
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
describe('Condition step', () => {
beforeEach(prepareDbAndSignIn)
afterEach(removePreventReload)
it('options should work', () => {
cy.loadTypebotFixtureInDatabase('typebots/logic/condition.json')
cy.visit('/typebots/typebot4/edit')
cy.findAllByText('Equal to').first().click()
cy.findByTestId('variables-input').click()
cy.findByRole('menuitem', { name: 'Age' }).click()
cy.findByRole('button', { name: 'Select an operator' }).click()
cy.findByRole('menuitem', { name: 'Greater than' }).click()
cy.findByPlaceholderText('Type a value...').type('80')
cy.findByRole('button', { name: 'Add a comparison' }).click()
cy.findAllByTestId('variables-input').last().click()
cy.findByRole('menuitem', { name: 'Age' }).click()
cy.findByRole('button', { name: 'Select an operator' }).click()
cy.findByRole('menuitem', { name: 'Less than' }).click()
cy.findAllByPlaceholderText('Type a value...').last().type('100')
cy.findAllByText('Equal to').last().click()
cy.findByTestId('variables-input').click()
cy.findByRole('menuitem', { name: 'Age' }).click()
cy.findByRole('button', { name: 'Select an operator' }).click()
cy.findByRole('menuitem', { name: 'Greater than' }).click()
cy.findByPlaceholderText('Type a value...').type('20')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody()
.findByPlaceholderText('Type your answer...')
.type('15{enter}')
getIframeBody().findByText('You are younger than 20').should('exist')
cy.findByRole('button', { name: 'Restart' }).click()
getIframeBody()
.findByPlaceholderText('Type your answer...')
.type('45{enter}')
getIframeBody().findByText('You are older than 20').should('exist')
cy.findByRole('button', { name: 'Restart' }).click()
getIframeBody()
.findByPlaceholderText('Type your answer...')
.type('90{enter}')
getIframeBody().findByText('You are older than 80').should('exist')
})
})

View File

@ -1,40 +0,0 @@
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
describe('Redirect', () => {
beforeEach(prepareDbAndSignIn)
afterEach(removePreventReload)
it('should redirect to URL correctly', () => {
cy.loadTypebotFixtureInDatabase('typebots/logic/redirect.json')
cy.visit('/typebots/typebot4/edit')
cy.findByText('Configure...').click()
cy.findByPlaceholderText('Type a URL...').type('google.com')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody().findByRole('button', { name: 'Go to URL' }).click()
cy.url().should('eq', 'https://www.google.com/')
cy.go('back')
cy.window().then((win) => {
cy.stub(win, 'open').as('open')
})
cy.findByText('Redirect to google.com').click()
cy.findByRole('checkbox', { name: 'Open in new tab?' }).check({
force: true,
})
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody().findByRole('button', { name: 'Go to URL' }).click()
cy.get('@open').should(
'have.been.calledOnceWithExactly',
'https://google.com',
'_blank'
)
})
})

View File

@ -1,33 +0,0 @@
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
describe('Set variables', () => {
beforeEach(prepareDbAndSignIn)
afterEach(removePreventReload)
it.only('options should work', () => {
cy.loadTypebotFixtureInDatabase('typebots/logic/setVariable.json')
cy.visit('/typebots/typebot4/edit')
cy.findByText('Type a number...').click()
cy.createVariable('Num')
cy.findAllByText('Click to edit...').first().click()
cy.createVariable('Total')
cy.findByRole('textbox', { name: 'Value / Expression:' }).type(
'1000 * {{Num}}',
{ parseSpecialCharSequences: false }
)
cy.findAllByText('Click to edit...').last().click()
cy.createVariable('Custom var')
cy.findByRole('textbox', { name: 'Value / Expression:' }).type(
'Custom value'
)
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody().findByPlaceholderText('Type a number...').type('365{enter}')
getIframeBody().findByText('Total: 365000').should('exist')
getIframeBody().findByText('Custom var: Custom value')
})
})

View File

@ -1,88 +0,0 @@
import path from 'path'
import { parse } from 'papaparse'
import { prepareDbAndSignIn, removePreventReload } from 'cypress/support'
const downloadsFolder = Cypress.config('downloadsFolder')
describe('Results page', () => {
beforeEach(() => {
prepareDbAndSignIn()
cy.intercept({ url: '/api/typebots/typebot2/results*', method: 'GET' }).as(
'getResults'
)
})
afterEach(removePreventReload)
it('results should be deletable', () => {
cy.visit('/typebots/typebot2/results')
cy.findByText('content198').should('exist')
cy.findByText('content197').should('exist')
cy.findAllByRole('checkbox').eq(2).check({ force: true })
cy.findAllByRole('checkbox').eq(3).check({ force: true })
cy.findByRole('button', { name: 'Delete 2' }).click({ force: true })
cy.findByRole('button', { name: 'Delete' }).click()
cy.findByText('content198').should('not.exist')
cy.findByText('content197').should('not.exist')
cy.wait(200)
cy.findAllByRole('checkbox').first().check({ force: true })
cy.findByRole('button', { name: 'Delete 198' }).click({ force: true })
cy.findByRole('button', { name: 'Delete' }).click()
cy.findAllByRole('row').should('have.length', 1)
})
it('submissions table should have infinite scroll', () => {
cy.visit('/typebots/typebot2/results')
cy.findByText('content50').should('not.exist')
cy.findByText('content199').should('exist')
cy.findByTestId('table-wrapper').scrollTo('bottom')
cy.findByText('content149', { timeout: 10000 }).should('exist')
cy.findByTestId('table-wrapper').scrollTo('bottom')
cy.findByText('content99').should('exist')
cy.findByTestId('table-wrapper').scrollTo('bottom')
cy.findByText('content50').should('exist')
cy.findByText('content0').should('exist')
})
it('should correctly export selection in CSV', () => {
cy.visit('/typebots/typebot2/results')
cy.wait('@getResults')
cy.findByRole('button', { name: 'Export' }).should('not.exist')
cy.findByText('content199').should('exist')
cy.findAllByRole('checkbox').eq(2).check({ force: true })
cy.findAllByRole('checkbox').eq(3).check({ force: true })
cy.findByRole('button', { name: 'Export 2' }).click({ force: true })
const filename = path.join(
downloadsFolder,
`typebot-export_${new Date()
.toLocaleDateString()
.replaceAll('/', '-')}.csv`
)
cy.readFile(filename, { timeout: 15000 })
.then(parse)
.then(validateExportSelection as any)
cy.findAllByRole('checkbox').first().check({ force: true })
cy.findByRole('button', { name: 'Export 200' }).click({ force: true })
const filenameAll = path.join(
downloadsFolder,
`typebot-export_${new Date()
.toLocaleDateString()
.replaceAll('/', '-')}_all.csv`
)
cy.readFile(filenameAll, { timeout: 15000 })
.then(parse)
.then(validateExportAll as any)
})
})
const validateExportSelection = (list: { data: unknown[][] }) => {
expect(list.data, 'number of records').to.have.length(3)
expect(list.data[1][1], 'first record').to.equal('content198')
expect(list.data[2][1], 'second record').to.equal('content197')
}
const validateExportAll = (list: { data: unknown[][] }) => {
expect(list.data, 'number of records').to.have.length(201)
expect(list.data[1][1], 'first record').to.equal('content199')
expect(list.data[200][1], 'second record').to.equal('content0')
}

View File

@ -1,27 +0,0 @@
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
describe('General settings', () => {
beforeEach(prepareDbAndSignIn)
afterEach(removePreventReload)
it('should reflect changes in real time', () => {
cy.loadTypebotFixtureInDatabase('typebots/theme/theme.json')
cy.visit('/typebots/typebot4/settings')
getIframeBody()
.findByRole('link', { name: 'Made with Typebot.' })
.should('have.attr', 'href')
.should('eq', 'https://www.typebot.io/?utm_source=litebadge')
cy.findByRole('button', { name: 'General' }).click()
cy.findByRole('checkbox', { name: 'Typebot.io branding' }).uncheck({
force: true,
})
getIframeBody()
.findByRole('link', { name: 'Made with Typebot.' })
.should('not.exist')
})
})

View File

@ -1,51 +0,0 @@
import { prepareDbAndSignIn, removePreventReload } from 'cypress/support'
const favIconUrl = 'https://www.baptistearno.com/favicon.png'
const imageUrl = 'https://www.baptistearno.com/images/site-preview.png'
describe('Typing emulation', () => {
beforeEach(prepareDbAndSignIn)
afterEach(removePreventReload)
it('should reflect changes in real time', () => {
cy.loadTypebotFixtureInDatabase('typebots/theme/theme.json')
cy.visit('/typebots/typebot4/settings')
cy.findByRole('button', { name: 'Metadata' }).click()
// Fav icon
cy.findAllByRole('img', { name: 'Fav icon' })
.click()
.should('have.attr', 'src')
.should('eq', '/favicon.png')
cy.findByRole('button', { name: 'Giphy' }).should('not.exist')
cy.findByRole('button', { name: 'Embed link' }).click()
cy.findByPlaceholderText('Paste the image link...').type(favIconUrl)
cy.findAllByRole('img', { name: 'Fav icon' })
.should('have.attr', 'src')
.should('eq', favIconUrl)
// Image
cy.findAllByRole('img', { name: 'Website image' })
.click()
.should('have.attr', 'src')
.should('eq', '/viewer-preview.png')
cy.findByRole('button', { name: 'Giphy' }).should('not.exist')
cy.findByRole('button', { name: 'Embed link' }).click()
cy.findByPlaceholderText('Paste the image link...').type(imageUrl)
cy.findAllByRole('img', { name: 'Website image' })
.should('have.attr', 'src')
.should('eq', imageUrl)
// Title
cy.findByRole('textbox', { name: 'Title:' })
.click({ force: true })
.clear()
.type('Awesome typebot')
// Description
cy.findByRole('textbox', { name: 'Description:' })
.clear()
.type('Lorem ipsum')
})
})

View File

@ -1,20 +0,0 @@
import { prepareDbAndSignIn, removePreventReload } from 'cypress/support'
describe('Typing emulation', () => {
beforeEach(prepareDbAndSignIn)
afterEach(removePreventReload)
it('should reflect changes in real time', () => {
cy.loadTypebotFixtureInDatabase('typebots/theme/theme.json')
cy.visit('/typebots/typebot4/settings')
cy.findByRole('button', { name: 'Typing emulation' }).click()
cy.findByTestId('speed').clear().type('350')
cy.findByTestId('max-delay').clear().type('1.5')
cy.findByRole('checkbox', { name: 'Typing emulation' }).uncheck({
force: true,
})
cy.findByTestId('speed').should('not.exist')
cy.findByTestId('max-delay').should('not.exist')
})
})

View File

@ -1,98 +0,0 @@
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
describe('General theme settings', () => {
beforeEach(prepareDbAndSignIn)
afterEach(removePreventReload)
it('should reflect changes in real time', () => {
cy.loadTypebotFixtureInDatabase('typebots/theme/theme.json')
cy.visit('/typebots/typebot4/theme')
getIframeBody().findByText('Ready?').should('exist')
cy.findByRole('button', { name: 'Chat' }).click()
// Host bubbles
cy.findAllByRole('button', { name: 'Pick a color' }).first().click()
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#2a9d8f')
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(1)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#ffffff')
getIframeBody()
.findByTestId('host-bubble')
.should('have.css', 'background-color')
.should('eq', 'rgb(42, 157, 143)')
getIframeBody()
.findByTestId('host-bubble')
.should('have.css', 'color')
.should('eq', 'rgb(255, 255, 255)')
// Buttons
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(4)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#7209b7')
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(5)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#e9c46a')
getIframeBody()
.findByTestId('button')
.should('have.css', 'background-color')
.should('eq', 'rgb(114, 9, 183)')
getIframeBody()
.findByTestId('button')
.should('have.css', 'color')
.should('eq', 'rgb(233, 196, 106)')
// Guest bubbles
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(2)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#d8f3dc')
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(3)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#264653')
getIframeBody().findByRole('button', { name: 'Go' }).click()
getIframeBody()
.findByTestId('guest-bubble')
.should('have.css', 'background-color')
.should('eq', 'rgb(216, 243, 220)')
getIframeBody()
.findByTestId('guest-bubble')
.should('have.css', 'color')
.should('eq', 'rgb(38, 70, 83)')
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(3)
.click({ force: true })
// Input
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(6)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' })
.clear({ force: true })
.type('#ffe8d6')
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(7)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#023e8a')
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(8)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' }).clear().type('red')
getIframeBody()
.findByTestId('input')
.should('have.css', 'background-color')
.should('eq', 'rgb(255, 232, 214)')
getIframeBody()
.findByTestId('input')
.should('have.css', 'color')
.should('eq', 'rgb(2, 62, 138)')
})
})

View File

@ -1,28 +0,0 @@
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
describe('Custom CSS settings', () => {
beforeEach(prepareDbAndSignIn)
afterEach(removePreventReload)
it('should reflect changes in real time', () => {
cy.loadTypebotFixtureInDatabase('typebots/theme/theme.json')
cy.visit('/typebots/typebot4/theme')
cy.findByRole('button', { name: 'Custom CSS' }).click()
cy.findByTestId('code-editor').type(
'.typebot-button {background-color: green}',
{
parseSpecialCharSequences: false,
}
)
getIframeBody()
.findByTestId('button')
.should('have.css', 'background-color')
.should('eq', 'rgb(0, 128, 0)')
})
})

View File

@ -1,38 +0,0 @@
import {
getIframeBody,
prepareDbAndSignIn,
removePreventReload,
} from 'cypress/support'
describe('General theme settings', () => {
beforeEach(prepareDbAndSignIn)
afterEach(removePreventReload)
it('should reflect changes in real time', () => {
cy.loadTypebotFixtureInDatabase('typebots/theme/theme.json')
cy.visit('/typebots/typebot4/theme')
cy.findByRole('button', { name: 'General' }).click()
// Font
cy.findByDisplayValue('Open Sans').clear().type('Roboto')
cy.findByRole('menuitem', { name: 'Roboto Slab' }).click()
getIframeBody()
.findByTestId('container')
.should('have.css', 'font-family')
.should('eq', '"Roboto Slab"')
// BG color
getIframeBody()
.findByTestId('container')
.should('have.css', 'background-color')
.should('eq', 'rgba(0, 0, 0, 0)')
cy.findByDisplayValue('Color').check({ force: true })
cy.findByRole('button', { name: 'Pick a color' }).click()
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#2a9d8f')
getIframeBody()
.findByTestId('container')
.should('have.css', 'background-color')
.should('eq', 'rgb(42, 157, 143)')
})
})

View File

@ -1,15 +0,0 @@
{
"extends": "../tsconfig.json",
"include": ["**/*.ts"],
"exclude": [],
"compilerOptions": {
"types": ["cypress", "@testing-library/cypress", "cypress-file-upload"],
"lib": ["es2015", "dom", "ES2021.String"],
"target": "es5",
"isolatedModules": false,
"allowJs": true,
"noEmit": true,
"downlevelIteration": true,
"jsx": "react"
}
}

View File

@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "cypress run"
"test": "yarn playwright test",
"test:open": "PWDEBUG=1 yarn playwright test"
},
"dependencies": {
"@chakra-ui/css-reset": "^1.1.1",
@ -16,8 +17,6 @@
"@codemirror/lang-css": "^0.19.3",
"@codemirror/lang-json": "^0.19.1",
"@codemirror/text": "^0.19.6",
"@dnd-kit/core": "^4.0.3",
"@dnd-kit/sortable": "^5.1.0",
"@emotion/react": "^11.7.1",
"@emotion/styled": "^11.6.0",
"@giphy/js-fetch-api": "^4.1.2",
@ -72,6 +71,7 @@
"utils": "*"
},
"devDependencies": {
"@playwright/test": "^1.18.0",
"@testing-library/cypress": "^8.0.2",
"@types/google-spreadsheet": "^3.1.5",
"@types/micro-cors": "^0.1.2",

View File

@ -9,6 +9,7 @@ import 'assets/styles/plate.css'
import 'focus-visible/dist/focus-visible'
import 'assets/styles/submissionsTable.css'
import 'assets/styles/codeMirror.css'
import 'assets/styles/custom.css'
import { UserContext } from 'contexts/UserContext'
import { TypebotContext } from 'contexts/TypebotContext'
import { useRouter } from 'next/router'

View File

@ -56,7 +56,7 @@ if (process.env.NODE_ENV !== 'production')
email: {
label: 'Email',
type: 'email',
placeholder: 'email@email.com',
placeholder: 'credentials@email.com',
},
},
async authorize(credentials) {

View File

@ -20,6 +20,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
ownerId: user.id,
parentFolderId,
},
orderBy: { createdAt: 'desc' },
})
return res.send({ folders })
}

View File

@ -3,17 +3,17 @@ import { Stack } from '@chakra-ui/layout'
import { DashboardHeader } from 'components/dashboard/DashboardHeader'
import { Seo } from 'components/Seo'
import { FolderContent } from 'components/dashboard/FolderContent'
import { UserContext } from 'contexts/UserContext'
import { TypebotDndContext } from 'contexts/TypebotDndContext'
const DashboardPage = () => {
return (
<UserContext>
<Stack minH="100vh">
<Seo title="My typebots" />
<Stack>
<DashboardHeader />
<DashboardHeader />
<TypebotDndContext>
<FolderContent folder={null} />
</Stack>
</UserContext>
</TypebotDndContext>
</Stack>
)
}

View File

@ -13,7 +13,7 @@ const TypebotEditPage = () => (
<Seo title="Editor" />
<KBarProvider actions={actions}>
<KBar />
<Flex overflow="hidden" h="100vh" flexDir="column">
<Flex overflow="hidden" h="100vh" flexDir="column" id="editor-container">
<TypebotHeader />
<Board />
</Flex>

View File

@ -6,6 +6,7 @@ import { FolderContent } from 'components/dashboard/FolderContent'
import { useRouter } from 'next/router'
import { useFolderContent } from 'services/folders'
import { Spinner, useToast } from '@chakra-ui/react'
import { TypebotDndContext } from 'contexts/TypebotDndContext'
const FolderPage = () => {
const router = useRouter()
@ -26,16 +27,18 @@ const FolderPage = () => {
})
return (
<Stack>
<Stack minH="100vh">
<Seo title="My typebots" />
<DashboardHeader />
{!folder ? (
<Flex flex="1">
<Spinner mx="auto" />
</Flex>
) : (
<FolderContent folder={folder} />
)}
<TypebotDndContext>
{!folder ? (
<Flex flex="1">
<Spinner mx="auto" />
</Flex>
) : (
<FolderContent folder={folder} />
)}
</TypebotDndContext>
</Stack>
)
}

View File

@ -0,0 +1,31 @@
import { devices, PlaywrightTestConfig } from '@playwright/test'
import path from 'path'
const config: PlaywrightTestConfig = {
globalSetup: require.resolve(path.join(__dirname, 'playwright/global-setup')),
testDir: path.join(__dirname, 'playwright/tests'),
timeout: 10 * 1000,
expect: {
timeout: 5000,
},
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
actionTimeout: 0,
baseURL: process.env.NEXTAUTH_URL,
trace: 'on-first-retry',
storageState: path.join(__dirname, 'playwright/authenticatedState.json'),
video: 'retain-on-failure',
},
outputDir: path.join(__dirname, 'playwright/test-results/'),
projects: [
{
name: 'Chrome',
use: {
...devices['Desktop Chrome'],
},
},
],
}
export default config

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Some files were not shown because too many files have changed in this diff Show More