Added a dropdown selector for envvars to enhance UX for short-input and long-input sub-blocks

This commit is contained in:
Waleed Latif
2025-02-01 18:32:32 -08:00
parent 667704820d
commit 3fa92e245e
3 changed files with 218 additions and 5 deletions

View File

@@ -1,9 +1,10 @@
import { Textarea } from '@/components/ui/textarea'
import { useSubBlockValue } from '../hooks/use-sub-block-value'
import { cn } from '@/lib/utils'
import { useState, useRef, useEffect } from 'react'
import { useState, useRef } from 'react'
import { SubBlockConfig } from '@/blocks/types'
import { formatDisplayText } from '@/components/ui/formatted-text'
import { EnvVarDropdown, checkEnvVarTrigger } from '@/components/ui/env-var-dropdown'
interface LongInputProps {
placeholder?: string
@@ -21,9 +22,23 @@ export function LongInput({
config,
}: LongInputProps) {
const [value, setValue] = useSubBlockValue(blockId, subBlockId)
const [showEnvVars, setShowEnvVars] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [cursorPosition, setCursorPosition] = useState(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const overlayRef = useRef<HTMLDivElement>(null)
// Handle input changes
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
setValue(newValue)
setCursorPosition(newCursorPosition)
const { show, searchTerm } = checkEnvVarTrigger(newValue, newCursorPosition)
setShowEnvVars(show)
setSearchTerm(searchTerm)
}
// Sync scroll position between textarea and overlay
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
if (overlayRef.current) {
@@ -64,6 +79,13 @@ export function LongInput({
e.preventDefault()
}
// Handle key combinations
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
setShowEnvVars(false)
}
}
return (
<div className="relative w-full">
<Textarea
@@ -77,10 +99,15 @@ export function LongInput({
rows={4}
placeholder={placeholder ?? ''}
value={value?.toString() ?? ''}
onChange={(e) => setValue(e.target.value)}
onChange={handleChange}
onDrop={handleDrop}
onDragOver={handleDragOver}
onScroll={handleScroll}
onKeyDown={handleKeyDown}
onFocus={() => {
setShowEnvVars(false)
setSearchTerm('')
}}
/>
<div
ref={overlayRef}
@@ -88,6 +115,17 @@ export function LongInput({
>
{formatDisplayText(value?.toString() ?? '')}
</div>
<EnvVarDropdown
visible={showEnvVars}
onSelect={setValue}
searchTerm={searchTerm}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowEnvVars(false)
setSearchTerm('')
}}
/>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { useSubBlockValue } from '../hooks/use-sub-block-value'
import { cn } from '@/lib/utils'
import { SubBlockConfig } from '@/blocks/types'
import { formatDisplayText } from '@/components/ui/formatted-text'
import { EnvVarDropdown, checkEnvVarTrigger } from '@/components/ui/env-var-dropdown'
interface ShortInputProps {
placeholder?: string
@@ -23,10 +24,24 @@ export function ShortInput({
config,
}: ShortInputProps) {
const [isFocused, setIsFocused] = useState(false)
const [showEnvVars, setShowEnvVars] = useState(false)
const [value, setValue] = useSubBlockValue(blockId, subBlockId)
const [searchTerm, setSearchTerm] = useState('')
const [cursorPosition, setCursorPosition] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const overlayRef = useRef<HTMLDivElement>(null)
// Handle input changes
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
setValue(newValue)
setCursorPosition(newCursorPosition)
const { show, searchTerm } = checkEnvVarTrigger(newValue, newCursorPosition)
setShowEnvVars(show)
setSearchTerm(searchTerm)
}
// Sync scroll position between input and overlay
const handleScroll = (e: React.UIEvent<HTMLInputElement>) => {
if (overlayRef.current) {
@@ -76,7 +91,14 @@ export function ShortInput({
}
const handleDragOver = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault() // This is needed to allow drops
e.preventDefault()
}
// Handle key combinations
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setShowEnvVars(false)
}
}
// Value display logic
@@ -98,12 +120,17 @@ export function ShortInput({
placeholder={placeholder ?? ''}
type="text"
value={displayValue}
onChange={(e) => setValue(e.target.value)}
onFocus={() => setIsFocused(true)}
onChange={handleChange}
onFocus={() => {
setIsFocused(true)
setShowEnvVars(false)
setSearchTerm('')
}}
onBlur={() => setIsFocused(false)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onScroll={handleScroll}
onKeyDown={handleKeyDown}
autoComplete="off"
/>
<div
@@ -114,6 +141,17 @@ export function ShortInput({
? '•'.repeat(value?.toString().length ?? 0)
: formatDisplayText(value?.toString() ?? '')}
</div>
<EnvVarDropdown
visible={showEnvVars}
onSelect={setValue}
searchTerm={searchTerm}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowEnvVars(false)
setSearchTerm('')
}}
/>
</div>
)
}

View File

@@ -0,0 +1,137 @@
import React, { useState, useEffect } from 'react'
import { useEnvironmentStore } from '@/stores/environment/store'
import { cn } from '@/lib/utils'
interface EnvVarDropdownProps {
visible: boolean
onSelect: (newValue: string) => void
searchTerm?: string
className?: string
inputValue: string
cursorPosition: number
onClose?: () => void
}
export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
visible,
onSelect,
searchTerm = '',
className,
inputValue,
cursorPosition,
onClose
}) => {
const envVars = useEnvironmentStore((state) => Object.keys(state.variables))
const [selectedIndex, setSelectedIndex] = useState(0)
// Filter env vars based on search term
const filteredEnvVars = envVars.filter(envVar =>
envVar.toLowerCase().includes(searchTerm.toLowerCase())
)
// Reset selection when filtered results change
useEffect(() => {
setSelectedIndex(0)
}, [searchTerm])
// Handle environment variable selection
const handleEnvVarSelect = (envVar: string) => {
const textBeforeCursor = inputValue.slice(0, cursorPosition)
const textAfterCursor = inputValue.slice(cursorPosition)
// Find the position of the last '{{' before cursor
const lastOpenBraces = textBeforeCursor.lastIndexOf('{{')
if (lastOpenBraces === -1) return
const newValue = textBeforeCursor.slice(0, lastOpenBraces) +
'{{' + envVar + '}}' +
textAfterCursor
onSelect(newValue)
onClose?.()
}
// Handle keyboard navigation
const handleKeyDown = (e: KeyboardEvent) => {
if (!visible || filteredEnvVars.length === 0) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex(prev =>
prev < filteredEnvVars.length - 1 ? prev + 1 : prev
)
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex(prev => prev > 0 ? prev - 1 : prev)
break
case 'Enter':
e.preventDefault()
handleEnvVarSelect(filteredEnvVars[selectedIndex])
break
case 'Escape':
e.preventDefault()
onClose?.()
break
}
}
// Add and remove keyboard event listener
useEffect(() => {
if (visible) {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}
}, [visible, selectedIndex, filteredEnvVars])
if (!visible) return null
return (
<div
className={cn(
"absolute z-[9999] w-full mt-1 overflow-hidden bg-popover rounded-md border shadow-md",
className
)}
>
{filteredEnvVars.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
No matching environment variables
</div>
) : (
<div className="py-1">
{filteredEnvVars.map((envVar, index) => (
<button
key={envVar}
className={cn(
"w-full px-3 py-1.5 text-sm text-left",
"hover:bg-accent hover:text-accent-foreground",
"focus:bg-accent focus:text-accent-foreground focus:outline-none",
index === selectedIndex && "bg-accent text-accent-foreground"
)}
onMouseEnter={() => setSelectedIndex(index)}
onMouseDown={(e) => {
e.preventDefault() // Prevent input blur
handleEnvVarSelect(envVar)
}}
>
{envVar}
</button>
))}
</div>
)}
</div>
)
}
// Helper function to check for '{{' trigger and get search term
export const checkEnvVarTrigger = (text: string, cursorPosition: number): { show: boolean; searchTerm: string } => {
if (cursorPosition >= 2) {
const textBeforeCursor = text.slice(0, cursorPosition)
const match = textBeforeCursor.match(/\{\{(\w*)$/)
if (match) {
return { show: true, searchTerm: match[1] }
}
}
return { show: false, searchTerm: '' }
}