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

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