feat(integration): ✨ Add Google Sheets integration
This commit is contained in:
@@ -243,3 +243,10 @@ export const FilterIcon = (props: IconProps) => (
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const UserIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
@@ -67,3 +67,180 @@ export const FacebookLogo = (props: IconProps) => (
|
||||
/>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const GoogleSheetsLogo = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 49 67" {...props}>
|
||||
<title>Sheets-icon</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-1"
|
||||
></path>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-3"
|
||||
></path>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-5"
|
||||
></path>
|
||||
<linearGradient
|
||||
x1="50.0053945%"
|
||||
y1="8.58610612%"
|
||||
x2="50.0053945%"
|
||||
y2="100.013939%"
|
||||
id="linearGradient-7"
|
||||
>
|
||||
<stop stopColor="#263238" stopOpacity="0.2" offset="0%"></stop>
|
||||
<stop stopColor="#263238" stopOpacity="0.02" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-8"
|
||||
></path>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-10"
|
||||
></path>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-12"
|
||||
></path>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-14"
|
||||
></path>
|
||||
<radialGradient
|
||||
cx="3.16804688%"
|
||||
cy="2.71744318%"
|
||||
fx="3.16804688%"
|
||||
fy="2.71744318%"
|
||||
r="161.248516%"
|
||||
gradientTransform="translate(0.031680,0.027174),scale(1.000000,0.727273),translate(-0.031680,-0.027174)"
|
||||
id="radialGradient-16"
|
||||
>
|
||||
<stop stopColor="#FFFFFF" stopOpacity="0.1" offset="0%"></stop>
|
||||
<stop stopColor="#FFFFFF" stopOpacity="0" offset="100%"></stop>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g id="Page-1" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
||||
<g
|
||||
id="Consumer-Apps-Sheets-Large-VD-R8-"
|
||||
transform="translate(-451.000000, -451.000000)"
|
||||
>
|
||||
<g id="Hero" transform="translate(0.000000, 63.000000)">
|
||||
<g id="Personal" transform="translate(277.000000, 299.000000)">
|
||||
<g id="Sheets-icon" transform="translate(174.833333, 89.958333)">
|
||||
<g id="Group">
|
||||
<g id="Clipped">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlinkHref="#path-1"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L36.9791667,10.3541667 L29.5833333,0 Z"
|
||||
id="Path"
|
||||
fill="#0F9D58"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-4" fill="white">
|
||||
<use xlinkHref="#path-3"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path
|
||||
d="M11.8333333,31.8020833 L11.8333333,53.25 L35.5,53.25 L35.5,31.8020833 L11.8333333,31.8020833 Z M22.1875,50.2916667 L14.7916667,50.2916667 L14.7916667,46.59375 L22.1875,46.59375 L22.1875,50.2916667 Z M22.1875,44.375 L14.7916667,44.375 L14.7916667,40.6770833 L22.1875,40.6770833 L22.1875,44.375 Z M22.1875,38.4583333 L14.7916667,38.4583333 L14.7916667,34.7604167 L22.1875,34.7604167 L22.1875,38.4583333 Z M32.5416667,50.2916667 L25.1458333,50.2916667 L25.1458333,46.59375 L32.5416667,46.59375 L32.5416667,50.2916667 Z M32.5416667,44.375 L25.1458333,44.375 L25.1458333,40.6770833 L32.5416667,40.6770833 L32.5416667,44.375 Z M32.5416667,38.4583333 L25.1458333,38.4583333 L25.1458333,34.7604167 L32.5416667,34.7604167 L32.5416667,38.4583333 Z"
|
||||
id="Shape"
|
||||
fill="#F1F1F1"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-4)"
|
||||
></path>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-6" fill="white">
|
||||
<use xlinkHref="#path-5"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<polygon
|
||||
id="Path"
|
||||
fill="url(#linearGradient-7)"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-6)"
|
||||
points="30.8813021 16.4520313 47.3333333 32.9003646 47.3333333 17.75"
|
||||
></polygon>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-9" fill="white">
|
||||
<use xlinkHref="#path-8"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<g id="Group" mask="url(#mask-9)">
|
||||
<g transform="translate(26.625000, -2.958333)">
|
||||
<path
|
||||
d="M2.95833333,2.95833333 L2.95833333,16.2708333 C2.95833333,18.7225521 4.94411458,20.7083333 7.39583333,20.7083333 L20.7083333,20.7083333 L2.95833333,2.95833333 Z"
|
||||
id="Path"
|
||||
fill="#87CEAC"
|
||||
fillRule="nonzero"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-11" fill="white">
|
||||
<use xlinkHref="#path-10"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path
|
||||
d="M4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,4.80729167 C0,2.36666667 1.996875,0.369791667 4.4375,0.369791667 L29.5833333,0.369791667 L29.5833333,0 L4.4375,0 Z"
|
||||
id="Path"
|
||||
fillOpacity="0.2"
|
||||
fill="#FFFFFF"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-11)"
|
||||
></path>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-13" fill="white">
|
||||
<use xlinkHref="#path-12"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path
|
||||
d="M42.8958333,64.7135417 L4.4375,64.7135417 C1.996875,64.7135417 0,62.7166667 0,60.2760417 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,60.2760417 C47.3333333,62.7166667 45.3364583,64.7135417 42.8958333,64.7135417 Z"
|
||||
id="Path"
|
||||
fillOpacity="0.2"
|
||||
fill="#263238"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-13)"
|
||||
></path>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-15" fill="white">
|
||||
<use xlinkHref="#path-14"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path
|
||||
d="M34.0208333,17.75 C31.5691146,17.75 29.5833333,15.7642188 29.5833333,13.3125 L29.5833333,13.6822917 C29.5833333,16.1340104 31.5691146,18.1197917 34.0208333,18.1197917 L47.3333333,18.1197917 L47.3333333,17.75 L34.0208333,17.75 Z"
|
||||
id="Path"
|
||||
fillOpacity="0.1"
|
||||
fill="#263238"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-15)"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="Path"
|
||||
fill="url(#radialGradient-16)"
|
||||
fillRule="nonzero"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, ButtonProps, Flex, HStack } from '@chakra-ui/react'
|
||||
import { BubbleStepType, InputStepType, StepType, LogicStepType } from 'models'
|
||||
import { StepType, DraggableStepType } from 'models'
|
||||
import { useDnd } from 'contexts/DndContext'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { StepIcon } from './StepIcon'
|
||||
@@ -9,11 +9,8 @@ export const StepCard = ({
|
||||
type,
|
||||
onMouseDown,
|
||||
}: {
|
||||
type: BubbleStepType | InputStepType | LogicStepType
|
||||
onMouseDown: (
|
||||
e: React.MouseEvent,
|
||||
type: BubbleStepType | InputStepType | LogicStepType
|
||||
) => void
|
||||
type: DraggableStepType
|
||||
onMouseDown: (e: React.MouseEvent, type: DraggableStepType) => void
|
||||
}) => {
|
||||
const { draggedStepType } = useDnd()
|
||||
const [isMouseDown, setIsMouseDown] = useState(false)
|
||||
|
||||
@@ -12,48 +12,45 @@ import {
|
||||
PhoneIcon,
|
||||
TextIcon,
|
||||
} from 'assets/icons'
|
||||
import { BubbleStepType, InputStepType, LogicStepType, StepType } from 'models'
|
||||
import { GoogleSheetsLogo } from 'assets/logos'
|
||||
import {
|
||||
BubbleStepType,
|
||||
InputStepType,
|
||||
IntegrationStepType,
|
||||
LogicStepType,
|
||||
StepType,
|
||||
} from 'models'
|
||||
import React from 'react'
|
||||
|
||||
type StepIconProps = { type: StepType } & IconProps
|
||||
|
||||
export const StepIcon = ({ type, ...props }: StepIconProps) => {
|
||||
switch (type) {
|
||||
case BubbleStepType.TEXT: {
|
||||
case BubbleStepType.TEXT:
|
||||
return <ChatIcon {...props} />
|
||||
}
|
||||
case InputStepType.TEXT: {
|
||||
case InputStepType.TEXT:
|
||||
return <TextIcon {...props} />
|
||||
}
|
||||
case InputStepType.NUMBER: {
|
||||
case InputStepType.NUMBER:
|
||||
return <NumberIcon {...props} />
|
||||
}
|
||||
case InputStepType.EMAIL: {
|
||||
case InputStepType.EMAIL:
|
||||
return <EmailIcon {...props} />
|
||||
}
|
||||
case InputStepType.URL: {
|
||||
case InputStepType.URL:
|
||||
return <GlobeIcon {...props} />
|
||||
}
|
||||
case InputStepType.DATE: {
|
||||
case InputStepType.DATE:
|
||||
return <CalendarIcon {...props} />
|
||||
}
|
||||
case InputStepType.PHONE: {
|
||||
case InputStepType.PHONE:
|
||||
return <PhoneIcon {...props} />
|
||||
}
|
||||
case InputStepType.CHOICE: {
|
||||
case InputStepType.CHOICE:
|
||||
return <CheckSquareIcon {...props} />
|
||||
}
|
||||
case LogicStepType.SET_VARIABLE: {
|
||||
case LogicStepType.SET_VARIABLE:
|
||||
return <EditIcon {...props} />
|
||||
}
|
||||
case LogicStepType.CONDITION: {
|
||||
case LogicStepType.CONDITION:
|
||||
return <FilterIcon {...props} />
|
||||
}
|
||||
case 'start': {
|
||||
case IntegrationStepType.GOOGLE_SHEETS:
|
||||
return <GoogleSheetsLogo {...props} />
|
||||
case 'start':
|
||||
return <FlagIcon {...props} />
|
||||
}
|
||||
default: {
|
||||
default:
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Text } from '@chakra-ui/react'
|
||||
import { BubbleStepType, InputStepType, LogicStepType, StepType } from 'models'
|
||||
import {
|
||||
BubbleStepType,
|
||||
InputStepType,
|
||||
IntegrationStepType,
|
||||
LogicStepType,
|
||||
StepType,
|
||||
} from 'models'
|
||||
import React from 'react'
|
||||
|
||||
type Props = { type: StepType }
|
||||
@@ -34,6 +40,9 @@ export const StepTypeLabel = ({ type }: Props) => {
|
||||
case LogicStepType.CONDITION: {
|
||||
return <Text>Condition</Text>
|
||||
}
|
||||
case IntegrationStepType.GOOGLE_SHEETS: {
|
||||
return <Text>Sheets</Text>
|
||||
}
|
||||
default: {
|
||||
return <></>
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@ import {
|
||||
SimpleGrid,
|
||||
useEventListener,
|
||||
} from '@chakra-ui/react'
|
||||
import { BubbleStepType, InputStepType, LogicStepType } from 'models'
|
||||
import {
|
||||
BubbleStepType,
|
||||
DraggableStepType,
|
||||
InputStepType,
|
||||
IntegrationStepType,
|
||||
LogicStepType,
|
||||
} from 'models'
|
||||
import { useDnd } from 'contexts/DndContext'
|
||||
import React, { useState } from 'react'
|
||||
import { StepCard, StepCardOverlay } from './StepCard'
|
||||
@@ -29,10 +35,7 @@ export const StepTypesList = () => {
|
||||
}
|
||||
useEventListener('mousemove', handleMouseMove)
|
||||
|
||||
const handleMouseDown = (
|
||||
e: React.MouseEvent,
|
||||
type: BubbleStepType | InputStepType | LogicStepType
|
||||
) => {
|
||||
const handleMouseDown = (e: React.MouseEvent, type: DraggableStepType) => {
|
||||
const element = e.currentTarget as HTMLDivElement
|
||||
const rect = element.getBoundingClientRect()
|
||||
const relativeX = e.clientX - rect.left
|
||||
@@ -94,6 +97,15 @@ export const StepTypesList = () => {
|
||||
<StepCard key={type} type={type} onMouseDown={handleMouseDown} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
|
||||
Integrations
|
||||
</Text>
|
||||
<SimpleGrid columns={2} spacing="2">
|
||||
{Object.values(IntegrationStepType).map((type) => (
|
||||
<StepCard key={type} type={type} onMouseDown={handleMouseDown} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{draggedStepType && (
|
||||
<StepCardOverlay
|
||||
type={draggedStepType}
|
||||
|
||||
@@ -3,17 +3,16 @@ import {
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
useEventListener,
|
||||
Portal,
|
||||
} from '@chakra-ui/react'
|
||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||
import {
|
||||
ChoiceInputOptions,
|
||||
ConditionOptions,
|
||||
InputStep,
|
||||
InputStepType,
|
||||
IntegrationStepType,
|
||||
LogicStepType,
|
||||
SetVariableOptions,
|
||||
Step,
|
||||
TextInputOptions,
|
||||
StepOptions,
|
||||
} from 'models'
|
||||
import { useRef } from 'react'
|
||||
import {
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
} from './bodies'
|
||||
import { ChoiceInputSettingsBody } from './bodies/ChoiceInputSettingsBody'
|
||||
import { ConditionSettingsBody } from './bodies/ConditionSettingsBody'
|
||||
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
|
||||
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
|
||||
import { SetVariableSettingsBody } from './bodies/SetVariableSettingsBody'
|
||||
|
||||
@@ -41,24 +41,21 @@ export const SettingsPopoverContent = ({ step }: Props) => {
|
||||
}
|
||||
useEventListener('wheel', handleMouseWheel, ref.current)
|
||||
return (
|
||||
<PopoverContent onMouseDown={handleMouseDown}>
|
||||
<PopoverArrow />
|
||||
<PopoverBody p="6" overflowY="scroll" maxH="400px" ref={ref}>
|
||||
<SettingsPopoverBodyContent step={step} />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
<Portal>
|
||||
<PopoverContent onMouseDown={handleMouseDown}>
|
||||
<PopoverArrow />
|
||||
<PopoverBody p="6" overflowY="scroll" maxH="400px" ref={ref}>
|
||||
<SettingsPopoverBodyContent step={step} />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingsPopoverBodyContent = ({ step }: Props) => {
|
||||
const { updateStep } = useTypebot()
|
||||
const handleOptionsChange = (
|
||||
options:
|
||||
| TextInputOptions
|
||||
| ChoiceInputOptions
|
||||
| SetVariableOptions
|
||||
| ConditionOptions
|
||||
) => updateStep(step.id, { options } as Partial<InputStep>)
|
||||
const handleOptionsChange = (options: StepOptions) =>
|
||||
updateStep(step.id, { options } as Partial<InputStep>)
|
||||
|
||||
switch (step.type) {
|
||||
case InputStepType.TEXT: {
|
||||
@@ -133,6 +130,15 @@ const SettingsPopoverBodyContent = ({ step }: Props) => {
|
||||
/>
|
||||
)
|
||||
}
|
||||
case IntegrationStepType.GOOGLE_SHEETS: {
|
||||
return (
|
||||
<GoogleSheetsSettingsBody
|
||||
options={step.options}
|
||||
onOptionsChange={handleOptionsChange}
|
||||
stepId={step.id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
default: {
|
||||
return <></>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
|
||||
import { PlusIcon, TrashIcon } from 'assets/icons'
|
||||
import { DropdownList } from 'components/shared/DropdownList'
|
||||
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
||||
import { ExtractingCell, Table, Variable } from 'models'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Sheet } from 'services/integrations'
|
||||
import { generate } from 'short-uuid'
|
||||
import { useImmer } from 'use-immer'
|
||||
|
||||
type Props = {
|
||||
sheet: Sheet
|
||||
initialCells?: Table<ExtractingCell>
|
||||
onCellsChange: (cells: Table<ExtractingCell>) => void
|
||||
}
|
||||
|
||||
const id = generate()
|
||||
const defaultCells: Table<ExtractingCell> = {
|
||||
byId: { [id]: {} },
|
||||
allIds: [id],
|
||||
}
|
||||
|
||||
export const ExtractCellList = ({
|
||||
sheet,
|
||||
initialCells,
|
||||
onCellsChange,
|
||||
}: Props) => {
|
||||
const [cells, setCells] = useImmer(initialCells ?? defaultCells)
|
||||
const [showDeleteId, setShowDeleteId] = useState<string | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
onCellsChange(cells)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cells])
|
||||
|
||||
const createCell = () => {
|
||||
setCells((cells) => {
|
||||
const id = generate()
|
||||
cells.byId[id] = {}
|
||||
cells.allIds.push(id)
|
||||
})
|
||||
}
|
||||
|
||||
const updateCell = (cellId: string, updates: Partial<ExtractingCell>) =>
|
||||
setCells((cells) => {
|
||||
cells.byId[cellId] = {
|
||||
...cells.byId[cellId],
|
||||
...updates,
|
||||
}
|
||||
})
|
||||
|
||||
const deleteCell = (cellId: string) => () => {
|
||||
setCells((cells) => {
|
||||
delete cells.byId[cellId]
|
||||
const index = cells.allIds.indexOf(cellId)
|
||||
if (index !== -1) cells.allIds.splice(index, 1)
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseEnter = (cellId: string) => () => {
|
||||
setShowDeleteId(cellId)
|
||||
}
|
||||
|
||||
const handleCellChange = (cellId: string) => (cell: ExtractingCell) =>
|
||||
updateCell(cellId, cell)
|
||||
|
||||
const handleMouseLeave = () => setShowDeleteId(undefined)
|
||||
|
||||
return (
|
||||
<Stack spacing="4">
|
||||
{cells.allIds.map((cellId) => (
|
||||
<>
|
||||
<Flex
|
||||
pos="relative"
|
||||
onMouseEnter={handleMouseEnter(cellId)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<CellWithVariableIdStack
|
||||
key={cellId}
|
||||
cell={cells.byId[cellId]}
|
||||
columns={sheet.columns}
|
||||
onCellChange={handleCellChange(cellId)}
|
||||
/>
|
||||
<Fade in={showDeleteId === cellId}>
|
||||
<IconButton
|
||||
icon={<TrashIcon />}
|
||||
aria-label="Remove cell"
|
||||
onClick={deleteCell(cellId)}
|
||||
pos="absolute"
|
||||
left="-10px"
|
||||
top="-10px"
|
||||
size="sm"
|
||||
/>
|
||||
</Fade>
|
||||
</Flex>
|
||||
</>
|
||||
))}
|
||||
<Button leftIcon={<PlusIcon />} onClick={createCell} flexShrink={0}>
|
||||
Add
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export const CellWithVariableIdStack = ({
|
||||
cell,
|
||||
columns,
|
||||
onCellChange,
|
||||
}: {
|
||||
cell: ExtractingCell
|
||||
columns: string[]
|
||||
onCellChange: (cell: ExtractingCell) => void
|
||||
}) => {
|
||||
const handleColumnSelect = (column: string) => {
|
||||
onCellChange({ ...cell, column })
|
||||
}
|
||||
const handleVariableIdChange = (variable: Variable) => {
|
||||
onCellChange({ ...cell, variableId: variable.id })
|
||||
}
|
||||
return (
|
||||
<Stack bgColor="blue.50" p="4" rounded="md" flex="1">
|
||||
<DropdownList<string>
|
||||
currentItem={cell.column}
|
||||
onItemSelect={handleColumnSelect}
|
||||
items={columns}
|
||||
bgColor="white"
|
||||
placeholder="Select a column"
|
||||
/>
|
||||
<VariableSearchInput
|
||||
initialVariableId={cell.variableId}
|
||||
onSelectVariable={handleVariableIdChange}
|
||||
placeholder="Select a variable"
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Divider, Stack, Text } from '@chakra-ui/react'
|
||||
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
|
||||
import { DropdownList } from 'components/shared/DropdownList'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { CredentialsType } from 'db'
|
||||
import {
|
||||
Cell,
|
||||
ExtractingCell,
|
||||
GoogleSheetsAction,
|
||||
GoogleSheetsOptions,
|
||||
Table,
|
||||
} from 'models'
|
||||
import React, { useMemo } from 'react'
|
||||
import {
|
||||
getGoogleSheetsConsentScreenUrl,
|
||||
Sheet,
|
||||
useSheets,
|
||||
} from 'services/integrations'
|
||||
import { isDefined } from 'utils'
|
||||
import { ExtractCellList } from './ExtractCellList'
|
||||
import { SheetsDropdown } from './SheetsDropdown'
|
||||
import { SpreadsheetsDropdown } from './SpreadsheetDropdown'
|
||||
import { CellWithValueStack, UpdateCellList } from './UpdateCellList'
|
||||
|
||||
type Props = {
|
||||
options?: GoogleSheetsOptions
|
||||
onOptionsChange: (options: GoogleSheetsOptions) => void
|
||||
stepId: string
|
||||
}
|
||||
|
||||
export const GoogleSheetsSettingsBody = ({
|
||||
options,
|
||||
onOptionsChange,
|
||||
stepId,
|
||||
}: Props) => {
|
||||
const { save, hasUnsavedChanges } = useTypebot()
|
||||
const { sheets, isLoading } = useSheets({
|
||||
credentialsId: options?.credentialsId,
|
||||
spreadsheetId: options?.spreadsheetId,
|
||||
})
|
||||
const sheet = useMemo(
|
||||
() => sheets?.find((s) => s.id === options?.sheetId),
|
||||
[sheets, options?.sheetId]
|
||||
)
|
||||
const handleCredentialsIdChange = (credentialsId: string) =>
|
||||
onOptionsChange({ ...options, credentialsId })
|
||||
const handleSpreadsheetIdChange = (spreadsheetId: string) =>
|
||||
onOptionsChange({ ...options, spreadsheetId })
|
||||
const handleSheetIdChange = (sheetId: string) =>
|
||||
onOptionsChange({ ...options, sheetId })
|
||||
const handleActionChange = (action: GoogleSheetsAction) =>
|
||||
onOptionsChange({ ...options, action })
|
||||
|
||||
const handleCreateNewClick = async () => {
|
||||
if (hasUnsavedChanges) {
|
||||
const errorToastId = await save()
|
||||
if (errorToastId) return
|
||||
}
|
||||
const linkElement = document.createElement('a')
|
||||
linkElement.href = getGoogleSheetsConsentScreenUrl(
|
||||
window.location.href,
|
||||
stepId
|
||||
)
|
||||
linkElement.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<CredentialsDropdown
|
||||
type={CredentialsType.GOOGLE_SHEETS}
|
||||
currentCredentialsId={options?.credentialsId}
|
||||
onCredentialsSelect={handleCredentialsIdChange}
|
||||
onCreateNewClick={handleCreateNewClick}
|
||||
/>
|
||||
{options?.credentialsId && (
|
||||
<SpreadsheetsDropdown
|
||||
credentialsId={options.credentialsId}
|
||||
spreadsheetId={options.spreadsheetId}
|
||||
onSelectSpreadsheetId={handleSpreadsheetIdChange}
|
||||
/>
|
||||
)}
|
||||
{options?.spreadsheetId && options.credentialsId && (
|
||||
<SheetsDropdown
|
||||
sheets={sheets ?? []}
|
||||
isLoading={isLoading}
|
||||
sheetId={options.sheetId}
|
||||
onSelectSheetId={handleSheetIdChange}
|
||||
/>
|
||||
)}
|
||||
{options?.spreadsheetId &&
|
||||
options.credentialsId &&
|
||||
isDefined(options.sheetId) && (
|
||||
<>
|
||||
<Divider />
|
||||
<DropdownList<GoogleSheetsAction>
|
||||
currentItem={options.action}
|
||||
onItemSelect={handleActionChange}
|
||||
items={Object.values(GoogleSheetsAction)}
|
||||
placeholder="Select an operation"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{sheet && options?.action && (
|
||||
<ActionOptions
|
||||
options={options}
|
||||
sheet={sheet}
|
||||
onOptionsChange={onOptionsChange}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const ActionOptions = ({
|
||||
options,
|
||||
sheet,
|
||||
onOptionsChange,
|
||||
}: {
|
||||
options: GoogleSheetsOptions
|
||||
sheet: Sheet
|
||||
onOptionsChange: (options: GoogleSheetsOptions) => void
|
||||
}) => {
|
||||
const handleInsertColumnsChange = (cellsToInsert: Table<Cell>) =>
|
||||
onOptionsChange({ ...options, cellsToInsert } as GoogleSheetsOptions)
|
||||
|
||||
const handleUpsertColumnsChange = (cellsToUpsert: Table<Cell>) =>
|
||||
onOptionsChange({ ...options, cellsToUpsert } as GoogleSheetsOptions)
|
||||
|
||||
const handleReferenceCellChange = (referenceCell: Cell) =>
|
||||
onOptionsChange({ ...options, referenceCell } as GoogleSheetsOptions)
|
||||
|
||||
const handleExtractingCellsChange = (cellsToExtract: Table<ExtractingCell>) =>
|
||||
onOptionsChange({ ...options, cellsToExtract } as GoogleSheetsOptions)
|
||||
|
||||
switch (options.action) {
|
||||
case GoogleSheetsAction.INSERT_ROW:
|
||||
return (
|
||||
<UpdateCellList
|
||||
initialCells={options.cellsToInsert}
|
||||
sheet={sheet}
|
||||
onCellsChange={handleInsertColumnsChange}
|
||||
/>
|
||||
)
|
||||
case GoogleSheetsAction.UPDATE_ROW:
|
||||
return (
|
||||
<Stack>
|
||||
<Text>Row to select</Text>
|
||||
<CellWithValueStack
|
||||
cell={options.referenceCell ?? {}}
|
||||
columns={sheet.columns}
|
||||
onCellChange={handleReferenceCellChange}
|
||||
/>
|
||||
<Text>Cells to update</Text>
|
||||
<UpdateCellList
|
||||
initialCells={options.cellsToUpsert}
|
||||
sheet={sheet}
|
||||
onCellsChange={handleUpsertColumnsChange}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
case GoogleSheetsAction.GET:
|
||||
return (
|
||||
<Stack>
|
||||
<Text>Row to select</Text>
|
||||
<CellWithValueStack
|
||||
cell={options.referenceCell ?? {}}
|
||||
columns={sheet.columns}
|
||||
onCellChange={handleReferenceCellChange}
|
||||
/>
|
||||
<Text>Cells to extract</Text>
|
||||
<ExtractCellList
|
||||
initialCells={options.cellsToExtract}
|
||||
sheet={sheet}
|
||||
onCellsChange={handleExtractingCellsChange}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
default:
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
|
||||
import { useMemo } from 'react'
|
||||
import { Sheet } from 'services/integrations'
|
||||
import { isDefined } from 'utils'
|
||||
|
||||
type Props = {
|
||||
sheets: Sheet[]
|
||||
isLoading: boolean
|
||||
sheetId?: string
|
||||
onSelectSheetId: (id: string) => void
|
||||
}
|
||||
|
||||
export const SheetsDropdown = ({
|
||||
sheets,
|
||||
isLoading,
|
||||
sheetId,
|
||||
onSelectSheetId,
|
||||
}: Props) => {
|
||||
const currentSheet = useMemo(
|
||||
() => sheets?.find((s) => s.id === sheetId),
|
||||
[sheetId, sheets]
|
||||
)
|
||||
|
||||
const handleSpreadsheetSelect = (name: string) => {
|
||||
const id = sheets?.find((s) => s.name === name)?.id
|
||||
if (isDefined(id)) onSelectSheetId(id)
|
||||
}
|
||||
return (
|
||||
<SearchableDropdown
|
||||
selectedItem={currentSheet?.name}
|
||||
items={(sheets ?? []).map((s) => s.name)}
|
||||
onSelectItem={handleSpreadsheetSelect}
|
||||
placeholder={isLoading ? 'Loading...' : 'Select the sheet'}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
|
||||
import { useMemo } from 'react'
|
||||
import { useSpreadsheets } from 'services/integrations'
|
||||
|
||||
type Props = {
|
||||
credentialsId: string
|
||||
spreadsheetId?: string
|
||||
onSelectSpreadsheetId: (id: string) => void
|
||||
}
|
||||
|
||||
export const SpreadsheetsDropdown = ({
|
||||
credentialsId,
|
||||
spreadsheetId,
|
||||
onSelectSpreadsheetId,
|
||||
}: Props) => {
|
||||
const { spreadsheets, isLoading } = useSpreadsheets({ credentialsId })
|
||||
const currentSpreadsheet = useMemo(
|
||||
() => spreadsheets?.find((s) => s.id === spreadsheetId),
|
||||
[spreadsheetId, spreadsheets]
|
||||
)
|
||||
|
||||
const handleSpreadsheetSelect = (name: string) => {
|
||||
const id = spreadsheets?.find((s) => s.name === name)?.id
|
||||
if (id) onSelectSpreadsheetId(id)
|
||||
}
|
||||
return (
|
||||
<SearchableDropdown
|
||||
selectedItem={currentSpreadsheet?.name}
|
||||
items={(spreadsheets ?? []).map((s) => s.name)}
|
||||
onSelectItem={handleSpreadsheetSelect}
|
||||
placeholder={isLoading ? 'Loading...' : 'Search for spreadsheet'}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
|
||||
import { PlusIcon, TrashIcon } from 'assets/icons'
|
||||
import { DropdownList } from 'components/shared/DropdownList'
|
||||
import { InputWithVariable } from 'components/shared/InputWithVariable'
|
||||
import { Cell, Table } from 'models'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Sheet } from 'services/integrations'
|
||||
import { generate } from 'short-uuid'
|
||||
import { useImmer } from 'use-immer'
|
||||
|
||||
type Props = {
|
||||
sheet: Sheet
|
||||
initialCells?: Table<Cell>
|
||||
onCellsChange: (cells: Table<Cell>) => void
|
||||
}
|
||||
|
||||
const id = generate()
|
||||
const defaultCells: Table<Cell> = {
|
||||
byId: { [id]: {} },
|
||||
allIds: [id],
|
||||
}
|
||||
|
||||
export const UpdateCellList = ({
|
||||
sheet,
|
||||
initialCells,
|
||||
onCellsChange,
|
||||
}: Props) => {
|
||||
const [cells, setCells] = useImmer(initialCells ?? defaultCells)
|
||||
const [showDeleteId, setShowDeleteId] = useState<string | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
onCellsChange(cells)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cells])
|
||||
|
||||
const createCell = () => {
|
||||
setCells((cells) => {
|
||||
const id = generate()
|
||||
cells.byId[id] = {}
|
||||
cells.allIds.push(id)
|
||||
})
|
||||
}
|
||||
|
||||
const updateCell = (cellId: string, updates: Partial<Cell>) =>
|
||||
setCells((cells) => {
|
||||
cells.byId[cellId] = {
|
||||
...cells.byId[cellId],
|
||||
...updates,
|
||||
}
|
||||
})
|
||||
|
||||
const deleteCell = (cellId: string) => () => {
|
||||
setCells((cells) => {
|
||||
delete cells.byId[cellId]
|
||||
const index = cells.allIds.indexOf(cellId)
|
||||
if (index !== -1) cells.allIds.splice(index, 1)
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseEnter = (cellId: string) => () => {
|
||||
setShowDeleteId(cellId)
|
||||
}
|
||||
|
||||
const handleCellChange = (cellId: string) => (cell: Cell) =>
|
||||
updateCell(cellId, cell)
|
||||
|
||||
const handleMouseLeave = () => setShowDeleteId(undefined)
|
||||
|
||||
return (
|
||||
<Stack spacing="4">
|
||||
{cells.allIds.map((cellId) => (
|
||||
<>
|
||||
<Flex
|
||||
pos="relative"
|
||||
onMouseEnter={handleMouseEnter(cellId)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<CellWithValueStack
|
||||
key={cellId}
|
||||
cell={cells.byId[cellId]}
|
||||
columns={sheet.columns}
|
||||
onCellChange={handleCellChange(cellId)}
|
||||
/>
|
||||
<Fade in={showDeleteId === cellId}>
|
||||
<IconButton
|
||||
icon={<TrashIcon />}
|
||||
aria-label="Remove cell"
|
||||
onClick={deleteCell(cellId)}
|
||||
pos="absolute"
|
||||
left="-10px"
|
||||
top="-10px"
|
||||
size="sm"
|
||||
/>
|
||||
</Fade>
|
||||
</Flex>
|
||||
</>
|
||||
))}
|
||||
<Button leftIcon={<PlusIcon />} onClick={createCell} flexShrink={0}>
|
||||
Add
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export const CellWithValueStack = ({
|
||||
cell,
|
||||
columns,
|
||||
onCellChange,
|
||||
}: {
|
||||
cell: Cell
|
||||
columns: string[]
|
||||
onCellChange: (column: Cell) => void
|
||||
}) => {
|
||||
const handleColumnSelect = (column: string) => {
|
||||
onCellChange({ ...cell, column })
|
||||
}
|
||||
const handleValueChange = (value: string) => {
|
||||
onCellChange({ ...cell, value })
|
||||
}
|
||||
return (
|
||||
<Stack bgColor="blue.50" p="4" rounded="md" flex="1">
|
||||
<DropdownList<string>
|
||||
currentItem={cell.column}
|
||||
onItemSelect={handleColumnSelect}
|
||||
items={columns}
|
||||
bgColor="white"
|
||||
placeholder="Select a column"
|
||||
/>
|
||||
<InputWithVariable
|
||||
initialValue={cell.value ?? ''}
|
||||
onValueChange={handleValueChange}
|
||||
placeholder="Type a value..."
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GoogleSheetsSettingsBody } from './GoogleSheetsSettingsBody'
|
||||
@@ -7,21 +7,27 @@ import {
|
||||
useEventListener,
|
||||
} from '@chakra-ui/react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Block, Step } from 'models'
|
||||
import { Block, DraggableStep, Step } from 'models'
|
||||
import { useGraph } from 'contexts/GraphContext'
|
||||
import { StepIcon } from 'components/board/StepTypesList/StepIcon'
|
||||
import { isDefined, isInputStep, isLogicStep, isTextBubbleStep } from 'utils'
|
||||
import {
|
||||
isDefined,
|
||||
isInputStep,
|
||||
isLogicStep,
|
||||
isTextBubbleStep,
|
||||
isIntegrationStep,
|
||||
} from 'utils'
|
||||
import { Coordinates } from '@dnd-kit/core/dist/types'
|
||||
import { TextEditor } from './TextEditor/TextEditor'
|
||||
import { StepNodeContent } from './StepNodeContent'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { ContextMenu } from 'components/shared/ContextMenu'
|
||||
import { SettingsPopoverContent } from './SettingsPopoverContent'
|
||||
import { DraggableStep } from 'contexts/DndContext'
|
||||
import { StepNodeContextMenu } from './StepNodeContextMenu'
|
||||
import { SourceEndpoint } from './SourceEndpoint'
|
||||
import { hasDefaultConnector } from 'services/typebots'
|
||||
import { TargetEndpoint } from './TargetEndpoint'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
export const StepNode = ({
|
||||
step,
|
||||
@@ -39,6 +45,7 @@ export const StepNode = ({
|
||||
step: DraggableStep
|
||||
) => void
|
||||
}) => {
|
||||
const { query } = useRouter()
|
||||
const { setConnectingIds, connectingIds } = useGraph()
|
||||
const { moveStep, typebot } = useTypebot()
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
@@ -152,7 +159,11 @@ export const StepNode = ({
|
||||
renderMenu={() => <StepNodeContextMenu stepId={step.id} />}
|
||||
>
|
||||
{(ref, isOpened) => (
|
||||
<Popover placement="left" isLazy>
|
||||
<Popover
|
||||
placement="left"
|
||||
isLazy
|
||||
defaultIsOpen={query.stepId?.toString() === step.id}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Flex
|
||||
pos="relative"
|
||||
@@ -226,11 +237,12 @@ export const StepNode = ({
|
||||
)}
|
||||
</Flex>
|
||||
</PopoverTrigger>
|
||||
{(isInputStep(step) || isLogicStep(step)) && (
|
||||
<SettingsPopoverContent step={step} />
|
||||
)}
|
||||
{hasPopover(step) && <SettingsPopoverContent step={step} />}
|
||||
</Popover>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const hasPopover = (step: Step) =>
|
||||
isInputStep(step) || isLogicStep(step) || isIntegrationStep(step)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LogicStepType,
|
||||
SetVariableStep,
|
||||
ConditionStep,
|
||||
IntegrationStepType,
|
||||
} from 'models'
|
||||
import { ChoiceItemsList } from './ChoiceInputStepNode/ChoiceItemsList'
|
||||
import { SourceEndpoint } from './SourceEndpoint'
|
||||
@@ -84,6 +85,10 @@ export const StepNodeContent = ({ step }: Props) => {
|
||||
case LogicStepType.CONDITION: {
|
||||
return <ConditionNodeContent step={step} />
|
||||
}
|
||||
case IntegrationStepType.GOOGLE_SHEETS: {
|
||||
if (!step.options) return <Text color={'gray.500'}>Configure...</Text>
|
||||
return <Text>{step.options?.action}</Text>
|
||||
}
|
||||
case 'start': {
|
||||
return <Text>{step.label}</Text>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react'
|
||||
import { Step, Table } from 'models'
|
||||
import { DraggableStep, useDnd } from 'contexts/DndContext'
|
||||
import { DraggableStep, Step, Table } from 'models'
|
||||
import { useDnd } from 'contexts/DndContext'
|
||||
import { Coordinates } from 'contexts/GraphContext'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { StepNode, StepNodeOverlay } from './StepNode'
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Flex, FlexProps, useEventListener } from '@chakra-ui/react'
|
||||
import React, { useRef, useMemo } from 'react'
|
||||
import { blockWidth, useGraph } from 'contexts/GraphContext'
|
||||
import { BlockNode } from './BlockNode/BlockNode'
|
||||
import { DraggableStepType, useDnd } from 'contexts/DndContext'
|
||||
import { useDnd } from 'contexts/DndContext'
|
||||
import { Edges } from './Edges'
|
||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||
import { headerHeight } from 'components/shared/TypebotHeader/TypebotHeader'
|
||||
import { DraggableStepType } from 'models'
|
||||
|
||||
const Graph = ({ ...props }: FlexProps) => {
|
||||
const { draggedStepType, setDraggedStepType, draggedStep, setDraggedStep } =
|
||||
|
||||
104
apps/builder/components/shared/CredentialsDropdown.tsx
Normal file
104
apps/builder/components/shared/CredentialsDropdown.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuButtonProps,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon, PlusIcon } from 'assets/icons'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { CredentialsType } from 'db'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
type Props = Omit<MenuButtonProps, 'type'> & {
|
||||
type: CredentialsType
|
||||
currentCredentialsId?: string
|
||||
onCredentialsSelect: (credentialId: string) => void
|
||||
onCreateNewClick: () => void
|
||||
}
|
||||
|
||||
export const CredentialsDropdown = ({
|
||||
type,
|
||||
currentCredentialsId,
|
||||
onCredentialsSelect,
|
||||
onCreateNewClick,
|
||||
...props
|
||||
}: Props) => {
|
||||
const router = useRouter()
|
||||
const { credentials } = useUser()
|
||||
|
||||
const credentialsList = useMemo(() => {
|
||||
return credentials.filter((credential) => credential.type === type)
|
||||
}, [type, credentials])
|
||||
|
||||
const currentCredential = useMemo(
|
||||
() => credentials.find((c) => c.id === currentCredentialsId),
|
||||
[currentCredentialsId, credentials]
|
||||
)
|
||||
|
||||
const handleMenuItemClick = (credentialId: string) => () => {
|
||||
onCredentialsSelect(credentialId)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return
|
||||
if (router.query.credentialsId) {
|
||||
handleMenuItemClick(router.query.credentialsId.toString())()
|
||||
clearQueryParams()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router.isReady])
|
||||
|
||||
const clearQueryParams = () => {
|
||||
const hasQueryParams = router.asPath.includes('?')
|
||||
if (hasQueryParams)
|
||||
router.push(router.asPath.split('?')[0], undefined, { shallow: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu isLazy placement="bottom-end" matchWidth>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
|
||||
colorScheme="gray"
|
||||
justifyContent="space-between"
|
||||
textAlign="left"
|
||||
{...props}
|
||||
>
|
||||
<Text isTruncated overflowY="visible" h="20px">
|
||||
{currentCredential ? currentCredential.name : 'Select an account'}
|
||||
</Text>
|
||||
</MenuButton>
|
||||
<MenuList maxW="500px">
|
||||
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
|
||||
{credentialsList.map((credentials) => (
|
||||
<MenuItem
|
||||
key={credentials.id}
|
||||
maxW="500px"
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
onClick={handleMenuItemClick(credentials.id)}
|
||||
>
|
||||
{credentials.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem
|
||||
maxW="500px"
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
icon={<PlusIcon />}
|
||||
onClick={onCreateNewClick}
|
||||
>
|
||||
Connect new
|
||||
</MenuItem>
|
||||
</Stack>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Input, InputProps } from '@chakra-ui/react'
|
||||
import { ChangeEvent, useEffect, useState } from 'react'
|
||||
import {
|
||||
ChangeEvent,
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
|
||||
type Props = Omit<InputProps, 'onChange' | 'value'> & {
|
||||
@@ -8,24 +14,31 @@ type Props = Omit<InputProps, 'onChange' | 'value'> & {
|
||||
onChange: (debouncedValue: string) => void
|
||||
}
|
||||
|
||||
export const DebouncedInput = ({
|
||||
delay,
|
||||
onChange,
|
||||
initialValue,
|
||||
...props
|
||||
}: Props) => {
|
||||
const [currentValue, setCurrentValue] = useState(initialValue)
|
||||
const [currentValueDebounced] = useDebounce(currentValue, delay)
|
||||
export const DebouncedInput = forwardRef(
|
||||
(
|
||||
{ delay, onChange, initialValue, ...props }: Props,
|
||||
ref: ForwardedRef<HTMLInputElement>
|
||||
) => {
|
||||
const [currentValue, setCurrentValue] = useState(initialValue)
|
||||
const [currentValueDebounced] = useDebounce(currentValue, delay)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentValueDebounced === initialValue) return
|
||||
onChange(currentValueDebounced)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentValueDebounced])
|
||||
useEffect(() => {
|
||||
if (currentValueDebounced === initialValue) return
|
||||
onChange(currentValueDebounced)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentValueDebounced])
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setCurrentValue(e.target.value)
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setCurrentValue(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
ref={ref}
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <Input {...props} value={currentValue} onChange={handleChange} />
|
||||
}
|
||||
)
|
||||
|
||||
@@ -11,15 +11,17 @@ import { ChevronLeftIcon } from 'assets/icons'
|
||||
import React from 'react'
|
||||
|
||||
type Props<T> = {
|
||||
currentItem: T
|
||||
currentItem?: T
|
||||
onItemSelect: (item: T) => void
|
||||
items: T[]
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export const DropdownList = <T,>({
|
||||
currentItem,
|
||||
onItemSelect,
|
||||
items,
|
||||
placeholder = '',
|
||||
...props
|
||||
}: Props<T> & MenuButtonProps) => {
|
||||
const handleMenuItemClick = (operator: T) => () => {
|
||||
@@ -27,7 +29,7 @@ export const DropdownList = <T,>({
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Menu isLazy placement="bottom-end">
|
||||
<Menu isLazy placement="bottom-end" matchWidth>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
|
||||
@@ -37,9 +39,9 @@ export const DropdownList = <T,>({
|
||||
textAlign="left"
|
||||
{...props}
|
||||
>
|
||||
{currentItem}
|
||||
{currentItem ?? placeholder}
|
||||
</MenuButton>
|
||||
<MenuList maxW="500px">
|
||||
<MenuList maxW="500px" shadow="lg">
|
||||
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
|
||||
{items.map((item) => (
|
||||
<MenuItem
|
||||
|
||||
105
apps/builder/components/shared/InputWithVariable.tsx
Normal file
105
apps/builder/components/shared/InputWithVariable.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputProps,
|
||||
InputRightElement,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@chakra-ui/react'
|
||||
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 const InputWithVariable = ({
|
||||
initialValue,
|
||||
noAbsolute,
|
||||
onValueChange,
|
||||
...props
|
||||
}: {
|
||||
initialValue: string
|
||||
onValueChange: (value: string) => void
|
||||
noAbsolute?: boolean
|
||||
} & InputProps) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const [debouncedValue] = useDebounce(value, 100)
|
||||
const [carretPosition, setCarretPosition] = useState<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
onValueChange(debouncedValue)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedValue])
|
||||
|
||||
const handleVariableSelected = (variable: Variable) => {
|
||||
if (!inputRef.current) return
|
||||
const cursorPosition = carretPosition
|
||||
const textBeforeCursorPosition = inputRef.current.value.substring(
|
||||
0,
|
||||
cursorPosition
|
||||
)
|
||||
const textAfterCursorPosition = inputRef.current.value.substring(
|
||||
cursorPosition,
|
||||
inputRef.current.value.length
|
||||
)
|
||||
setValue(
|
||||
textBeforeCursorPosition +
|
||||
`{{${variable.name}}}` +
|
||||
textAfterCursorPosition
|
||||
)
|
||||
inputRef.current.focus()
|
||||
setTimeout(() => {
|
||||
if (!inputRef.current) return
|
||||
inputRef.current.selectionStart = inputRef.current.selectionEnd =
|
||||
carretPosition + `{{${variable.name}}}`.length
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleKeyUp = () => {
|
||||
if (!inputRef.current?.selectionStart) return
|
||||
setCarretPosition(inputRef.current.selectionStart)
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setValue(e.target.value)
|
||||
|
||||
return (
|
||||
<InputGroup>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
onKeyUp={handleKeyUp}
|
||||
onClick={handleKeyUp}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
bgColor={'white'}
|
||||
/>
|
||||
<InputRightElement
|
||||
pos={noAbsolute ? 'relative' : 'absolute'}
|
||||
zIndex={noAbsolute ? 'unset' : '1'}
|
||||
>
|
||||
<Popover matchWidth isLazy>
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
aria-label="Insert a variable"
|
||||
icon={<UserIcon />}
|
||||
size="sm"
|
||||
pos="relative"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w="full">
|
||||
<VariableSearchInput
|
||||
onSelectVariable={handleVariableSelected}
|
||||
placeholder="Search for a variable"
|
||||
shadow="lg"
|
||||
isDefaultOpen
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
@@ -8,18 +8,21 @@ import {
|
||||
PopoverContent,
|
||||
Button,
|
||||
Text,
|
||||
InputProps,
|
||||
} from '@chakra-ui/react'
|
||||
import { useState, useRef, useEffect, ChangeEvent } from 'react'
|
||||
|
||||
type Props = {
|
||||
selectedItem?: string
|
||||
items: string[]
|
||||
onSelectItem: (value: string) => void
|
||||
} & InputProps
|
||||
export const SearchableDropdown = ({
|
||||
selectedItem,
|
||||
items,
|
||||
onSelectItem,
|
||||
}: {
|
||||
selectedItem?: string
|
||||
items: string[]
|
||||
onSelectItem: (value: string) => void
|
||||
}) => {
|
||||
...inputProps
|
||||
}: Props) => {
|
||||
const { onOpen, onClose, isOpen } = useDisclosure()
|
||||
const [inputValue, setInputValue] = useState(selectedItem)
|
||||
const [filteredItems, setFilteredItems] = useState([
|
||||
@@ -64,19 +67,38 @@ export const SearchableDropdown = ({
|
||||
])
|
||||
}
|
||||
|
||||
const handleItemClick = (item: string) => () => {
|
||||
setInputValue(item)
|
||||
onSelectItem(item)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex ref={dropdownRef}>
|
||||
<Popover isOpen={isOpen} initialFocusRef={inputRef}>
|
||||
<Flex ref={dropdownRef} w="full">
|
||||
<Popover
|
||||
isOpen={isOpen}
|
||||
initialFocusRef={inputRef}
|
||||
matchWidth
|
||||
offset={[0, 0]}
|
||||
isLazy
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
onClick={onOpen}
|
||||
w="300px"
|
||||
{...inputProps}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent maxH="35vh" overflowY="scroll" spacing="0" w="300px">
|
||||
<PopoverContent
|
||||
maxH="35vh"
|
||||
overflowY="scroll"
|
||||
spacing="0"
|
||||
role="menu"
|
||||
w="inherit"
|
||||
shadow="lg"
|
||||
>
|
||||
{filteredItems.length > 0 ? (
|
||||
<>
|
||||
{filteredItems.map((item, idx) => {
|
||||
@@ -84,15 +106,12 @@ export const SearchableDropdown = ({
|
||||
<Button
|
||||
minH="40px"
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
setInputValue(item)
|
||||
onSelectItem(item)
|
||||
onClose()
|
||||
}}
|
||||
onClick={handleItemClick(item)}
|
||||
fontSize="16px"
|
||||
fontWeight="normal"
|
||||
rounded="none"
|
||||
colorScheme="gray"
|
||||
role="menuitem"
|
||||
variant="ghost"
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
BubbleStep,
|
||||
BubbleStepType,
|
||||
ChoiceItem,
|
||||
InputStep,
|
||||
InputStepType,
|
||||
LogicStepType,
|
||||
LogicStep,
|
||||
} from 'models'
|
||||
import { ChoiceItem, DraggableStep, DraggableStepType } from 'models'
|
||||
import {
|
||||
createContext,
|
||||
Dispatch,
|
||||
@@ -16,9 +8,6 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
export type DraggableStep = BubbleStep | InputStep | LogicStep
|
||||
export type DraggableStepType = BubbleStepType | InputStepType | LogicStepType
|
||||
|
||||
const dndContext = createContext<{
|
||||
draggedStepType?: DraggableStepType
|
||||
setDraggedStepType: Dispatch<SetStateAction<DraggableStepType | undefined>>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useToast } from '@chakra-ui/react'
|
||||
import { ToastId, useToast } from '@chakra-ui/react'
|
||||
import { PublicTypebot, Settings, Theme, Typebot } from 'models'
|
||||
import { useRouter } from 'next/router'
|
||||
import {
|
||||
@@ -43,7 +43,7 @@ const typebotContext = createContext<
|
||||
isPublishing: boolean
|
||||
hasUnsavedChanges: boolean
|
||||
isSavingLoading: boolean
|
||||
save: () => void
|
||||
save: () => Promise<ToastId | undefined>
|
||||
undo: () => void
|
||||
updateTypebot: (updates: UpdateTypebotPayload) => void
|
||||
publishTypebot: () => void
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { Coordinates } from 'contexts/GraphContext'
|
||||
import { WritableDraft } from 'immer/dist/internal'
|
||||
import {
|
||||
Block,
|
||||
BubbleStepType,
|
||||
InputStepType,
|
||||
LogicStepType,
|
||||
Step,
|
||||
Typebot,
|
||||
} from 'models'
|
||||
import { Block, DraggableStep, DraggableStepType, Typebot } from 'models'
|
||||
import { parseNewBlock } from 'services/typebots'
|
||||
import { Updater } from 'use-immer'
|
||||
import { createStepDraft, deleteStepDraft } from './steps'
|
||||
@@ -15,7 +8,7 @@ import { createStepDraft, deleteStepDraft } from './steps'
|
||||
export type BlocksActions = {
|
||||
createBlock: (
|
||||
props: Coordinates & {
|
||||
step: BubbleStepType | InputStepType | LogicStepType | Step
|
||||
step: DraggableStep | DraggableStepType
|
||||
}
|
||||
) => void
|
||||
updateBlock: (blockId: string, updates: Partial<Omit<Block, 'id'>>) => void
|
||||
@@ -28,7 +21,7 @@ export const blocksActions = (setTypebot: Updater<Typebot>): BlocksActions => ({
|
||||
y,
|
||||
step,
|
||||
}: Coordinates & {
|
||||
step: BubbleStepType | InputStepType | LogicStepType | Step
|
||||
step: DraggableStep | DraggableStepType
|
||||
}) => {
|
||||
setTypebot((typebot) => {
|
||||
const newBlock = parseNewBlock({
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import {
|
||||
BubbleStepType,
|
||||
ChoiceInputStep,
|
||||
InputStepType,
|
||||
Step,
|
||||
Typebot,
|
||||
LogicStepType,
|
||||
DraggableStep,
|
||||
DraggableStepType,
|
||||
} from 'models'
|
||||
import { parseNewStep } from 'services/typebots'
|
||||
import { Updater } from 'use-immer'
|
||||
@@ -16,7 +15,7 @@ import { isChoiceInput } from 'utils'
|
||||
export type StepsActions = {
|
||||
createStep: (
|
||||
blockId: string,
|
||||
step: BubbleStepType | InputStepType | LogicStepType | Step,
|
||||
step: DraggableStep | DraggableStepType,
|
||||
index?: number
|
||||
) => void
|
||||
updateStep: (
|
||||
@@ -30,7 +29,7 @@ export type StepsActions = {
|
||||
export const stepsAction = (setTypebot: Updater<Typebot>): StepsActions => ({
|
||||
createStep: (
|
||||
blockId: string,
|
||||
step: BubbleStepType | InputStepType | LogicStepType | Step,
|
||||
step: DraggableStep | DraggableStepType,
|
||||
index?: number
|
||||
) => {
|
||||
setTypebot((typebot) => {
|
||||
@@ -76,7 +75,7 @@ export const deleteStepDraft = (
|
||||
|
||||
export const createStepDraft = (
|
||||
typebot: WritableDraft<Typebot>,
|
||||
step: BubbleStepType | InputStepType | LogicStepType | Step,
|
||||
step: DraggableStep | DraggableStepType,
|
||||
blockId: string,
|
||||
index?: number
|
||||
) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { User } from 'db'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/router'
|
||||
import {
|
||||
@@ -13,6 +12,8 @@ import { isDefined } from 'utils'
|
||||
import { updateUser as updateUserInDb } from 'services/user'
|
||||
import { useToast } from '@chakra-ui/react'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { useCredentials } from 'services/credentials'
|
||||
import { Credentials, User } from 'db'
|
||||
|
||||
const userContext = createContext<{
|
||||
user?: User
|
||||
@@ -20,6 +21,7 @@ const userContext = createContext<{
|
||||
isSaving: boolean
|
||||
hasUnsavedChanges: boolean
|
||||
isOAuthProvider: boolean
|
||||
credentials: Credentials[]
|
||||
updateUser: (newUser: Partial<User>) => void
|
||||
saveUser: () => void
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@@ -29,8 +31,12 @@ const userContext = createContext<{
|
||||
export const UserContext = ({ children }: { children: ReactNode }) => {
|
||||
const router = useRouter()
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
const [user, setUser] = useState<User>()
|
||||
const [user, setUser] = useState<User | undefined>()
|
||||
const { credentials } = useCredentials({
|
||||
userId: user?.id,
|
||||
onError: (error) =>
|
||||
toast({ title: error.name, description: error.message }),
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const isOAuthProvider = useMemo(
|
||||
() => (session?.providerType as boolean | undefined) ?? false,
|
||||
@@ -69,9 +75,13 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
|
||||
setIsSaving(true)
|
||||
const { error } = await updateUserInDb(user.id, user)
|
||||
if (error) toast({ title: error.name, description: error.message })
|
||||
await refreshUser()
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
const refreshUser = async () => {
|
||||
await fetch('/api/auth/session?update')
|
||||
reloadSession()
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -84,6 +94,7 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
|
||||
isLoading: status === 'loading',
|
||||
hasUnsavedChanges,
|
||||
isOAuthProvider,
|
||||
credentials: credentials ?? [],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"id": "typebot4",
|
||||
"createdAt": "2022-01-17T14:37:01.826Z",
|
||||
"updatedAt": "2022-01-17T14:37:01.826Z",
|
||||
"name": "My typebot",
|
||||
"ownerId": "user2",
|
||||
"publishedTypebotId": null,
|
||||
"folderId": null,
|
||||
"blocks": {
|
||||
"byId": {
|
||||
"bec5A5bLwenmZpCJc8FRaM": {
|
||||
"id": "bec5A5bLwenmZpCJc8FRaM",
|
||||
"title": "Start",
|
||||
"stepIds": ["uDMB7a2ucg17WGvbQJjeRn"],
|
||||
"graphCoordinates": { "x": 0, "y": 0 }
|
||||
},
|
||||
"bcyiT7P6E99YnHKnpxs4Yux": {
|
||||
"id": "bcyiT7P6E99YnHKnpxs4Yux",
|
||||
"title": "Block #2",
|
||||
"stepIds": ["step1"],
|
||||
"graphCoordinates": { "x": 411, "y": 108 }
|
||||
},
|
||||
"bgpNxHtBBXWrP1QMe2A8hZ9": {
|
||||
"id": "bgpNxHtBBXWrP1QMe2A8hZ9",
|
||||
"title": "Block #3",
|
||||
"graphCoordinates": { "x": 1, "y": 236 },
|
||||
"stepIds": ["sj72oDhJEe4N92KTt64GKWs"]
|
||||
}
|
||||
},
|
||||
"allIds": [
|
||||
"bec5A5bLwenmZpCJc8FRaM",
|
||||
"bcyiT7P6E99YnHKnpxs4Yux",
|
||||
"bgpNxHtBBXWrP1QMe2A8hZ9"
|
||||
]
|
||||
},
|
||||
"steps": {
|
||||
"byId": {
|
||||
"step1": {
|
||||
"id": "step1",
|
||||
"type": "Google Sheets",
|
||||
"blockId": "bcyiT7P6E99YnHKnpxs4Yux"
|
||||
},
|
||||
"uDMB7a2ucg17WGvbQJjeRn": {
|
||||
"id": "uDMB7a2ucg17WGvbQJjeRn",
|
||||
"type": "start",
|
||||
"label": "Start",
|
||||
"target": { "blockId": "bgpNxHtBBXWrP1QMe2A8hZ9" },
|
||||
"blockId": "bec5A5bLwenmZpCJc8FRaM"
|
||||
},
|
||||
"sj72oDhJEe4N92KTt64GKWs": {
|
||||
"id": "sj72oDhJEe4N92KTt64GKWs",
|
||||
"blockId": "bgpNxHtBBXWrP1QMe2A8hZ9",
|
||||
"type": "email input",
|
||||
"target": { "blockId": "bcyiT7P6E99YnHKnpxs4Yux" },
|
||||
"options": { "variableId": "8H3aQsNji2Gyfpp3RPozNN" }
|
||||
}
|
||||
},
|
||||
"allIds": ["uDMB7a2ucg17WGvbQJjeRn", "step1", "sj72oDhJEe4N92KTt64GKWs"]
|
||||
},
|
||||
"choiceItems": { "byId": {}, "allIds": [] },
|
||||
"variables": {
|
||||
"byId": {
|
||||
"8H3aQsNji2Gyfpp3RPozNN": {
|
||||
"id": "8H3aQsNji2Gyfpp3RPozNN",
|
||||
"name": "Email"
|
||||
}
|
||||
},
|
||||
"allIds": ["8H3aQsNji2Gyfpp3RPozNN"]
|
||||
},
|
||||
"theme": {
|
||||
"general": {
|
||||
"font": "Open Sans",
|
||||
"background": { "type": "None", "content": "#ffffff" }
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
|
||||
},
|
||||
"publicId": null
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"id": "typebot4",
|
||||
"createdAt": "2022-01-17T14:37:01.826Z",
|
||||
"updatedAt": "2022-01-17T14:37:01.826Z",
|
||||
"name": "My typebot",
|
||||
"ownerId": "user2",
|
||||
"publishedTypebotId": null,
|
||||
"folderId": null,
|
||||
"blocks": {
|
||||
"byId": {
|
||||
"bec5A5bLwenmZpCJc8FRaM": {
|
||||
"id": "bec5A5bLwenmZpCJc8FRaM",
|
||||
"title": "Start",
|
||||
"stepIds": ["uDMB7a2ucg17WGvbQJjeRn"],
|
||||
"graphCoordinates": { "x": 0, "y": 0 }
|
||||
},
|
||||
"bcyiT7P6E99YnHKnpxs4Yux": {
|
||||
"id": "bcyiT7P6E99YnHKnpxs4Yux",
|
||||
"title": "Block #2",
|
||||
"stepIds": ["step1"],
|
||||
"graphCoordinates": { "x": 411, "y": 108 }
|
||||
},
|
||||
"bgpNxHtBBXWrP1QMe2A8hZ9": {
|
||||
"id": "bgpNxHtBBXWrP1QMe2A8hZ9",
|
||||
"title": "Block #3",
|
||||
"stepIds": ["sj72oDhJEe4N92KTt64GKWs"],
|
||||
"graphCoordinates": { "x": 1, "y": 236 }
|
||||
},
|
||||
"buwMV9tx2EFcMbRkNTELt3J": {
|
||||
"id": "buwMV9tx2EFcMbRkNTELt3J",
|
||||
"title": "Block #4",
|
||||
"graphCoordinates": { "x": 441, "y": 330 },
|
||||
"stepIds": ["s2R2bk7qfSRVgTyRmPhVw7p"]
|
||||
}
|
||||
},
|
||||
"allIds": [
|
||||
"bec5A5bLwenmZpCJc8FRaM",
|
||||
"bcyiT7P6E99YnHKnpxs4Yux",
|
||||
"bgpNxHtBBXWrP1QMe2A8hZ9",
|
||||
"buwMV9tx2EFcMbRkNTELt3J"
|
||||
]
|
||||
},
|
||||
"steps": {
|
||||
"byId": {
|
||||
"step1": {
|
||||
"id": "step1",
|
||||
"type": "Google Sheets",
|
||||
"blockId": "bcyiT7P6E99YnHKnpxs4Yux",
|
||||
"target": { "blockId": "buwMV9tx2EFcMbRkNTELt3J" }
|
||||
},
|
||||
"uDMB7a2ucg17WGvbQJjeRn": {
|
||||
"id": "uDMB7a2ucg17WGvbQJjeRn",
|
||||
"type": "start",
|
||||
"label": "Start",
|
||||
"target": { "blockId": "bgpNxHtBBXWrP1QMe2A8hZ9" },
|
||||
"blockId": "bec5A5bLwenmZpCJc8FRaM"
|
||||
},
|
||||
"sj72oDhJEe4N92KTt64GKWs": {
|
||||
"id": "sj72oDhJEe4N92KTt64GKWs",
|
||||
"type": "email input",
|
||||
"target": { "blockId": "bcyiT7P6E99YnHKnpxs4Yux" },
|
||||
"blockId": "bgpNxHtBBXWrP1QMe2A8hZ9",
|
||||
"options": { "variableId": "8H3aQsNji2Gyfpp3RPozNN" }
|
||||
},
|
||||
"s2R2bk7qfSRVgTyRmPhVw7p": {
|
||||
"id": "s2R2bk7qfSRVgTyRmPhVw7p",
|
||||
"blockId": "buwMV9tx2EFcMbRkNTELt3J",
|
||||
"type": "text",
|
||||
"content": {
|
||||
"html": "<div>Your name is: {{First name}} {{Last name}}</div>",
|
||||
"richText": [
|
||||
{
|
||||
"type": "p",
|
||||
"children": [
|
||||
{ "text": "Your name is: {{First name}} {{Last name}}" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"plainText": "Your name is: {{First name}} {{Last name}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"allIds": [
|
||||
"uDMB7a2ucg17WGvbQJjeRn",
|
||||
"step1",
|
||||
"sj72oDhJEe4N92KTt64GKWs",
|
||||
"s2R2bk7qfSRVgTyRmPhVw7p"
|
||||
]
|
||||
},
|
||||
"choiceItems": { "byId": {}, "allIds": [] },
|
||||
"variables": {
|
||||
"byId": {
|
||||
"8H3aQsNji2Gyfpp3RPozNN": {
|
||||
"id": "8H3aQsNji2Gyfpp3RPozNN",
|
||||
"name": "Email"
|
||||
}
|
||||
},
|
||||
"allIds": ["8H3aQsNji2Gyfpp3RPozNN"]
|
||||
},
|
||||
"theme": {
|
||||
"general": {
|
||||
"font": "Open Sans",
|
||||
"background": { "type": "None", "content": "#ffffff" }
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
|
||||
},
|
||||
"publicId": null
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { InputStepType, PublicTypebot, Typebot } from 'models'
|
||||
import { Plan, PrismaClient } from 'db'
|
||||
import { CredentialsType, Plan, PrismaClient } from 'db'
|
||||
import { parseTestTypebot } from './utils'
|
||||
import { userIds } from './data'
|
||||
|
||||
@@ -7,9 +7,10 @@ const prisma = new PrismaClient()
|
||||
|
||||
const teardownTestData = async () => prisma.user.deleteMany()
|
||||
|
||||
export const seedDb = async () => {
|
||||
export const seedDb = async (googleRefreshToken: string) => {
|
||||
await teardownTestData()
|
||||
await createUsers()
|
||||
await createCredentials(googleRefreshToken)
|
||||
await createFolders()
|
||||
await createTypebots()
|
||||
await createResults()
|
||||
@@ -33,6 +34,23 @@ const createUsers = () =>
|
||||
],
|
||||
})
|
||||
|
||||
const createCredentials = (refresh_token: string) =>
|
||||
prisma.credentials.createMany({
|
||||
data: [
|
||||
{
|
||||
name: 'test2@gmail.com',
|
||||
ownerId: userIds[1],
|
||||
type: CredentialsType.GOOGLE_SHEETS,
|
||||
data: {
|
||||
expiry_date: 1642441058842,
|
||||
access_token:
|
||||
'ya29.A0ARrdaM--PV_87ebjywDJpXKb77NBFJl16meVUapYdfNv6W6ZzqqC47fNaPaRjbDbOIIcp6f49cMaX5ndK9TAFnKwlVqz3nrK9nLKqgyDIhYsIq47smcAIZkK56SWPx3X3DwAFqRu2UPojpd2upWwo-3uJrod',
|
||||
refresh_token,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const createFolders = () =>
|
||||
prisma.dashboardFolder.createMany({
|
||||
data: [{ ownerId: userIds[1], name: 'Folder #1', id: 'folder1' }],
|
||||
|
||||
@@ -280,7 +280,9 @@ const createTypebotWithStep = (step: Omit<InputStep, 'id' | 'blockId'>) => {
|
||||
//@ts-ignore
|
||||
options:
|
||||
step.type === InputStepType.CHOICE
|
||||
? { itemIds: ['item1'] }
|
||||
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
{ itemIds: ['item1'] }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
139
apps/builder/cypress/tests/integrations.ts
Normal file
139
apps/builder/cypress/tests/integrations.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { preventUserFromRefreshing } from 'cypress/plugins/utils'
|
||||
import { getIframeBody } from 'cypress/support'
|
||||
|
||||
describe('Google sheets', () => {
|
||||
beforeEach(() => {
|
||||
cy.task('seed', Cypress.env('GOOGLE_SHEETS_REFRESH_TOKEN'))
|
||||
cy.signOut()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.removeEventListener('beforeunload', preventUserFromRefreshing)
|
||||
})
|
||||
})
|
||||
|
||||
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.signIn('test2@gmail.com')
|
||||
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' }).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.signIn('test2@gmail.com')
|
||||
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.signIn('test2@gmail.com')
|
||||
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' }).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.findByTestId('step-step1').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()
|
||||
}
|
||||
28
apps/builder/libs/google-sheets.ts
Normal file
28
apps/builder/libs/google-sheets.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Prisma, Credentials as CredentialsFromDb } from 'db'
|
||||
import { OAuth2Client, Credentials } from 'google-auth-library'
|
||||
import prisma from './prisma'
|
||||
|
||||
export const oauth2Client = new OAuth2Client(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET,
|
||||
`${process.env.NEXTAUTH_URL}/api/credentials/google-sheets/callback`
|
||||
)
|
||||
|
||||
export const getAuthenticatedGoogleClient = async (
|
||||
userId: string,
|
||||
credentialsId: string
|
||||
): Promise<OAuth2Client> => {
|
||||
const credentials = (await prisma.credentials.findFirst({
|
||||
where: { id: credentialsId, ownerId: userId },
|
||||
})) as CredentialsFromDb
|
||||
oauth2Client.setCredentials(credentials.data as Credentials)
|
||||
oauth2Client.on('tokens', updateTokens(credentialsId))
|
||||
return oauth2Client
|
||||
}
|
||||
|
||||
const updateTokens =
|
||||
(credentialsId: string) => async (credentials: Credentials) =>
|
||||
prisma.credentials.update({
|
||||
where: { id: credentialsId },
|
||||
data: { data: credentials as Prisma.InputJsonValue },
|
||||
})
|
||||
@@ -1,12 +1,5 @@
|
||||
import { Link } from '@chakra-ui/react'
|
||||
import {
|
||||
AutoformatRule,
|
||||
createAutoformatPlugin,
|
||||
} from '@udecode/plate-autoformat'
|
||||
import {
|
||||
MARK_BOLD,
|
||||
MARK_UNDERLINE,
|
||||
MARK_ITALIC,
|
||||
createBoldPlugin,
|
||||
createItalicPlugin,
|
||||
createUnderlinePlugin,
|
||||
@@ -21,40 +14,12 @@ export const editorStyle: React.CSSProperties = {
|
||||
borderRadius: '0.25rem',
|
||||
}
|
||||
|
||||
export const autoFormatRules: AutoformatRule[] = [
|
||||
{
|
||||
mode: 'mark',
|
||||
type: MARK_BOLD,
|
||||
match: '**',
|
||||
},
|
||||
{
|
||||
mode: 'mark',
|
||||
type: MARK_UNDERLINE,
|
||||
match: '__',
|
||||
},
|
||||
{
|
||||
mode: 'mark',
|
||||
type: MARK_ITALIC,
|
||||
match: '*',
|
||||
},
|
||||
{
|
||||
mode: 'mark',
|
||||
type: MARK_ITALIC,
|
||||
match: '_',
|
||||
},
|
||||
]
|
||||
|
||||
export const platePlugins = createPlugins(
|
||||
[
|
||||
createBoldPlugin(),
|
||||
createItalicPlugin(),
|
||||
createUnderlinePlugin(),
|
||||
createLinkPlugin(),
|
||||
createAutoformatPlugin({
|
||||
options: {
|
||||
rules: autoFormatRules,
|
||||
},
|
||||
}),
|
||||
],
|
||||
{ components: { [ELEMENT_LINK]: Link } }
|
||||
)
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
"@dnd-kit/sortable": "^5.1.0",
|
||||
"@emotion/react": "^11.7.1",
|
||||
"@emotion/styled": "^11.6.0",
|
||||
"@googleapis/drive": "^2.1.0",
|
||||
"@next-auth/prisma-adapter": "next",
|
||||
"@udecode/plate-autoformat": "^9.0.0",
|
||||
"@udecode/plate-basic-marks": "^9.0.0",
|
||||
"@udecode/plate-common": "^7.0.2",
|
||||
"@udecode/plate-core": "^9.0.0",
|
||||
@@ -30,6 +30,8 @@
|
||||
"fast-equals": "^2.0.4",
|
||||
"focus-visible": "^5.2.0",
|
||||
"framer-motion": "^4",
|
||||
"google-auth-library": "^7.11.0",
|
||||
"google-spreadsheet": "^3.2.0",
|
||||
"htmlparser2": "^7.2.0",
|
||||
"immer": "^9.0.7",
|
||||
"kbar": "^0.1.0-beta.24",
|
||||
@@ -61,6 +63,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/cypress": "^8.0.2",
|
||||
"@types/google-spreadsheet": "^3.1.5",
|
||||
"@types/micro-cors": "^0.1.2",
|
||||
"@types/node": "^16.11.9",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
|
||||
62
apps/builder/pages/api/credentials/google-sheets/callback.ts
Normal file
62
apps/builder/pages/api/credentials/google-sheets/callback.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { oauth2Client } from 'libs/google-sheets'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getSession } from 'next-auth/react'
|
||||
import { CredentialsType, Prisma, User } from 'db'
|
||||
import prisma from 'libs/prisma'
|
||||
import { googleSheetsScopes } from './consent-url'
|
||||
import { stringify } from 'querystring'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const session = await getSession({ req })
|
||||
const { redirectUrl, stepId } = JSON.parse(
|
||||
Buffer.from(req.query.state.toString(), 'base64').toString()
|
||||
)
|
||||
if (req.method === 'GET') {
|
||||
const code = req.query.code.toString()
|
||||
if (!code)
|
||||
return res.status(400).send({ message: "Bad request, couldn't get code" })
|
||||
if (!session?.user)
|
||||
return res.status(401).json({ message: 'Not authenticated' })
|
||||
const user = session.user as User
|
||||
const { tokens } = await oauth2Client.getToken(code)
|
||||
if (!tokens?.access_token) {
|
||||
console.error('Error getting oAuth tokens:')
|
||||
throw new Error('ERROR')
|
||||
}
|
||||
oauth2Client.setCredentials(tokens)
|
||||
const { email, scopes } = await oauth2Client.getTokenInfo(
|
||||
tokens.access_token
|
||||
)
|
||||
if (!email)
|
||||
return res
|
||||
.status(400)
|
||||
.send({ message: "Couldn't get email from getTokenInfo" })
|
||||
if (googleSheetsScopes.some((scope) => !scopes.includes(scope)))
|
||||
return res
|
||||
.status(400)
|
||||
.send({ message: "User didn't accepted required scopes" })
|
||||
const credentials = {
|
||||
name: email,
|
||||
type: CredentialsType.GOOGLE_SHEETS,
|
||||
ownerId: user.id,
|
||||
data: tokens as Prisma.InputJsonValue,
|
||||
}
|
||||
const { id: credentialsId } = await prisma.credentials.upsert({
|
||||
create: credentials,
|
||||
update: credentials,
|
||||
where: {
|
||||
name_type_ownerId: {
|
||||
name: credentials.name,
|
||||
type: credentials.type,
|
||||
ownerId: user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
const queryParams = stringify({ stepId, credentialsId })
|
||||
return res.redirect(
|
||||
`${redirectUrl}?${queryParams}` ?? `${process.env.NEXTAUTH_URL}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default handler
|
||||
@@ -0,0 +1,22 @@
|
||||
import { oauth2Client } from 'libs/google-sheets'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
export const googleSheetsScopes = [
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/spreadsheets',
|
||||
'https://www.googleapis.com/auth/drive.readonly',
|
||||
]
|
||||
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method === 'GET') {
|
||||
const url = oauth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: googleSheetsScopes,
|
||||
prompt: 'consent',
|
||||
state: Buffer.from(JSON.stringify(req.query)).toString('base64'),
|
||||
})
|
||||
return res.status(301).redirect(url)
|
||||
}
|
||||
}
|
||||
|
||||
export default handler
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { drive } from '@googleapis/drive'
|
||||
import { getAuthenticatedGoogleClient } from 'libs/google-sheets'
|
||||
import { methodNotAllowed } from 'utils'
|
||||
import { getSession } from 'next-auth/react'
|
||||
import { User } from 'db'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const session = await getSession({ req })
|
||||
|
||||
if (!session?.user)
|
||||
return res.status(401).json({ message: 'Not authenticated' })
|
||||
|
||||
const user = session.user as User
|
||||
if (req.method === 'GET') {
|
||||
const credentialsId = req.query.credentialsId.toString()
|
||||
const auth = await getAuthenticatedGoogleClient(user.id, credentialsId)
|
||||
const { data } = await drive({
|
||||
version: 'v3',
|
||||
auth,
|
||||
}).files.list({
|
||||
q: "mimeType='application/vnd.google-apps.spreadsheet'",
|
||||
fields: 'nextPageToken, files(id, name)',
|
||||
})
|
||||
return res.send(data)
|
||||
}
|
||||
return methodNotAllowed(res)
|
||||
}
|
||||
|
||||
export default handler
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { GoogleSpreadsheet } from 'google-spreadsheet'
|
||||
import { getAuthenticatedGoogleClient } from 'libs/google-sheets'
|
||||
import { methodNotAllowed } from 'utils'
|
||||
import { getSession } from 'next-auth/react'
|
||||
import { User } from 'db'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const session = await getSession({ req })
|
||||
|
||||
if (!session?.user)
|
||||
return res.status(401).json({ message: 'Not authenticated' })
|
||||
|
||||
const user = session.user as User
|
||||
if (req.method === 'GET') {
|
||||
const credentialsId = req.query.credentialsId.toString()
|
||||
|
||||
const spreadsheetId = req.query.id.toString()
|
||||
const doc = new GoogleSpreadsheet(spreadsheetId)
|
||||
doc.useOAuth2Client(
|
||||
await getAuthenticatedGoogleClient(user.id, credentialsId)
|
||||
)
|
||||
await doc.loadInfo()
|
||||
return res.send({
|
||||
sheets: await Promise.all(
|
||||
Array.from(Array(doc.sheetCount)).map(async (_, idx) => {
|
||||
const sheet = doc.sheetsByIndex[idx]
|
||||
await sheet.loadHeaderRow()
|
||||
return {
|
||||
id: sheet.sheetId,
|
||||
name: sheet.title,
|
||||
columns: sheet.headerValues,
|
||||
}
|
||||
})
|
||||
),
|
||||
})
|
||||
}
|
||||
return methodNotAllowed(res)
|
||||
}
|
||||
|
||||
export default handler
|
||||
25
apps/builder/pages/api/users/[id]/credentials.ts
Normal file
25
apps/builder/pages/api/users/[id]/credentials.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { User } from 'db'
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getSession } from 'next-auth/react'
|
||||
import { methodNotAllowed } from 'utils'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const session = await getSession({ req })
|
||||
|
||||
if (!session?.user)
|
||||
return res.status(401).json({ message: 'Not authenticated' })
|
||||
|
||||
const user = session.user as User
|
||||
const id = req.query.id.toString()
|
||||
if (user.id !== id) return res.status(401).send({ message: 'Forbidden' })
|
||||
if (req.method === 'GET') {
|
||||
const credentials = await prisma.credentials.findMany({
|
||||
where: { ownerId: user.id },
|
||||
})
|
||||
return res.send({ credentials })
|
||||
}
|
||||
return methodNotAllowed(res)
|
||||
}
|
||||
|
||||
export default handler
|
||||
22
apps/builder/services/credentials.ts
Normal file
22
apps/builder/services/credentials.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Credentials } from 'db'
|
||||
import useSWR from 'swr'
|
||||
import { fetcher } from './utils'
|
||||
|
||||
export const useCredentials = ({
|
||||
userId,
|
||||
onError,
|
||||
}: {
|
||||
userId?: string
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const { data, error, mutate } = useSWR<{ credentials: Credentials[] }, Error>(
|
||||
userId ? `/api/users/${userId}/credentials` : null,
|
||||
fetcher
|
||||
)
|
||||
if (error) onError(error)
|
||||
return {
|
||||
credentials: data?.credentials,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { DashboardFolder } from '.prisma/client'
|
||||
import useSWR from 'swr'
|
||||
import { fetcher, sendRequest } from './utils'
|
||||
import { fetcher } from './utils'
|
||||
import { stringify } from 'qs'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const useFolders = ({
|
||||
parentId,
|
||||
|
||||
66
apps/builder/services/integrations.ts
Normal file
66
apps/builder/services/integrations.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { sendRequest } from 'utils'
|
||||
import { stringify } from 'qs'
|
||||
import useSWR from 'swr'
|
||||
import { fetcher } from './utils'
|
||||
|
||||
export const getGoogleSheetsConsentScreenUrl = (
|
||||
redirectUrl: string,
|
||||
stepId: string
|
||||
) => {
|
||||
const queryParams = stringify({ redirectUrl, stepId })
|
||||
return `/api/credentials/google-sheets/consent-url?${queryParams}`
|
||||
}
|
||||
|
||||
export const createSheetsAccount = async (code: string) => {
|
||||
const queryParams = stringify({ code })
|
||||
return sendRequest({
|
||||
url: `/api/credentials/google-sheets/callback?${queryParams}`,
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export type Spreadsheet = { id: string; name: string }
|
||||
export const useSpreadsheets = ({
|
||||
credentialsId,
|
||||
onError,
|
||||
}: {
|
||||
credentialsId: string
|
||||
onError?: (error: Error) => void
|
||||
}) => {
|
||||
const queryParams = stringify({ credentialsId })
|
||||
const { data, error, mutate } = useSWR<{ files: Spreadsheet[] }, Error>(
|
||||
`/api/integrations/google-sheets/spreadsheets?${queryParams}`,
|
||||
fetcher
|
||||
)
|
||||
if (error) onError && onError(error)
|
||||
return {
|
||||
spreadsheets: data?.files,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
|
||||
export type Sheet = { id: string; name: string; columns: string[] }
|
||||
export const useSheets = ({
|
||||
credentialsId,
|
||||
spreadsheetId,
|
||||
onError,
|
||||
}: {
|
||||
credentialsId?: string
|
||||
spreadsheetId?: string
|
||||
onError?: (error: Error) => void
|
||||
}) => {
|
||||
const queryParams = stringify({ credentialsId })
|
||||
const { data, error, mutate } = useSWR<{ sheets: Sheet[] }, Error>(
|
||||
!credentialsId || !spreadsheetId
|
||||
? null
|
||||
: `/api/integrations/google-sheets/spreadsheets/${spreadsheetId}/sheets?${queryParams}`,
|
||||
fetcher
|
||||
)
|
||||
if (error) onError && onError(error)
|
||||
return {
|
||||
sheets: data?.sheets,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { PublicTypebot, Typebot } from 'models'
|
||||
import { sendRequest } from './utils'
|
||||
import shortId from 'short-uuid'
|
||||
import { HStack, Text } from '@chakra-ui/react'
|
||||
import { CalendarIcon } from 'assets/icons'
|
||||
import { StepIcon } from 'components/board/StepTypesList/StepIcon'
|
||||
import { isInputStep } from 'utils'
|
||||
import { isInputStep, sendRequest } from 'utils'
|
||||
|
||||
export const parseTypebotToPublicTypebot = (
|
||||
typebot: Typebot
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Result } from 'models'
|
||||
import useSWRInfinite from 'swr/infinite'
|
||||
import { fetcher, sendRequest } from './utils'
|
||||
import { fetcher } from './utils'
|
||||
import { stringify } from 'qs'
|
||||
import { Answer } from 'db'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
const getKey = (
|
||||
typebotId: string,
|
||||
|
||||
@@ -6,25 +6,24 @@ import {
|
||||
Settings,
|
||||
StartStep,
|
||||
Theme,
|
||||
BubbleStep,
|
||||
InputStep,
|
||||
BubbleStepType,
|
||||
InputStepType,
|
||||
ChoiceInputStep,
|
||||
LogicStepType,
|
||||
LogicStep,
|
||||
Step,
|
||||
ConditionStep,
|
||||
ComparisonOperators,
|
||||
LogicalOperator,
|
||||
DraggableStepType,
|
||||
DraggableStep,
|
||||
} from 'models'
|
||||
import shortId, { generate } from 'short-uuid'
|
||||
import { Typebot } from 'models'
|
||||
import useSWR from 'swr'
|
||||
import { fetcher, sendRequest, toKebabCase } from './utils'
|
||||
import { fetcher, toKebabCase } from './utils'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { stringify } from 'qs'
|
||||
import { isChoiceInput, isConditionStep } from 'utils'
|
||||
import { isChoiceInput, isConditionStep, sendRequest } from 'utils'
|
||||
|
||||
export const useTypebots = ({
|
||||
folderId,
|
||||
@@ -114,9 +113,9 @@ export const parseNewBlock = ({
|
||||
}
|
||||
|
||||
export const parseNewStep = (
|
||||
type: BubbleStepType | InputStepType | LogicStepType,
|
||||
type: DraggableStepType,
|
||||
blockId: string
|
||||
): BubbleStep | InputStep | LogicStep => {
|
||||
): DraggableStep => {
|
||||
const id = `s${shortId.generate()}`
|
||||
switch (type) {
|
||||
case BubbleStepType.TEXT: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { User } from 'db'
|
||||
import { sendRequest } from './utils'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const updateUser = async (id: string, user: User) =>
|
||||
sendRequest({
|
||||
|
||||
@@ -10,36 +10,6 @@ export const isMobile =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('only screen and (max-width: 760px)').matches
|
||||
|
||||
export const sendRequest = async <ResponseData>({
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
}: {
|
||||
url: string
|
||||
method: string
|
||||
body?: Record<string, unknown>
|
||||
}): Promise<{ data?: ResponseData; error?: Error }> => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
mode: 'cors',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!response.ok) throw new Error(response.statusText)
|
||||
const data = await response.json()
|
||||
return { data }
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return { error: e as Error }
|
||||
}
|
||||
}
|
||||
|
||||
export const insertItemInList = <T>(
|
||||
arr: T[],
|
||||
index: number,
|
||||
newItem: T
|
||||
): T[] => [...arr.slice(0, index), newItem, ...arr.slice(index)]
|
||||
|
||||
export const preventUserFromRefreshing = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
|
||||
27
apps/viewer/libs/google-sheets.ts
Normal file
27
apps/viewer/libs/google-sheets.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Prisma, Credentials as CredentialsFromDb } from 'db'
|
||||
import { OAuth2Client, Credentials } from 'google-auth-library'
|
||||
import prisma from './prisma'
|
||||
|
||||
export const oauth2Client = new OAuth2Client(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET,
|
||||
`${process.env.NEXTAUTH_URL}/api/credentials/google-sheets/callback`
|
||||
)
|
||||
|
||||
export const getAuthenticatedGoogleClient = async (
|
||||
credentialsId: string
|
||||
): Promise<OAuth2Client> => {
|
||||
const credentials = (await prisma.credentials.findFirst({
|
||||
where: { id: credentialsId },
|
||||
})) as CredentialsFromDb
|
||||
oauth2Client.setCredentials(credentials.data as Credentials)
|
||||
oauth2Client.on('tokens', updateTokens(credentialsId))
|
||||
return oauth2Client
|
||||
}
|
||||
|
||||
const updateTokens =
|
||||
(credentialsId: string) => async (credentials: Credentials) =>
|
||||
prisma.credentials.update({
|
||||
where: { id: credentialsId },
|
||||
data: { data: credentials as Prisma.InputJsonValue },
|
||||
})
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"bot-engine": "*",
|
||||
"db": "*",
|
||||
"google-spreadsheet": "^3.2.0",
|
||||
"models": "*",
|
||||
"next": "^12.0.7",
|
||||
"react": "^17.0.2",
|
||||
@@ -18,6 +19,7 @@
|
||||
"utils": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google-spreadsheet": "^3.1.5",
|
||||
"@types/node": "^17.0.4",
|
||||
"@types/react": "^17.0.38",
|
||||
"@typescript-eslint/eslint-plugin": "^5.9.0",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { methodNotAllowed } from 'utils'
|
||||
import { GoogleSpreadsheet } from 'google-spreadsheet'
|
||||
import { getAuthenticatedGoogleClient } from 'libs/google-sheets'
|
||||
import { Cell } from 'models'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method === 'GET') {
|
||||
const spreadsheetId = req.query.spreadsheetId.toString()
|
||||
const sheetId = req.query.sheetId.toString()
|
||||
const credentialsId = req.query.credentialsId.toString()
|
||||
const referenceCell = {
|
||||
column: req.query['referenceCell[column]'],
|
||||
value: req.query['referenceCell[value]'],
|
||||
} as Cell
|
||||
console.log(referenceCell)
|
||||
const extractingColumns = req.query.columns as string[]
|
||||
const doc = new GoogleSpreadsheet(spreadsheetId)
|
||||
doc.useOAuth2Client(await getAuthenticatedGoogleClient(credentialsId))
|
||||
await doc.loadInfo()
|
||||
const sheet = doc.sheetsById[sheetId]
|
||||
const rows = await sheet.getRows()
|
||||
const row = rows.find(
|
||||
(row) => row[referenceCell.column as string] === referenceCell.value
|
||||
)
|
||||
if (!row) return res.status(404).send({ message: "Couldn't find row" })
|
||||
return res.send({
|
||||
...extractingColumns.reduce(
|
||||
(obj, column) => ({ ...obj, [column]: row[column] }),
|
||||
{}
|
||||
),
|
||||
})
|
||||
}
|
||||
if (req.method === 'POST') {
|
||||
const spreadsheetId = req.query.spreadsheetId.toString()
|
||||
const sheetId = req.query.sheetId.toString()
|
||||
const { credentialsId, values } = JSON.parse(req.body) as {
|
||||
credentialsId: string
|
||||
values: { [key: string]: string }
|
||||
}
|
||||
const doc = new GoogleSpreadsheet(spreadsheetId)
|
||||
doc.useOAuth2Client(await getAuthenticatedGoogleClient(credentialsId))
|
||||
await doc.loadInfo()
|
||||
const sheet = doc.sheetsById[sheetId]
|
||||
await sheet.addRow(values)
|
||||
return res.send({ message: 'Success' })
|
||||
}
|
||||
if (req.method === 'PATCH') {
|
||||
const spreadsheetId = req.query.spreadsheetId.toString()
|
||||
const sheetId = req.query.sheetId.toString()
|
||||
const { credentialsId, values, referenceCell } = JSON.parse(req.body) as {
|
||||
credentialsId: string
|
||||
referenceCell: Cell
|
||||
values: { [key: string]: string }
|
||||
}
|
||||
const doc = new GoogleSpreadsheet(spreadsheetId)
|
||||
doc.useOAuth2Client(await getAuthenticatedGoogleClient(credentialsId))
|
||||
await doc.loadInfo()
|
||||
const sheet = doc.sheetsById[sheetId]
|
||||
const rows = await sheet.getRows()
|
||||
const updatingRowIndex = rows.findIndex(
|
||||
(row) => row[referenceCell.column as string] === referenceCell.value
|
||||
)
|
||||
if (updatingRowIndex === -1)
|
||||
return res.status(404).send({ message: "Couldn't find row to update" })
|
||||
for (const key in values) {
|
||||
rows[updatingRowIndex][key] = values[key]
|
||||
}
|
||||
await rows[updatingRowIndex].save()
|
||||
return res.send({ message: 'Success' })
|
||||
}
|
||||
return methodNotAllowed(res)
|
||||
}
|
||||
|
||||
export default handler
|
||||
Reference in New Issue
Block a user