feat(integration): ✨ Add Google Sheets integration
This commit is contained in:
@ -1,5 +1,5 @@
|
||||
import { Button, ButtonProps, Flex, HStack } from '@chakra-ui/react'
|
||||
import { BubbleStepType, InputStepType, StepType, LogicStepType } from 'models'
|
||||
import { StepType, DraggableStepType } from 'models'
|
||||
import { useDnd } from 'contexts/DndContext'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { StepIcon } from './StepIcon'
|
||||
@ -9,11 +9,8 @@ export const StepCard = ({
|
||||
type,
|
||||
onMouseDown,
|
||||
}: {
|
||||
type: BubbleStepType | InputStepType | LogicStepType
|
||||
onMouseDown: (
|
||||
e: React.MouseEvent,
|
||||
type: BubbleStepType | InputStepType | LogicStepType
|
||||
) => void
|
||||
type: DraggableStepType
|
||||
onMouseDown: (e: React.MouseEvent, type: DraggableStepType) => void
|
||||
}) => {
|
||||
const { draggedStepType } = useDnd()
|
||||
const [isMouseDown, setIsMouseDown] = useState(false)
|
||||
|
@ -12,48 +12,45 @@ import {
|
||||
PhoneIcon,
|
||||
TextIcon,
|
||||
} from 'assets/icons'
|
||||
import { BubbleStepType, InputStepType, LogicStepType, StepType } from 'models'
|
||||
import { GoogleSheetsLogo } from 'assets/logos'
|
||||
import {
|
||||
BubbleStepType,
|
||||
InputStepType,
|
||||
IntegrationStepType,
|
||||
LogicStepType,
|
||||
StepType,
|
||||
} from 'models'
|
||||
import React from 'react'
|
||||
|
||||
type StepIconProps = { type: StepType } & IconProps
|
||||
|
||||
export const StepIcon = ({ type, ...props }: StepIconProps) => {
|
||||
switch (type) {
|
||||
case BubbleStepType.TEXT: {
|
||||
case BubbleStepType.TEXT:
|
||||
return <ChatIcon {...props} />
|
||||
}
|
||||
case InputStepType.TEXT: {
|
||||
case InputStepType.TEXT:
|
||||
return <TextIcon {...props} />
|
||||
}
|
||||
case InputStepType.NUMBER: {
|
||||
case InputStepType.NUMBER:
|
||||
return <NumberIcon {...props} />
|
||||
}
|
||||
case InputStepType.EMAIL: {
|
||||
case InputStepType.EMAIL:
|
||||
return <EmailIcon {...props} />
|
||||
}
|
||||
case InputStepType.URL: {
|
||||
case InputStepType.URL:
|
||||
return <GlobeIcon {...props} />
|
||||
}
|
||||
case InputStepType.DATE: {
|
||||
case InputStepType.DATE:
|
||||
return <CalendarIcon {...props} />
|
||||
}
|
||||
case InputStepType.PHONE: {
|
||||
case InputStepType.PHONE:
|
||||
return <PhoneIcon {...props} />
|
||||
}
|
||||
case InputStepType.CHOICE: {
|
||||
case InputStepType.CHOICE:
|
||||
return <CheckSquareIcon {...props} />
|
||||
}
|
||||
case LogicStepType.SET_VARIABLE: {
|
||||
case LogicStepType.SET_VARIABLE:
|
||||
return <EditIcon {...props} />
|
||||
}
|
||||
case LogicStepType.CONDITION: {
|
||||
case LogicStepType.CONDITION:
|
||||
return <FilterIcon {...props} />
|
||||
}
|
||||
case 'start': {
|
||||
case IntegrationStepType.GOOGLE_SHEETS:
|
||||
return <GoogleSheetsLogo {...props} />
|
||||
case 'start':
|
||||
return <FlagIcon {...props} />
|
||||
}
|
||||
default: {
|
||||
default:
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,11 @@
|
||||
import { Text } from '@chakra-ui/react'
|
||||
import { BubbleStepType, InputStepType, LogicStepType, StepType } from 'models'
|
||||
import {
|
||||
BubbleStepType,
|
||||
InputStepType,
|
||||
IntegrationStepType,
|
||||
LogicStepType,
|
||||
StepType,
|
||||
} from 'models'
|
||||
import React from 'react'
|
||||
|
||||
type Props = { type: StepType }
|
||||
@ -34,6 +40,9 @@ export const StepTypeLabel = ({ type }: Props) => {
|
||||
case LogicStepType.CONDITION: {
|
||||
return <Text>Condition</Text>
|
||||
}
|
||||
case IntegrationStepType.GOOGLE_SHEETS: {
|
||||
return <Text>Sheets</Text>
|
||||
}
|
||||
default: {
|
||||
return <></>
|
||||
}
|
||||
|
@ -5,7 +5,13 @@ import {
|
||||
SimpleGrid,
|
||||
useEventListener,
|
||||
} from '@chakra-ui/react'
|
||||
import { BubbleStepType, InputStepType, LogicStepType } from 'models'
|
||||
import {
|
||||
BubbleStepType,
|
||||
DraggableStepType,
|
||||
InputStepType,
|
||||
IntegrationStepType,
|
||||
LogicStepType,
|
||||
} from 'models'
|
||||
import { useDnd } from 'contexts/DndContext'
|
||||
import React, { useState } from 'react'
|
||||
import { StepCard, StepCardOverlay } from './StepCard'
|
||||
@ -29,10 +35,7 @@ export const StepTypesList = () => {
|
||||
}
|
||||
useEventListener('mousemove', handleMouseMove)
|
||||
|
||||
const handleMouseDown = (
|
||||
e: React.MouseEvent,
|
||||
type: BubbleStepType | InputStepType | LogicStepType
|
||||
) => {
|
||||
const handleMouseDown = (e: React.MouseEvent, type: DraggableStepType) => {
|
||||
const element = e.currentTarget as HTMLDivElement
|
||||
const rect = element.getBoundingClientRect()
|
||||
const relativeX = e.clientX - rect.left
|
||||
@ -94,6 +97,15 @@ export const StepTypesList = () => {
|
||||
<StepCard key={type} type={type} onMouseDown={handleMouseDown} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
|
||||
Integrations
|
||||
</Text>
|
||||
<SimpleGrid columns={2} spacing="2">
|
||||
{Object.values(IntegrationStepType).map((type) => (
|
||||
<StepCard key={type} type={type} onMouseDown={handleMouseDown} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{draggedStepType && (
|
||||
<StepCardOverlay
|
||||
type={draggedStepType}
|
||||
|
@ -3,17 +3,16 @@ import {
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
useEventListener,
|
||||
Portal,
|
||||
} from '@chakra-ui/react'
|
||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||
import {
|
||||
ChoiceInputOptions,
|
||||
ConditionOptions,
|
||||
InputStep,
|
||||
InputStepType,
|
||||
IntegrationStepType,
|
||||
LogicStepType,
|
||||
SetVariableOptions,
|
||||
Step,
|
||||
TextInputOptions,
|
||||
StepOptions,
|
||||
} from 'models'
|
||||
import { useRef } from 'react'
|
||||
import {
|
||||
@ -25,6 +24,7 @@ import {
|
||||
} from './bodies'
|
||||
import { ChoiceInputSettingsBody } from './bodies/ChoiceInputSettingsBody'
|
||||
import { ConditionSettingsBody } from './bodies/ConditionSettingsBody'
|
||||
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
|
||||
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
|
||||
import { SetVariableSettingsBody } from './bodies/SetVariableSettingsBody'
|
||||
|
||||
@ -41,24 +41,21 @@ export const SettingsPopoverContent = ({ step }: Props) => {
|
||||
}
|
||||
useEventListener('wheel', handleMouseWheel, ref.current)
|
||||
return (
|
||||
<PopoverContent onMouseDown={handleMouseDown}>
|
||||
<PopoverArrow />
|
||||
<PopoverBody p="6" overflowY="scroll" maxH="400px" ref={ref}>
|
||||
<SettingsPopoverBodyContent step={step} />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
<Portal>
|
||||
<PopoverContent onMouseDown={handleMouseDown}>
|
||||
<PopoverArrow />
|
||||
<PopoverBody p="6" overflowY="scroll" maxH="400px" ref={ref}>
|
||||
<SettingsPopoverBodyContent step={step} />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingsPopoverBodyContent = ({ step }: Props) => {
|
||||
const { updateStep } = useTypebot()
|
||||
const handleOptionsChange = (
|
||||
options:
|
||||
| TextInputOptions
|
||||
| ChoiceInputOptions
|
||||
| SetVariableOptions
|
||||
| ConditionOptions
|
||||
) => updateStep(step.id, { options } as Partial<InputStep>)
|
||||
const handleOptionsChange = (options: StepOptions) =>
|
||||
updateStep(step.id, { options } as Partial<InputStep>)
|
||||
|
||||
switch (step.type) {
|
||||
case InputStepType.TEXT: {
|
||||
@ -133,6 +130,15 @@ const SettingsPopoverBodyContent = ({ step }: Props) => {
|
||||
/>
|
||||
)
|
||||
}
|
||||
case IntegrationStepType.GOOGLE_SHEETS: {
|
||||
return (
|
||||
<GoogleSheetsSettingsBody
|
||||
options={step.options}
|
||||
onOptionsChange={handleOptionsChange}
|
||||
stepId={step.id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
default: {
|
||||
return <></>
|
||||
}
|
||||
|
@ -0,0 +1,136 @@
|
||||
import { Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
|
||||
import { PlusIcon, TrashIcon } from 'assets/icons'
|
||||
import { DropdownList } from 'components/shared/DropdownList'
|
||||
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
||||
import { ExtractingCell, Table, Variable } from 'models'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Sheet } from 'services/integrations'
|
||||
import { generate } from 'short-uuid'
|
||||
import { useImmer } from 'use-immer'
|
||||
|
||||
type Props = {
|
||||
sheet: Sheet
|
||||
initialCells?: Table<ExtractingCell>
|
||||
onCellsChange: (cells: Table<ExtractingCell>) => void
|
||||
}
|
||||
|
||||
const id = generate()
|
||||
const defaultCells: Table<ExtractingCell> = {
|
||||
byId: { [id]: {} },
|
||||
allIds: [id],
|
||||
}
|
||||
|
||||
export const ExtractCellList = ({
|
||||
sheet,
|
||||
initialCells,
|
||||
onCellsChange,
|
||||
}: Props) => {
|
||||
const [cells, setCells] = useImmer(initialCells ?? defaultCells)
|
||||
const [showDeleteId, setShowDeleteId] = useState<string | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
onCellsChange(cells)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cells])
|
||||
|
||||
const createCell = () => {
|
||||
setCells((cells) => {
|
||||
const id = generate()
|
||||
cells.byId[id] = {}
|
||||
cells.allIds.push(id)
|
||||
})
|
||||
}
|
||||
|
||||
const updateCell = (cellId: string, updates: Partial<ExtractingCell>) =>
|
||||
setCells((cells) => {
|
||||
cells.byId[cellId] = {
|
||||
...cells.byId[cellId],
|
||||
...updates,
|
||||
}
|
||||
})
|
||||
|
||||
const deleteCell = (cellId: string) => () => {
|
||||
setCells((cells) => {
|
||||
delete cells.byId[cellId]
|
||||
const index = cells.allIds.indexOf(cellId)
|
||||
if (index !== -1) cells.allIds.splice(index, 1)
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseEnter = (cellId: string) => () => {
|
||||
setShowDeleteId(cellId)
|
||||
}
|
||||
|
||||
const handleCellChange = (cellId: string) => (cell: ExtractingCell) =>
|
||||
updateCell(cellId, cell)
|
||||
|
||||
const handleMouseLeave = () => setShowDeleteId(undefined)
|
||||
|
||||
return (
|
||||
<Stack spacing="4">
|
||||
{cells.allIds.map((cellId) => (
|
||||
<>
|
||||
<Flex
|
||||
pos="relative"
|
||||
onMouseEnter={handleMouseEnter(cellId)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<CellWithVariableIdStack
|
||||
key={cellId}
|
||||
cell={cells.byId[cellId]}
|
||||
columns={sheet.columns}
|
||||
onCellChange={handleCellChange(cellId)}
|
||||
/>
|
||||
<Fade in={showDeleteId === cellId}>
|
||||
<IconButton
|
||||
icon={<TrashIcon />}
|
||||
aria-label="Remove cell"
|
||||
onClick={deleteCell(cellId)}
|
||||
pos="absolute"
|
||||
left="-10px"
|
||||
top="-10px"
|
||||
size="sm"
|
||||
/>
|
||||
</Fade>
|
||||
</Flex>
|
||||
</>
|
||||
))}
|
||||
<Button leftIcon={<PlusIcon />} onClick={createCell} flexShrink={0}>
|
||||
Add
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export const CellWithVariableIdStack = ({
|
||||
cell,
|
||||
columns,
|
||||
onCellChange,
|
||||
}: {
|
||||
cell: ExtractingCell
|
||||
columns: string[]
|
||||
onCellChange: (cell: ExtractingCell) => void
|
||||
}) => {
|
||||
const handleColumnSelect = (column: string) => {
|
||||
onCellChange({ ...cell, column })
|
||||
}
|
||||
const handleVariableIdChange = (variable: Variable) => {
|
||||
onCellChange({ ...cell, variableId: variable.id })
|
||||
}
|
||||
return (
|
||||
<Stack bgColor="blue.50" p="4" rounded="md" flex="1">
|
||||
<DropdownList<string>
|
||||
currentItem={cell.column}
|
||||
onItemSelect={handleColumnSelect}
|
||||
items={columns}
|
||||
bgColor="white"
|
||||
placeholder="Select a column"
|
||||
/>
|
||||
<VariableSearchInput
|
||||
initialVariableId={cell.variableId}
|
||||
onSelectVariable={handleVariableIdChange}
|
||||
placeholder="Select a variable"
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -0,0 +1,181 @@
|
||||
import { Divider, Stack, Text } from '@chakra-ui/react'
|
||||
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
|
||||
import { DropdownList } from 'components/shared/DropdownList'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { CredentialsType } from 'db'
|
||||
import {
|
||||
Cell,
|
||||
ExtractingCell,
|
||||
GoogleSheetsAction,
|
||||
GoogleSheetsOptions,
|
||||
Table,
|
||||
} from 'models'
|
||||
import React, { useMemo } from 'react'
|
||||
import {
|
||||
getGoogleSheetsConsentScreenUrl,
|
||||
Sheet,
|
||||
useSheets,
|
||||
} from 'services/integrations'
|
||||
import { isDefined } from 'utils'
|
||||
import { ExtractCellList } from './ExtractCellList'
|
||||
import { SheetsDropdown } from './SheetsDropdown'
|
||||
import { SpreadsheetsDropdown } from './SpreadsheetDropdown'
|
||||
import { CellWithValueStack, UpdateCellList } from './UpdateCellList'
|
||||
|
||||
type Props = {
|
||||
options?: GoogleSheetsOptions
|
||||
onOptionsChange: (options: GoogleSheetsOptions) => void
|
||||
stepId: string
|
||||
}
|
||||
|
||||
export const GoogleSheetsSettingsBody = ({
|
||||
options,
|
||||
onOptionsChange,
|
||||
stepId,
|
||||
}: Props) => {
|
||||
const { save, hasUnsavedChanges } = useTypebot()
|
||||
const { sheets, isLoading } = useSheets({
|
||||
credentialsId: options?.credentialsId,
|
||||
spreadsheetId: options?.spreadsheetId,
|
||||
})
|
||||
const sheet = useMemo(
|
||||
() => sheets?.find((s) => s.id === options?.sheetId),
|
||||
[sheets, options?.sheetId]
|
||||
)
|
||||
const handleCredentialsIdChange = (credentialsId: string) =>
|
||||
onOptionsChange({ ...options, credentialsId })
|
||||
const handleSpreadsheetIdChange = (spreadsheetId: string) =>
|
||||
onOptionsChange({ ...options, spreadsheetId })
|
||||
const handleSheetIdChange = (sheetId: string) =>
|
||||
onOptionsChange({ ...options, sheetId })
|
||||
const handleActionChange = (action: GoogleSheetsAction) =>
|
||||
onOptionsChange({ ...options, action })
|
||||
|
||||
const handleCreateNewClick = async () => {
|
||||
if (hasUnsavedChanges) {
|
||||
const errorToastId = await save()
|
||||
if (errorToastId) return
|
||||
}
|
||||
const linkElement = document.createElement('a')
|
||||
linkElement.href = getGoogleSheetsConsentScreenUrl(
|
||||
window.location.href,
|
||||
stepId
|
||||
)
|
||||
linkElement.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<CredentialsDropdown
|
||||
type={CredentialsType.GOOGLE_SHEETS}
|
||||
currentCredentialsId={options?.credentialsId}
|
||||
onCredentialsSelect={handleCredentialsIdChange}
|
||||
onCreateNewClick={handleCreateNewClick}
|
||||
/>
|
||||
{options?.credentialsId && (
|
||||
<SpreadsheetsDropdown
|
||||
credentialsId={options.credentialsId}
|
||||
spreadsheetId={options.spreadsheetId}
|
||||
onSelectSpreadsheetId={handleSpreadsheetIdChange}
|
||||
/>
|
||||
)}
|
||||
{options?.spreadsheetId && options.credentialsId && (
|
||||
<SheetsDropdown
|
||||
sheets={sheets ?? []}
|
||||
isLoading={isLoading}
|
||||
sheetId={options.sheetId}
|
||||
onSelectSheetId={handleSheetIdChange}
|
||||
/>
|
||||
)}
|
||||
{options?.spreadsheetId &&
|
||||
options.credentialsId &&
|
||||
isDefined(options.sheetId) && (
|
||||
<>
|
||||
<Divider />
|
||||
<DropdownList<GoogleSheetsAction>
|
||||
currentItem={options.action}
|
||||
onItemSelect={handleActionChange}
|
||||
items={Object.values(GoogleSheetsAction)}
|
||||
placeholder="Select an operation"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{sheet && options?.action && (
|
||||
<ActionOptions
|
||||
options={options}
|
||||
sheet={sheet}
|
||||
onOptionsChange={onOptionsChange}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const ActionOptions = ({
|
||||
options,
|
||||
sheet,
|
||||
onOptionsChange,
|
||||
}: {
|
||||
options: GoogleSheetsOptions
|
||||
sheet: Sheet
|
||||
onOptionsChange: (options: GoogleSheetsOptions) => void
|
||||
}) => {
|
||||
const handleInsertColumnsChange = (cellsToInsert: Table<Cell>) =>
|
||||
onOptionsChange({ ...options, cellsToInsert } as GoogleSheetsOptions)
|
||||
|
||||
const handleUpsertColumnsChange = (cellsToUpsert: Table<Cell>) =>
|
||||
onOptionsChange({ ...options, cellsToUpsert } as GoogleSheetsOptions)
|
||||
|
||||
const handleReferenceCellChange = (referenceCell: Cell) =>
|
||||
onOptionsChange({ ...options, referenceCell } as GoogleSheetsOptions)
|
||||
|
||||
const handleExtractingCellsChange = (cellsToExtract: Table<ExtractingCell>) =>
|
||||
onOptionsChange({ ...options, cellsToExtract } as GoogleSheetsOptions)
|
||||
|
||||
switch (options.action) {
|
||||
case GoogleSheetsAction.INSERT_ROW:
|
||||
return (
|
||||
<UpdateCellList
|
||||
initialCells={options.cellsToInsert}
|
||||
sheet={sheet}
|
||||
onCellsChange={handleInsertColumnsChange}
|
||||
/>
|
||||
)
|
||||
case GoogleSheetsAction.UPDATE_ROW:
|
||||
return (
|
||||
<Stack>
|
||||
<Text>Row to select</Text>
|
||||
<CellWithValueStack
|
||||
cell={options.referenceCell ?? {}}
|
||||
columns={sheet.columns}
|
||||
onCellChange={handleReferenceCellChange}
|
||||
/>
|
||||
<Text>Cells to update</Text>
|
||||
<UpdateCellList
|
||||
initialCells={options.cellsToUpsert}
|
||||
sheet={sheet}
|
||||
onCellsChange={handleUpsertColumnsChange}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
case GoogleSheetsAction.GET:
|
||||
return (
|
||||
<Stack>
|
||||
<Text>Row to select</Text>
|
||||
<CellWithValueStack
|
||||
cell={options.referenceCell ?? {}}
|
||||
columns={sheet.columns}
|
||||
onCellChange={handleReferenceCellChange}
|
||||
/>
|
||||
<Text>Cells to extract</Text>
|
||||
<ExtractCellList
|
||||
initialCells={options.cellsToExtract}
|
||||
sheet={sheet}
|
||||
onCellsChange={handleExtractingCellsChange}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
default:
|
||||
return <></>
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
|
||||
import { useMemo } from 'react'
|
||||
import { Sheet } from 'services/integrations'
|
||||
import { isDefined } from 'utils'
|
||||
|
||||
type Props = {
|
||||
sheets: Sheet[]
|
||||
isLoading: boolean
|
||||
sheetId?: string
|
||||
onSelectSheetId: (id: string) => void
|
||||
}
|
||||
|
||||
export const SheetsDropdown = ({
|
||||
sheets,
|
||||
isLoading,
|
||||
sheetId,
|
||||
onSelectSheetId,
|
||||
}: Props) => {
|
||||
const currentSheet = useMemo(
|
||||
() => sheets?.find((s) => s.id === sheetId),
|
||||
[sheetId, sheets]
|
||||
)
|
||||
|
||||
const handleSpreadsheetSelect = (name: string) => {
|
||||
const id = sheets?.find((s) => s.name === name)?.id
|
||||
if (isDefined(id)) onSelectSheetId(id)
|
||||
}
|
||||
return (
|
||||
<SearchableDropdown
|
||||
selectedItem={currentSheet?.name}
|
||||
items={(sheets ?? []).map((s) => s.name)}
|
||||
onSelectItem={handleSpreadsheetSelect}
|
||||
placeholder={isLoading ? 'Loading...' : 'Select the sheet'}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
|
||||
import { useMemo } from 'react'
|
||||
import { useSpreadsheets } from 'services/integrations'
|
||||
|
||||
type Props = {
|
||||
credentialsId: string
|
||||
spreadsheetId?: string
|
||||
onSelectSpreadsheetId: (id: string) => void
|
||||
}
|
||||
|
||||
export const SpreadsheetsDropdown = ({
|
||||
credentialsId,
|
||||
spreadsheetId,
|
||||
onSelectSpreadsheetId,
|
||||
}: Props) => {
|
||||
const { spreadsheets, isLoading } = useSpreadsheets({ credentialsId })
|
||||
const currentSpreadsheet = useMemo(
|
||||
() => spreadsheets?.find((s) => s.id === spreadsheetId),
|
||||
[spreadsheetId, spreadsheets]
|
||||
)
|
||||
|
||||
const handleSpreadsheetSelect = (name: string) => {
|
||||
const id = spreadsheets?.find((s) => s.name === name)?.id
|
||||
if (id) onSelectSpreadsheetId(id)
|
||||
}
|
||||
return (
|
||||
<SearchableDropdown
|
||||
selectedItem={currentSpreadsheet?.name}
|
||||
items={(spreadsheets ?? []).map((s) => s.name)}
|
||||
onSelectItem={handleSpreadsheetSelect}
|
||||
placeholder={isLoading ? 'Loading...' : 'Search for spreadsheet'}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,136 @@
|
||||
import { Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
|
||||
import { PlusIcon, TrashIcon } from 'assets/icons'
|
||||
import { DropdownList } from 'components/shared/DropdownList'
|
||||
import { InputWithVariable } from 'components/shared/InputWithVariable'
|
||||
import { Cell, Table } from 'models'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Sheet } from 'services/integrations'
|
||||
import { generate } from 'short-uuid'
|
||||
import { useImmer } from 'use-immer'
|
||||
|
||||
type Props = {
|
||||
sheet: Sheet
|
||||
initialCells?: Table<Cell>
|
||||
onCellsChange: (cells: Table<Cell>) => void
|
||||
}
|
||||
|
||||
const id = generate()
|
||||
const defaultCells: Table<Cell> = {
|
||||
byId: { [id]: {} },
|
||||
allIds: [id],
|
||||
}
|
||||
|
||||
export const UpdateCellList = ({
|
||||
sheet,
|
||||
initialCells,
|
||||
onCellsChange,
|
||||
}: Props) => {
|
||||
const [cells, setCells] = useImmer(initialCells ?? defaultCells)
|
||||
const [showDeleteId, setShowDeleteId] = useState<string | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
onCellsChange(cells)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cells])
|
||||
|
||||
const createCell = () => {
|
||||
setCells((cells) => {
|
||||
const id = generate()
|
||||
cells.byId[id] = {}
|
||||
cells.allIds.push(id)
|
||||
})
|
||||
}
|
||||
|
||||
const updateCell = (cellId: string, updates: Partial<Cell>) =>
|
||||
setCells((cells) => {
|
||||
cells.byId[cellId] = {
|
||||
...cells.byId[cellId],
|
||||
...updates,
|
||||
}
|
||||
})
|
||||
|
||||
const deleteCell = (cellId: string) => () => {
|
||||
setCells((cells) => {
|
||||
delete cells.byId[cellId]
|
||||
const index = cells.allIds.indexOf(cellId)
|
||||
if (index !== -1) cells.allIds.splice(index, 1)
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseEnter = (cellId: string) => () => {
|
||||
setShowDeleteId(cellId)
|
||||
}
|
||||
|
||||
const handleCellChange = (cellId: string) => (cell: Cell) =>
|
||||
updateCell(cellId, cell)
|
||||
|
||||
const handleMouseLeave = () => setShowDeleteId(undefined)
|
||||
|
||||
return (
|
||||
<Stack spacing="4">
|
||||
{cells.allIds.map((cellId) => (
|
||||
<>
|
||||
<Flex
|
||||
pos="relative"
|
||||
onMouseEnter={handleMouseEnter(cellId)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<CellWithValueStack
|
||||
key={cellId}
|
||||
cell={cells.byId[cellId]}
|
||||
columns={sheet.columns}
|
||||
onCellChange={handleCellChange(cellId)}
|
||||
/>
|
||||
<Fade in={showDeleteId === cellId}>
|
||||
<IconButton
|
||||
icon={<TrashIcon />}
|
||||
aria-label="Remove cell"
|
||||
onClick={deleteCell(cellId)}
|
||||
pos="absolute"
|
||||
left="-10px"
|
||||
top="-10px"
|
||||
size="sm"
|
||||
/>
|
||||
</Fade>
|
||||
</Flex>
|
||||
</>
|
||||
))}
|
||||
<Button leftIcon={<PlusIcon />} onClick={createCell} flexShrink={0}>
|
||||
Add
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export const CellWithValueStack = ({
|
||||
cell,
|
||||
columns,
|
||||
onCellChange,
|
||||
}: {
|
||||
cell: Cell
|
||||
columns: string[]
|
||||
onCellChange: (column: Cell) => void
|
||||
}) => {
|
||||
const handleColumnSelect = (column: string) => {
|
||||
onCellChange({ ...cell, column })
|
||||
}
|
||||
const handleValueChange = (value: string) => {
|
||||
onCellChange({ ...cell, value })
|
||||
}
|
||||
return (
|
||||
<Stack bgColor="blue.50" p="4" rounded="md" flex="1">
|
||||
<DropdownList<string>
|
||||
currentItem={cell.column}
|
||||
onItemSelect={handleColumnSelect}
|
||||
items={columns}
|
||||
bgColor="white"
|
||||
placeholder="Select a column"
|
||||
/>
|
||||
<InputWithVariable
|
||||
initialValue={cell.value ?? ''}
|
||||
onValueChange={handleValueChange}
|
||||
placeholder="Type a value..."
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { GoogleSheetsSettingsBody } from './GoogleSheetsSettingsBody'
|
@ -7,21 +7,27 @@ import {
|
||||
useEventListener,
|
||||
} from '@chakra-ui/react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Block, Step } from 'models'
|
||||
import { Block, DraggableStep, Step } from 'models'
|
||||
import { useGraph } from 'contexts/GraphContext'
|
||||
import { StepIcon } from 'components/board/StepTypesList/StepIcon'
|
||||
import { isDefined, isInputStep, isLogicStep, isTextBubbleStep } from 'utils'
|
||||
import {
|
||||
isDefined,
|
||||
isInputStep,
|
||||
isLogicStep,
|
||||
isTextBubbleStep,
|
||||
isIntegrationStep,
|
||||
} from 'utils'
|
||||
import { Coordinates } from '@dnd-kit/core/dist/types'
|
||||
import { TextEditor } from './TextEditor/TextEditor'
|
||||
import { StepNodeContent } from './StepNodeContent'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { ContextMenu } from 'components/shared/ContextMenu'
|
||||
import { SettingsPopoverContent } from './SettingsPopoverContent'
|
||||
import { DraggableStep } from 'contexts/DndContext'
|
||||
import { StepNodeContextMenu } from './StepNodeContextMenu'
|
||||
import { SourceEndpoint } from './SourceEndpoint'
|
||||
import { hasDefaultConnector } from 'services/typebots'
|
||||
import { TargetEndpoint } from './TargetEndpoint'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
export const StepNode = ({
|
||||
step,
|
||||
@ -39,6 +45,7 @@ export const StepNode = ({
|
||||
step: DraggableStep
|
||||
) => void
|
||||
}) => {
|
||||
const { query } = useRouter()
|
||||
const { setConnectingIds, connectingIds } = useGraph()
|
||||
const { moveStep, typebot } = useTypebot()
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
@ -152,7 +159,11 @@ export const StepNode = ({
|
||||
renderMenu={() => <StepNodeContextMenu stepId={step.id} />}
|
||||
>
|
||||
{(ref, isOpened) => (
|
||||
<Popover placement="left" isLazy>
|
||||
<Popover
|
||||
placement="left"
|
||||
isLazy
|
||||
defaultIsOpen={query.stepId?.toString() === step.id}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Flex
|
||||
pos="relative"
|
||||
@ -226,11 +237,12 @@ export const StepNode = ({
|
||||
)}
|
||||
</Flex>
|
||||
</PopoverTrigger>
|
||||
{(isInputStep(step) || isLogicStep(step)) && (
|
||||
<SettingsPopoverContent step={step} />
|
||||
)}
|
||||
{hasPopover(step) && <SettingsPopoverContent step={step} />}
|
||||
</Popover>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const hasPopover = (step: Step) =>
|
||||
isInputStep(step) || isLogicStep(step) || isIntegrationStep(step)
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
LogicStepType,
|
||||
SetVariableStep,
|
||||
ConditionStep,
|
||||
IntegrationStepType,
|
||||
} from 'models'
|
||||
import { ChoiceItemsList } from './ChoiceInputStepNode/ChoiceItemsList'
|
||||
import { SourceEndpoint } from './SourceEndpoint'
|
||||
@ -84,6 +85,10 @@ export const StepNodeContent = ({ step }: Props) => {
|
||||
case LogicStepType.CONDITION: {
|
||||
return <ConditionNodeContent step={step} />
|
||||
}
|
||||
case IntegrationStepType.GOOGLE_SHEETS: {
|
||||
if (!step.options) return <Text color={'gray.500'}>Configure...</Text>
|
||||
return <Text>{step.options?.action}</Text>
|
||||
}
|
||||
case 'start': {
|
||||
return <Text>{step.label}</Text>
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react'
|
||||
import { Step, Table } from 'models'
|
||||
import { DraggableStep, useDnd } from 'contexts/DndContext'
|
||||
import { DraggableStep, Step, Table } from 'models'
|
||||
import { useDnd } from 'contexts/DndContext'
|
||||
import { Coordinates } from 'contexts/GraphContext'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { StepNode, StepNodeOverlay } from './StepNode'
|
||||
|
@ -2,10 +2,11 @@ import { Flex, FlexProps, useEventListener } from '@chakra-ui/react'
|
||||
import React, { useRef, useMemo } from 'react'
|
||||
import { blockWidth, useGraph } from 'contexts/GraphContext'
|
||||
import { BlockNode } from './BlockNode/BlockNode'
|
||||
import { DraggableStepType, useDnd } from 'contexts/DndContext'
|
||||
import { useDnd } from 'contexts/DndContext'
|
||||
import { Edges } from './Edges'
|
||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||
import { headerHeight } from 'components/shared/TypebotHeader/TypebotHeader'
|
||||
import { DraggableStepType } from 'models'
|
||||
|
||||
const Graph = ({ ...props }: FlexProps) => {
|
||||
const { draggedStepType, setDraggedStepType, draggedStep, setDraggedStep } =
|
||||
|
104
apps/builder/components/shared/CredentialsDropdown.tsx
Normal file
104
apps/builder/components/shared/CredentialsDropdown.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuButtonProps,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon, PlusIcon } from 'assets/icons'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { CredentialsType } from 'db'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
type Props = Omit<MenuButtonProps, 'type'> & {
|
||||
type: CredentialsType
|
||||
currentCredentialsId?: string
|
||||
onCredentialsSelect: (credentialId: string) => void
|
||||
onCreateNewClick: () => void
|
||||
}
|
||||
|
||||
export const CredentialsDropdown = ({
|
||||
type,
|
||||
currentCredentialsId,
|
||||
onCredentialsSelect,
|
||||
onCreateNewClick,
|
||||
...props
|
||||
}: Props) => {
|
||||
const router = useRouter()
|
||||
const { credentials } = useUser()
|
||||
|
||||
const credentialsList = useMemo(() => {
|
||||
return credentials.filter((credential) => credential.type === type)
|
||||
}, [type, credentials])
|
||||
|
||||
const currentCredential = useMemo(
|
||||
() => credentials.find((c) => c.id === currentCredentialsId),
|
||||
[currentCredentialsId, credentials]
|
||||
)
|
||||
|
||||
const handleMenuItemClick = (credentialId: string) => () => {
|
||||
onCredentialsSelect(credentialId)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return
|
||||
if (router.query.credentialsId) {
|
||||
handleMenuItemClick(router.query.credentialsId.toString())()
|
||||
clearQueryParams()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router.isReady])
|
||||
|
||||
const clearQueryParams = () => {
|
||||
const hasQueryParams = router.asPath.includes('?')
|
||||
if (hasQueryParams)
|
||||
router.push(router.asPath.split('?')[0], undefined, { shallow: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu isLazy placement="bottom-end" matchWidth>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
|
||||
colorScheme="gray"
|
||||
justifyContent="space-between"
|
||||
textAlign="left"
|
||||
{...props}
|
||||
>
|
||||
<Text isTruncated overflowY="visible" h="20px">
|
||||
{currentCredential ? currentCredential.name : 'Select an account'}
|
||||
</Text>
|
||||
</MenuButton>
|
||||
<MenuList maxW="500px">
|
||||
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
|
||||
{credentialsList.map((credentials) => (
|
||||
<MenuItem
|
||||
key={credentials.id}
|
||||
maxW="500px"
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
onClick={handleMenuItemClick(credentials.id)}
|
||||
>
|
||||
{credentials.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem
|
||||
maxW="500px"
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
icon={<PlusIcon />}
|
||||
onClick={onCreateNewClick}
|
||||
>
|
||||
Connect new
|
||||
</MenuItem>
|
||||
</Stack>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)
|
||||
}
|
@ -1,5 +1,11 @@
|
||||
import { Input, InputProps } from '@chakra-ui/react'
|
||||
import { ChangeEvent, useEffect, useState } from 'react'
|
||||
import {
|
||||
ChangeEvent,
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
|
||||
type Props = Omit<InputProps, 'onChange' | 'value'> & {
|
||||
@ -8,24 +14,31 @@ type Props = Omit<InputProps, 'onChange' | 'value'> & {
|
||||
onChange: (debouncedValue: string) => void
|
||||
}
|
||||
|
||||
export const DebouncedInput = ({
|
||||
delay,
|
||||
onChange,
|
||||
initialValue,
|
||||
...props
|
||||
}: Props) => {
|
||||
const [currentValue, setCurrentValue] = useState(initialValue)
|
||||
const [currentValueDebounced] = useDebounce(currentValue, delay)
|
||||
export const DebouncedInput = forwardRef(
|
||||
(
|
||||
{ delay, onChange, initialValue, ...props }: Props,
|
||||
ref: ForwardedRef<HTMLInputElement>
|
||||
) => {
|
||||
const [currentValue, setCurrentValue] = useState(initialValue)
|
||||
const [currentValueDebounced] = useDebounce(currentValue, delay)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentValueDebounced === initialValue) return
|
||||
onChange(currentValueDebounced)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentValueDebounced])
|
||||
useEffect(() => {
|
||||
if (currentValueDebounced === initialValue) return
|
||||
onChange(currentValueDebounced)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentValueDebounced])
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setCurrentValue(e.target.value)
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setCurrentValue(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
ref={ref}
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <Input {...props} value={currentValue} onChange={handleChange} />
|
||||
}
|
||||
)
|
||||
|
@ -11,15 +11,17 @@ import { ChevronLeftIcon } from 'assets/icons'
|
||||
import React from 'react'
|
||||
|
||||
type Props<T> = {
|
||||
currentItem: T
|
||||
currentItem?: T
|
||||
onItemSelect: (item: T) => void
|
||||
items: T[]
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export const DropdownList = <T,>({
|
||||
currentItem,
|
||||
onItemSelect,
|
||||
items,
|
||||
placeholder = '',
|
||||
...props
|
||||
}: Props<T> & MenuButtonProps) => {
|
||||
const handleMenuItemClick = (operator: T) => () => {
|
||||
@ -27,7 +29,7 @@ export const DropdownList = <T,>({
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Menu isLazy placement="bottom-end">
|
||||
<Menu isLazy placement="bottom-end" matchWidth>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
|
||||
@ -37,9 +39,9 @@ export const DropdownList = <T,>({
|
||||
textAlign="left"
|
||||
{...props}
|
||||
>
|
||||
{currentItem}
|
||||
{currentItem ?? placeholder}
|
||||
</MenuButton>
|
||||
<MenuList maxW="500px">
|
||||
<MenuList maxW="500px" shadow="lg">
|
||||
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
|
||||
{items.map((item) => (
|
||||
<MenuItem
|
||||
|
105
apps/builder/components/shared/InputWithVariable.tsx
Normal file
105
apps/builder/components/shared/InputWithVariable.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import {
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputProps,
|
||||
InputRightElement,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@chakra-ui/react'
|
||||
import { UserIcon } from 'assets/icons'
|
||||
import { Variable } from 'models'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import { VariableSearchInput } from './VariableSearchInput'
|
||||
|
||||
export const InputWithVariable = ({
|
||||
initialValue,
|
||||
noAbsolute,
|
||||
onValueChange,
|
||||
...props
|
||||
}: {
|
||||
initialValue: string
|
||||
onValueChange: (value: string) => void
|
||||
noAbsolute?: boolean
|
||||
} & InputProps) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const [debouncedValue] = useDebounce(value, 100)
|
||||
const [carretPosition, setCarretPosition] = useState<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
onValueChange(debouncedValue)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedValue])
|
||||
|
||||
const handleVariableSelected = (variable: Variable) => {
|
||||
if (!inputRef.current) return
|
||||
const cursorPosition = carretPosition
|
||||
const textBeforeCursorPosition = inputRef.current.value.substring(
|
||||
0,
|
||||
cursorPosition
|
||||
)
|
||||
const textAfterCursorPosition = inputRef.current.value.substring(
|
||||
cursorPosition,
|
||||
inputRef.current.value.length
|
||||
)
|
||||
setValue(
|
||||
textBeforeCursorPosition +
|
||||
`{{${variable.name}}}` +
|
||||
textAfterCursorPosition
|
||||
)
|
||||
inputRef.current.focus()
|
||||
setTimeout(() => {
|
||||
if (!inputRef.current) return
|
||||
inputRef.current.selectionStart = inputRef.current.selectionEnd =
|
||||
carretPosition + `{{${variable.name}}}`.length
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleKeyUp = () => {
|
||||
if (!inputRef.current?.selectionStart) return
|
||||
setCarretPosition(inputRef.current.selectionStart)
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setValue(e.target.value)
|
||||
|
||||
return (
|
||||
<InputGroup>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
onKeyUp={handleKeyUp}
|
||||
onClick={handleKeyUp}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
bgColor={'white'}
|
||||
/>
|
||||
<InputRightElement
|
||||
pos={noAbsolute ? 'relative' : 'absolute'}
|
||||
zIndex={noAbsolute ? 'unset' : '1'}
|
||||
>
|
||||
<Popover matchWidth isLazy>
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
aria-label="Insert a variable"
|
||||
icon={<UserIcon />}
|
||||
size="sm"
|
||||
pos="relative"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w="full">
|
||||
<VariableSearchInput
|
||||
onSelectVariable={handleVariableSelected}
|
||||
placeholder="Search for a variable"
|
||||
shadow="lg"
|
||||
isDefaultOpen
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
@ -8,18 +8,21 @@ import {
|
||||
PopoverContent,
|
||||
Button,
|
||||
Text,
|
||||
InputProps,
|
||||
} from '@chakra-ui/react'
|
||||
import { useState, useRef, useEffect, ChangeEvent } from 'react'
|
||||
|
||||
type Props = {
|
||||
selectedItem?: string
|
||||
items: string[]
|
||||
onSelectItem: (value: string) => void
|
||||
} & InputProps
|
||||
export const SearchableDropdown = ({
|
||||
selectedItem,
|
||||
items,
|
||||
onSelectItem,
|
||||
}: {
|
||||
selectedItem?: string
|
||||
items: string[]
|
||||
onSelectItem: (value: string) => void
|
||||
}) => {
|
||||
...inputProps
|
||||
}: Props) => {
|
||||
const { onOpen, onClose, isOpen } = useDisclosure()
|
||||
const [inputValue, setInputValue] = useState(selectedItem)
|
||||
const [filteredItems, setFilteredItems] = useState([
|
||||
@ -64,19 +67,38 @@ export const SearchableDropdown = ({
|
||||
])
|
||||
}
|
||||
|
||||
const handleItemClick = (item: string) => () => {
|
||||
setInputValue(item)
|
||||
onSelectItem(item)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex ref={dropdownRef}>
|
||||
<Popover isOpen={isOpen} initialFocusRef={inputRef}>
|
||||
<Flex ref={dropdownRef} w="full">
|
||||
<Popover
|
||||
isOpen={isOpen}
|
||||
initialFocusRef={inputRef}
|
||||
matchWidth
|
||||
offset={[0, 0]}
|
||||
isLazy
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
onClick={onOpen}
|
||||
w="300px"
|
||||
{...inputProps}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent maxH="35vh" overflowY="scroll" spacing="0" w="300px">
|
||||
<PopoverContent
|
||||
maxH="35vh"
|
||||
overflowY="scroll"
|
||||
spacing="0"
|
||||
role="menu"
|
||||
w="inherit"
|
||||
shadow="lg"
|
||||
>
|
||||
{filteredItems.length > 0 ? (
|
||||
<>
|
||||
{filteredItems.map((item, idx) => {
|
||||
@ -84,15 +106,12 @@ export const SearchableDropdown = ({
|
||||
<Button
|
||||
minH="40px"
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
setInputValue(item)
|
||||
onSelectItem(item)
|
||||
onClose()
|
||||
}}
|
||||
onClick={handleItemClick(item)}
|
||||
fontSize="16px"
|
||||
fontWeight="normal"
|
||||
rounded="none"
|
||||
colorScheme="gray"
|
||||
role="menuitem"
|
||||
variant="ghost"
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
|
Reference in New Issue
Block a user