2
0

feat(integration): Add Google Sheets integration

This commit is contained in:
Baptiste Arnaud
2022-01-18 18:25:18 +01:00
parent 2814a352b2
commit f49b5143cf
67 changed files with 2560 additions and 391 deletions

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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)

View File

@@ -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 <></>
}
}
}

View File

@@ -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 <></>
}

View File

@@ -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}

View File

@@ -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 <></>
}

View File

@@ -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>
)
}

View File

@@ -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 <></>
}
}

View File

@@ -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}
/>
)
}

View File

@@ -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}
/>
)
}

View File

@@ -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>
)
}

View File

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

View File

@@ -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)

View File

@@ -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>
}

View File

@@ -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'

View File

@@ -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 } =

View 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>
)
}

View File

@@ -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} />
}
)

View File

@@ -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

View 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>
)
}

View File

@@ -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"
>

View File

@@ -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>>

View File

@@ -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

View File

@@ -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({

View File

@@ -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
) => {

View File

@@ -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}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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' }],

View File

@@ -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,
},
},

View 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()
}

View 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 },
})

View File

@@ -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 } }
)

View File

@@ -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",

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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,
}
}

View File

@@ -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,

View 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,
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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({

View File

@@ -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 = ''

View 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 },
})

View File

@@ -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",

View File

@@ -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