mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 23:48:09 -05:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06b1d82781 | ||
|
|
3d5d7474ed | ||
|
|
27794e59b3 | ||
|
|
88668fed84 | ||
|
|
fe5402a6d7 | ||
|
|
c436c2e378 | ||
|
|
60e905c520 |
File diff suppressed because one or more lines are too long
@@ -11,30 +11,17 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
icon={true}
|
||||
iconSvg={`<svg className="block-icon"
|
||||
|
||||
|
||||
|
||||
viewBox='-5 0 41 33'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 24 24'
|
||||
|
||||
|
||||
fill='none'
|
||||
>
|
||||
<circle cx='16' cy='16' r='14' fill='url(#paint0_linear_87_7225)' />
|
||||
<circle cx='12' cy='12' r='10' fill='#0088CC' />
|
||||
<path
|
||||
d='M22.9866 10.2088C23.1112 9.40332 22.3454 8.76755 21.6292 9.082L7.36482 15.3448C6.85123 15.5703 6.8888 16.3483 7.42147 16.5179L10.3631 17.4547C10.9246 17.6335 11.5325 17.541 12.0228 17.2023L18.655 12.6203C18.855 12.4821 19.073 12.7665 18.9021 12.9426L14.1281 17.8646C13.665 18.3421 13.7569 19.1512 14.314 19.5005L19.659 22.8523C20.2585 23.2282 21.0297 22.8506 21.1418 22.1261L22.9866 10.2088Z'
|
||||
d='M16.7 8.4c.1-.6-.4-1.1-1-.8l-9.8 4.3c-.4.2-.4.8.1.9l2.1.7c.4.1.8.1 1.1-.2l4.5-3.1c.1-.1.3.1.2.2l-3.2 3.5c-.3.3-.2.8.2 1l3.6 2.3c.4.2.9-.1 1-.5l1.2-7.8Z'
|
||||
fill='white'
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id='paint0_linear_87_7225'
|
||||
x1='16'
|
||||
y1='2'
|
||||
x2='16'
|
||||
y2='30'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#37BBFE' />
|
||||
<stop offset='1' stopColor='#007DBB' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>`}
|
||||
/>
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ const logger = createLogger('UserSettingsAPI')
|
||||
|
||||
const SettingsSchema = z.object({
|
||||
theme: z.enum(['system', 'light', 'dark']).optional(),
|
||||
debugMode: z.boolean().optional(),
|
||||
autoConnect: z.boolean().optional(),
|
||||
autoFillEnvVars: z.boolean().optional(),
|
||||
autoPan: z.boolean().optional(),
|
||||
consoleExpandedByDefault: z.boolean().optional(),
|
||||
telemetryEnabled: z.boolean().optional(),
|
||||
telemetryNotifiedUser: z.boolean().optional(),
|
||||
emailPreferences: z
|
||||
@@ -30,10 +30,10 @@ const SettingsSchema = z.object({
|
||||
// Default settings values
|
||||
const defaultSettings = {
|
||||
theme: 'system',
|
||||
debugMode: false,
|
||||
autoConnect: true,
|
||||
autoFillEnvVars: true,
|
||||
autoPan: true,
|
||||
consoleExpandedByDefault: true,
|
||||
telemetryEnabled: true,
|
||||
telemetryNotifiedUser: false,
|
||||
emailPreferences: {},
|
||||
@@ -64,10 +64,10 @@ export async function GET() {
|
||||
{
|
||||
data: {
|
||||
theme: userSettings.theme,
|
||||
debugMode: userSettings.debugMode,
|
||||
autoConnect: userSettings.autoConnect,
|
||||
autoFillEnvVars: userSettings.autoFillEnvVars,
|
||||
autoPan: userSettings.autoPan,
|
||||
consoleExpandedByDefault: userSettings.consoleExpandedByDefault,
|
||||
telemetryEnabled: userSettings.telemetryEnabled,
|
||||
telemetryNotifiedUser: userSettings.telemetryNotifiedUser,
|
||||
emailPreferences: userSettings.emailPreferences ?? {},
|
||||
|
||||
@@ -33,6 +33,20 @@ const EnvVarsSchema = z.record(z.string())
|
||||
// Use a combination of workflow ID and request ID to allow concurrent executions with different inputs
|
||||
const runningExecutions = new Set<string>()
|
||||
|
||||
// Utility function to filter out logs and workflowConnections from API response
|
||||
function createFilteredResult(result: any) {
|
||||
return {
|
||||
...result,
|
||||
logs: undefined,
|
||||
metadata: result.metadata
|
||||
? {
|
||||
...result.metadata,
|
||||
workflowConnections: undefined,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Custom error class for usage limit exceeded
|
||||
class UsageLimitError extends Error {
|
||||
statusCode: number
|
||||
@@ -358,7 +372,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return createHttpResponseFromBlock(result)
|
||||
}
|
||||
|
||||
return createSuccessResponse(result)
|
||||
// Filter out logs and workflowConnections from the API response
|
||||
const filteredResult = createFilteredResult(result)
|
||||
|
||||
return createSuccessResponse(filteredResult)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error executing workflow: ${id}`, error)
|
||||
|
||||
@@ -418,7 +435,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return createHttpResponseFromBlock(result)
|
||||
}
|
||||
|
||||
return createSuccessResponse(result)
|
||||
// Filter out logs and workflowConnections from the API response
|
||||
const filteredResult = createFilteredResult(result)
|
||||
|
||||
return createSuccessResponse(filteredResult)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error executing workflow: ${id}`, error)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { ConsoleEntry as ConsoleEntryType } from '@/stores/panel/console/types'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { CodeDisplay } from '../code-display/code-display'
|
||||
import { JSONView } from '../json-view/json-view'
|
||||
|
||||
@@ -164,7 +165,8 @@ const ImagePreview = ({
|
||||
}
|
||||
|
||||
export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true) // Default expanded
|
||||
const isConsoleExpandedByDefault = useGeneralStore((state) => state.isConsoleExpandedByDefault)
|
||||
const [isExpanded, setIsExpanded] = useState(isConsoleExpandedByDefault)
|
||||
const [showCopySuccess, setShowCopySuccess] = useState(false)
|
||||
const [showInput, setShowInput] = useState(false) // State for input/output toggle
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
|
||||
import { useSubBlockValue } from '../hooks/use-sub-block-value'
|
||||
|
||||
interface DropdownProps {
|
||||
@@ -34,6 +35,10 @@ export function Dropdown({
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
|
||||
const [storeInitialized, setStoreInitialized] = useState(false)
|
||||
|
||||
// For response dataMode conversion - get builderData and data sub-blocks
|
||||
const [builderData] = useSubBlockValue<any[]>(blockId, 'builderData')
|
||||
const [, setData] = useSubBlockValue<string>(blockId, 'data')
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value or prop value
|
||||
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
|
||||
|
||||
@@ -108,6 +113,20 @@ export function Dropdown({
|
||||
onValueChange={(newValue) => {
|
||||
// Only update store when not in preview mode and not disabled
|
||||
if (!isPreview && !disabled) {
|
||||
// Handle conversion when switching from Builder to Editor mode in response blocks
|
||||
if (
|
||||
subBlockId === 'dataMode' &&
|
||||
storeValue === 'structured' &&
|
||||
newValue === 'json' &&
|
||||
builderData &&
|
||||
Array.isArray(builderData) &&
|
||||
builderData.length > 0
|
||||
) {
|
||||
// Convert builderData to JSON string for editor mode
|
||||
const jsonString = ResponseBlockHandler.convertBuilderDataToJsonString(builderData)
|
||||
setData(jsonString)
|
||||
}
|
||||
|
||||
setStoreValue(newValue)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { ChevronDown, ChevronRight, Plus, Trash } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { JSONProperty } from '../response-format'
|
||||
import { ValueInput } from './value-input'
|
||||
|
||||
const TYPE_ICONS = {
|
||||
string: 'Aa',
|
||||
number: '123',
|
||||
boolean: 'T/F',
|
||||
object: '{}',
|
||||
array: '[]',
|
||||
}
|
||||
|
||||
const TYPE_COLORS = {
|
||||
string: 'text-green-600 dark:text-green-400',
|
||||
number: 'text-blue-600 dark:text-blue-400',
|
||||
boolean: 'text-purple-600 dark:text-purple-400',
|
||||
object: 'text-orange-600 dark:text-orange-400',
|
||||
array: 'text-pink-600 dark:text-pink-400',
|
||||
}
|
||||
|
||||
interface PropertyRendererProps {
|
||||
property: JSONProperty
|
||||
blockId: string
|
||||
isPreview: boolean
|
||||
onUpdateProperty: (id: string, updates: Partial<JSONProperty>) => void
|
||||
onAddProperty: (parentId?: string) => void
|
||||
onRemoveProperty: (id: string) => void
|
||||
onAddArrayItem: (arrayPropId: string) => void
|
||||
onRemoveArrayItem: (arrayPropId: string, index: number) => void
|
||||
onUpdateArrayItem: (arrayPropId: string, index: number, newValue: any) => void
|
||||
depth?: number
|
||||
}
|
||||
|
||||
export function PropertyRenderer({
|
||||
property,
|
||||
blockId,
|
||||
isPreview,
|
||||
onUpdateProperty,
|
||||
onAddProperty,
|
||||
onRemoveProperty,
|
||||
onAddArrayItem,
|
||||
onRemoveArrayItem,
|
||||
onUpdateArrayItem,
|
||||
depth = 0,
|
||||
}: PropertyRendererProps) {
|
||||
const isContainer = property.type === 'object'
|
||||
const indent = depth * 12
|
||||
|
||||
// Check if this object is using a variable reference
|
||||
const isObjectVariable =
|
||||
property.type === 'object' &&
|
||||
typeof property.value === 'string' &&
|
||||
property.value.trim().startsWith('<') &&
|
||||
property.value.trim().includes('>')
|
||||
|
||||
return (
|
||||
<div className='space-y-1' style={{ marginLeft: `${indent}px` }}>
|
||||
<div className='rounded border bg-card/50 p-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{isContainer && !isObjectVariable && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => onUpdateProperty(property.id, { collapsed: !property.collapsed })}
|
||||
className='h-4 w-4 shrink-0'
|
||||
disabled={isPreview}
|
||||
>
|
||||
{property.collapsed ? (
|
||||
<ChevronRight className='h-3 w-3' />
|
||||
) : (
|
||||
<ChevronDown className='h-3 w-3' />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={cn('shrink-0 px-1 py-0 font-mono text-xs', TYPE_COLORS[property.type])}
|
||||
>
|
||||
{TYPE_ICONS[property.type]}
|
||||
</Badge>
|
||||
|
||||
<Input
|
||||
value={property.key}
|
||||
onChange={(e) => onUpdateProperty(property.id, { key: e.target.value })}
|
||||
placeholder='key'
|
||||
disabled={isPreview}
|
||||
className='h-6 min-w-0 flex-1 text-xs'
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-6 shrink-0 px-2 text-xs'
|
||||
disabled={isPreview}
|
||||
>
|
||||
{property.type}
|
||||
<ChevronDown className='ml-1 h-3 w-3' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{Object.entries(TYPE_ICONS).map(([type, icon]) => (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
onClick={() => onUpdateProperty(property.id, { type: type as any })}
|
||||
className='text-xs'
|
||||
>
|
||||
<span className='mr-2 font-mono text-xs'>{icon}</span>
|
||||
{type}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-1'>
|
||||
{isContainer && !isObjectVariable && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => onAddProperty(property.id)}
|
||||
disabled={isPreview}
|
||||
className='h-6 w-6'
|
||||
title='Add property'
|
||||
>
|
||||
<Plus className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => onRemoveProperty(property.id)}
|
||||
disabled={isPreview}
|
||||
className='h-6 w-6 text-muted-foreground hover:text-destructive'
|
||||
>
|
||||
<Trash className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show value input for non-container types OR container types using variables */}
|
||||
{(!isContainer || isObjectVariable) && (
|
||||
<div className='mt-2'>
|
||||
<ValueInput
|
||||
property={property}
|
||||
blockId={blockId}
|
||||
isPreview={isPreview}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onAddArrayItem={onAddArrayItem}
|
||||
onRemoveArrayItem={onRemoveArrayItem}
|
||||
onUpdateArrayItem={onUpdateArrayItem}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show object variable input for object types */}
|
||||
{isContainer && !isObjectVariable && (
|
||||
<div className='mt-2'>
|
||||
<ValueInput
|
||||
property={{
|
||||
...property,
|
||||
id: `${property.id}-object-variable`,
|
||||
type: 'string',
|
||||
value: typeof property.value === 'string' ? property.value : '',
|
||||
}}
|
||||
blockId={blockId}
|
||||
isPreview={isPreview}
|
||||
onUpdateProperty={(id: string, updates: Partial<JSONProperty>) =>
|
||||
onUpdateProperty(property.id, updates)
|
||||
}
|
||||
onAddArrayItem={onAddArrayItem}
|
||||
onRemoveArrayItem={onRemoveArrayItem}
|
||||
onUpdateArrayItem={onUpdateArrayItem}
|
||||
placeholder='Use <variable.object> or define properties below'
|
||||
onObjectVariableChange={(newValue: string) => {
|
||||
if (newValue.startsWith('<')) {
|
||||
onUpdateProperty(property.id, { value: newValue })
|
||||
} else if (newValue === '') {
|
||||
onUpdateProperty(property.id, { value: [] })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isContainer && !property.collapsed && !isObjectVariable && (
|
||||
<div className='ml-1 space-y-1 border-muted/30 border-l-2 pl-2'>
|
||||
{Array.isArray(property.value) && property.value.length > 0 ? (
|
||||
property.value.map((childProp: JSONProperty) => (
|
||||
<PropertyRenderer
|
||||
key={childProp.id}
|
||||
property={childProp}
|
||||
blockId={blockId}
|
||||
isPreview={isPreview}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onRemoveProperty={onRemoveProperty}
|
||||
onAddArrayItem={onAddArrayItem}
|
||||
onRemoveArrayItem={onRemoveArrayItem}
|
||||
onUpdateArrayItem={onUpdateArrayItem}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className='rounded border-2 border-muted/50 border-dashed p-2 text-center'>
|
||||
<p className='text-muted-foreground text-xs'>No properties</p>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => onAddProperty(property.id)}
|
||||
disabled={isPreview}
|
||||
className='mt-1 h-6 text-xs'
|
||||
>
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
Add Property
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Plus, Trash } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import type { JSONProperty } from '../response-format'
|
||||
|
||||
const logger = createLogger('ValueInput')
|
||||
|
||||
interface ValueInputProps {
|
||||
property: JSONProperty
|
||||
blockId: string
|
||||
isPreview: boolean
|
||||
onUpdateProperty: (id: string, updates: Partial<JSONProperty>) => void
|
||||
onAddArrayItem: (arrayPropId: string) => void
|
||||
onRemoveArrayItem: (arrayPropId: string, index: number) => void
|
||||
onUpdateArrayItem: (arrayPropId: string, index: number, newValue: any) => void
|
||||
placeholder?: string
|
||||
onObjectVariableChange?: (newValue: string) => void
|
||||
}
|
||||
|
||||
export function ValueInput({
|
||||
property,
|
||||
blockId,
|
||||
isPreview,
|
||||
onUpdateProperty,
|
||||
onAddArrayItem,
|
||||
onRemoveArrayItem,
|
||||
onUpdateArrayItem,
|
||||
placeholder,
|
||||
onObjectVariableChange,
|
||||
}: ValueInputProps) {
|
||||
const [showEnvVars, setShowEnvVars] = useState(false)
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
|
||||
|
||||
const inputRefs = useRef<{ [key: string]: HTMLInputElement | null }>({})
|
||||
|
||||
const findPropertyById = (props: JSONProperty[], id: string): JSONProperty | null => {
|
||||
for (const prop of props) {
|
||||
if (prop.id === id) return prop
|
||||
if (prop.type === 'object' && Array.isArray(prop.value)) {
|
||||
const found = findPropertyById(prop.value, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLInputElement>, propId: string) => {
|
||||
if (isPreview) return
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'))
|
||||
if (data.type !== 'connectionBlock') return
|
||||
|
||||
const input = inputRefs.current[propId]
|
||||
const dropPosition = input?.selectionStart ?? 0
|
||||
|
||||
const currentValue = property.value?.toString() ?? ''
|
||||
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
|
||||
|
||||
input?.focus()
|
||||
|
||||
Promise.resolve().then(() => {
|
||||
onUpdateProperty(property.id, { value: newValue })
|
||||
setCursorPosition(dropPosition + 1)
|
||||
setShowTags(true)
|
||||
|
||||
if (data.connectionData?.sourceBlockId) {
|
||||
setActiveSourceBlockId(data.connectionData.sourceBlockId)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (input) {
|
||||
input.selectionStart = dropPosition + 1
|
||||
input.selectionEnd = dropPosition + 1
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse drop data:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
const getPlaceholder = () => {
|
||||
if (placeholder) return placeholder
|
||||
|
||||
switch (property.type) {
|
||||
case 'number':
|
||||
return '42 or <variable.count>'
|
||||
case 'boolean':
|
||||
return 'true/false or <variable.isEnabled>'
|
||||
case 'array':
|
||||
return '["item1", "item2"] or <variable.items>'
|
||||
case 'object':
|
||||
return '{...} or <variable.object>'
|
||||
default:
|
||||
return 'Enter text or <variable.name>'
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
const cursorPos = e.target.selectionStart || 0
|
||||
|
||||
if (onObjectVariableChange) {
|
||||
onObjectVariableChange(newValue.trim())
|
||||
} else {
|
||||
onUpdateProperty(property.id, { value: newValue })
|
||||
}
|
||||
|
||||
if (!isPreview) {
|
||||
const tagTrigger = checkTagTrigger(newValue, cursorPos)
|
||||
const envVarTrigger = checkEnvVarTrigger(newValue, cursorPos)
|
||||
|
||||
setShowTags(tagTrigger.show)
|
||||
setShowEnvVars(envVarTrigger.show)
|
||||
setSearchTerm(envVarTrigger.searchTerm || '')
|
||||
setCursorPosition(cursorPos)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTagSelect = (newValue: string) => {
|
||||
if (onObjectVariableChange) {
|
||||
onObjectVariableChange(newValue)
|
||||
} else {
|
||||
onUpdateProperty(property.id, { value: newValue })
|
||||
}
|
||||
setShowTags(false)
|
||||
}
|
||||
|
||||
const handleEnvVarSelect = (newValue: string) => {
|
||||
if (onObjectVariableChange) {
|
||||
onObjectVariableChange(newValue)
|
||||
} else {
|
||||
onUpdateProperty(property.id, { value: newValue })
|
||||
}
|
||||
setShowEnvVars(false)
|
||||
}
|
||||
|
||||
const isArrayVariable =
|
||||
property.type === 'array' &&
|
||||
typeof property.value === 'string' &&
|
||||
property.value.trim().startsWith('<') &&
|
||||
property.value.trim().includes('>')
|
||||
|
||||
// Handle array type with individual items
|
||||
if (property.type === 'array' && !isArrayVariable && Array.isArray(property.value)) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
inputRefs.current[`${property.id}-array-variable`] = el
|
||||
}}
|
||||
value={typeof property.value === 'string' ? property.value : ''}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value.trim()
|
||||
if (newValue.startsWith('<') || newValue.startsWith('[')) {
|
||||
onUpdateProperty(property.id, { value: newValue })
|
||||
} else if (newValue === '') {
|
||||
onUpdateProperty(property.id, { value: [] })
|
||||
}
|
||||
|
||||
const cursorPos = e.target.selectionStart || 0
|
||||
if (!isPreview) {
|
||||
const tagTrigger = checkTagTrigger(newValue, cursorPos)
|
||||
const envVarTrigger = checkEnvVarTrigger(newValue, cursorPos)
|
||||
|
||||
setShowTags(tagTrigger.show)
|
||||
setShowEnvVars(envVarTrigger.show)
|
||||
setSearchTerm(envVarTrigger.searchTerm || '')
|
||||
setCursorPosition(cursorPos)
|
||||
}
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, `${property.id}-array-variable`)}
|
||||
placeholder='Use <variable.items> or define items below'
|
||||
disabled={isPreview}
|
||||
className='h-7 text-xs'
|
||||
/>
|
||||
{!isPreview && showTags && (
|
||||
<TagDropdown
|
||||
visible={showTags}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={activeSourceBlockId}
|
||||
inputValue={typeof property.value === 'string' ? property.value : ''}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => setShowTags(false)}
|
||||
/>
|
||||
)}
|
||||
{!isPreview && showEnvVars && (
|
||||
<EnvVarDropdown
|
||||
visible={showEnvVars}
|
||||
onSelect={handleEnvVarSelect}
|
||||
searchTerm={searchTerm}
|
||||
inputValue={typeof property.value === 'string' ? property.value : ''}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => setShowEnvVars(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{property.value.length > 0 && (
|
||||
<>
|
||||
<div className='mt-2 mb-1 font-medium text-muted-foreground text-xs'>Array Items:</div>
|
||||
{property.value.map((item: any, index: number) => (
|
||||
<div key={index} className='flex items-center gap-1'>
|
||||
<div className='relative flex-1'>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
inputRefs.current[`${property.id}-array-${index}`] = el
|
||||
}}
|
||||
value={item || ''}
|
||||
onChange={(e) => onUpdateArrayItem(property.id, index, e.target.value)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, `${property.id}-array-${index}`)}
|
||||
placeholder={`Item ${index + 1}`}
|
||||
disabled={isPreview}
|
||||
className='h-7 text-xs'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => onRemoveArrayItem(property.id, index)}
|
||||
disabled={isPreview}
|
||||
className='h-7 w-7'
|
||||
>
|
||||
<Trash className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onAddArrayItem(property.id)}
|
||||
disabled={isPreview}
|
||||
className='h-7 w-full text-xs'
|
||||
>
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle regular input for all other types
|
||||
return (
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
inputRefs.current[property.id] = el
|
||||
}}
|
||||
value={property.value || ''}
|
||||
onChange={handleInputChange}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, property.id)}
|
||||
placeholder={getPlaceholder()}
|
||||
disabled={isPreview}
|
||||
className='h-7 text-xs'
|
||||
/>
|
||||
{!isPreview && showTags && (
|
||||
<TagDropdown
|
||||
visible={showTags}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={activeSourceBlockId}
|
||||
inputValue={property.value || ''}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => setShowTags(false)}
|
||||
/>
|
||||
)}
|
||||
{!isPreview && showEnvVars && (
|
||||
<EnvVarDropdown
|
||||
visible={showEnvVars}
|
||||
onSelect={handleEnvVarSelect}
|
||||
searchTerm={searchTerm}
|
||||
inputValue={property.value || ''}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => setShowEnvVars(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { Code, Eye, Plus } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
|
||||
import { PropertyRenderer } from './components/property-renderer'
|
||||
import { ResponseFormat as SharedResponseFormat } from '../starter/input-format'
|
||||
|
||||
export interface JSONProperty {
|
||||
id: string
|
||||
key: string
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array'
|
||||
value: any
|
||||
value?: any
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
@@ -17,31 +12,10 @@ interface ResponseFormatProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
isPreview?: boolean
|
||||
previewValue?: JSONProperty[] | null
|
||||
}
|
||||
|
||||
const TYPE_ICONS = {
|
||||
string: 'Aa',
|
||||
number: '123',
|
||||
boolean: 'T/F',
|
||||
object: '{}',
|
||||
array: '[]',
|
||||
}
|
||||
|
||||
const TYPE_COLORS = {
|
||||
string: 'text-green-600 dark:text-green-400',
|
||||
number: 'text-blue-600 dark:text-blue-400',
|
||||
boolean: 'text-purple-600 dark:text-purple-400',
|
||||
object: 'text-orange-600 dark:text-orange-400',
|
||||
array: 'text-pink-600 dark:text-pink-400',
|
||||
}
|
||||
|
||||
const DEFAULT_PROPERTY: JSONProperty = {
|
||||
id: crypto.randomUUID(),
|
||||
key: 'message',
|
||||
type: 'string',
|
||||
value: '',
|
||||
collapsed: false,
|
||||
previewValue?: any
|
||||
disabled?: boolean
|
||||
isConnecting?: boolean
|
||||
config?: any
|
||||
}
|
||||
|
||||
export function ResponseFormat({
|
||||
@@ -49,288 +23,19 @@ export function ResponseFormat({
|
||||
subBlockId,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
disabled = false,
|
||||
isConnecting = false,
|
||||
config,
|
||||
}: ResponseFormatProps) {
|
||||
// useSubBlockValue now includes debouncing by default
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<JSONProperty[]>(blockId, subBlockId, false, {
|
||||
debounceMs: 200, // Slightly longer debounce for complex structures
|
||||
})
|
||||
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
const properties: JSONProperty[] = value || [DEFAULT_PROPERTY]
|
||||
|
||||
const isVariableReference = (value: any): boolean => {
|
||||
return typeof value === 'string' && value.trim().startsWith('<') && value.trim().includes('>')
|
||||
}
|
||||
|
||||
const findPropertyById = (props: JSONProperty[], id: string): JSONProperty | null => {
|
||||
for (const prop of props) {
|
||||
if (prop.id === id) return prop
|
||||
if (prop.type === 'object' && Array.isArray(prop.value)) {
|
||||
const found = findPropertyById(prop.value, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const generateJSON = (props: JSONProperty[]): any => {
|
||||
const result: any = {}
|
||||
|
||||
for (const prop of props) {
|
||||
if (!prop.key.trim()) return
|
||||
|
||||
let value = prop.value
|
||||
|
||||
if (prop.type === 'object') {
|
||||
if (Array.isArray(prop.value)) {
|
||||
value = generateJSON(prop.value)
|
||||
} else if (typeof prop.value === 'string' && isVariableReference(prop.value)) {
|
||||
value = prop.value
|
||||
} else {
|
||||
value = {} // Default empty object for non-array, non-variable values
|
||||
}
|
||||
} else if (prop.type === 'array' && Array.isArray(prop.value)) {
|
||||
value = prop.value.map((item: any) => {
|
||||
if (typeof item === 'object' && item.type) {
|
||||
if (item.type === 'object' && Array.isArray(item.value)) {
|
||||
return generateJSON(item.value)
|
||||
}
|
||||
if (item.type === 'array' && Array.isArray(item.value)) {
|
||||
return item.value.map((subItem: any) =>
|
||||
typeof subItem === 'object' && subItem.type ? subItem.value : subItem
|
||||
)
|
||||
}
|
||||
return item.value
|
||||
}
|
||||
return item
|
||||
})
|
||||
} else if (prop.type === 'number' && !isVariableReference(value)) {
|
||||
value = Number.isNaN(Number(value)) ? value : Number(value)
|
||||
} else if (prop.type === 'boolean' && !isVariableReference(value)) {
|
||||
const strValue = String(value).toLowerCase().trim()
|
||||
value = strValue === 'true' || strValue === '1' || strValue === 'yes' || strValue === 'on'
|
||||
}
|
||||
|
||||
result[prop.key] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const updateProperties = (newProperties: JSONProperty[]) => {
|
||||
if (isPreview) return
|
||||
setStoreValue(newProperties)
|
||||
}
|
||||
|
||||
const updateProperty = (id: string, updates: Partial<JSONProperty>) => {
|
||||
const updateRecursive = (props: JSONProperty[]): JSONProperty[] => {
|
||||
return props.map((prop) => {
|
||||
if (prop.id === id) {
|
||||
const updated = { ...prop, ...updates }
|
||||
|
||||
if (updates.type && updates.type !== prop.type) {
|
||||
if (updates.type === 'object') {
|
||||
updated.value = []
|
||||
} else if (updates.type === 'array') {
|
||||
updated.value = []
|
||||
} else if (updates.type === 'boolean') {
|
||||
updated.value = 'false'
|
||||
} else if (updates.type === 'number') {
|
||||
updated.value = '0'
|
||||
} else {
|
||||
updated.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
if (prop.type === 'object' && Array.isArray(prop.value)) {
|
||||
return { ...prop, value: updateRecursive(prop.value) }
|
||||
}
|
||||
|
||||
return prop
|
||||
})
|
||||
}
|
||||
|
||||
updateProperties(updateRecursive(properties))
|
||||
}
|
||||
|
||||
const addProperty = (parentId?: string) => {
|
||||
const newProp: JSONProperty = {
|
||||
id: crypto.randomUUID(),
|
||||
key: '',
|
||||
type: 'string',
|
||||
value: '',
|
||||
collapsed: false,
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
const addToParent = (props: JSONProperty[]): JSONProperty[] => {
|
||||
return props.map((prop) => {
|
||||
if (prop.id === parentId && prop.type === 'object') {
|
||||
return { ...prop, value: [...(prop.value || []), newProp] }
|
||||
}
|
||||
if (prop.type === 'object' && Array.isArray(prop.value)) {
|
||||
return { ...prop, value: addToParent(prop.value) }
|
||||
}
|
||||
return prop
|
||||
})
|
||||
}
|
||||
updateProperties(addToParent(properties))
|
||||
} else {
|
||||
updateProperties([...properties, newProp])
|
||||
}
|
||||
}
|
||||
|
||||
const removeProperty = (id: string) => {
|
||||
const removeRecursive = (props: JSONProperty[]): JSONProperty[] => {
|
||||
return props
|
||||
.filter((prop) => prop.id !== id)
|
||||
.map((prop) => {
|
||||
if (prop.type === 'object' && Array.isArray(prop.value)) {
|
||||
return { ...prop, value: removeRecursive(prop.value) }
|
||||
}
|
||||
return prop
|
||||
})
|
||||
}
|
||||
|
||||
const newProperties = removeRecursive(properties)
|
||||
updateProperties(
|
||||
newProperties.length > 0
|
||||
? newProperties
|
||||
: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
key: '',
|
||||
type: 'string',
|
||||
value: '',
|
||||
collapsed: false,
|
||||
},
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const addArrayItem = (arrayPropId: string) => {
|
||||
const addItem = (props: JSONProperty[]): JSONProperty[] => {
|
||||
return props.map((prop) => {
|
||||
if (prop.id === arrayPropId && prop.type === 'array') {
|
||||
return { ...prop, value: [...(prop.value || []), ''] }
|
||||
}
|
||||
if (prop.type === 'object' && Array.isArray(prop.value)) {
|
||||
return { ...prop, value: addItem(prop.value) }
|
||||
}
|
||||
return prop
|
||||
})
|
||||
}
|
||||
updateProperties(addItem(properties))
|
||||
}
|
||||
|
||||
const removeArrayItem = (arrayPropId: string, index: number) => {
|
||||
const removeItem = (props: JSONProperty[]): JSONProperty[] => {
|
||||
return props.map((prop) => {
|
||||
if (prop.id === arrayPropId && prop.type === 'array') {
|
||||
const newValue = [...(prop.value || [])]
|
||||
newValue.splice(index, 1)
|
||||
return { ...prop, value: newValue }
|
||||
}
|
||||
if (prop.type === 'object' && Array.isArray(prop.value)) {
|
||||
return { ...prop, value: removeItem(prop.value) }
|
||||
}
|
||||
return prop
|
||||
})
|
||||
}
|
||||
updateProperties(removeItem(properties))
|
||||
}
|
||||
|
||||
const updateArrayItem = (arrayPropId: string, index: number, newValue: any) => {
|
||||
const updateItem = (props: JSONProperty[]): JSONProperty[] => {
|
||||
return props.map((prop) => {
|
||||
if (prop.id === arrayPropId && prop.type === 'array') {
|
||||
const updatedValue = [...(prop.value || [])]
|
||||
updatedValue[index] = newValue
|
||||
return { ...prop, value: updatedValue }
|
||||
}
|
||||
if (prop.type === 'object' && Array.isArray(prop.value)) {
|
||||
return { ...prop, value: updateItem(prop.value) }
|
||||
}
|
||||
return prop
|
||||
})
|
||||
}
|
||||
updateProperties(updateItem(properties))
|
||||
}
|
||||
|
||||
const hasConfiguredProperties = properties.some((prop) => prop.key.trim())
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='font-medium text-xs'> </Label>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
disabled={isPreview}
|
||||
className='h-6 px-2 text-xs'
|
||||
>
|
||||
{showPreview ? <Code className='mr-1 h-3 w-3' /> : <Eye className='mr-1 h-3 w-3' />}
|
||||
{showPreview ? 'Hide' : 'Preview'}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => addProperty()}
|
||||
disabled={isPreview}
|
||||
className='h-6 px-2 text-xs'
|
||||
>
|
||||
<Plus className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
<div className='rounded border bg-muted/30 p-2'>
|
||||
<pre className='max-h-32 overflow-auto text-xs'>
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(generateJSON(properties), null, 2)
|
||||
} catch (error) {
|
||||
return `Error generating preview: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-1'>
|
||||
{properties.map((prop) => (
|
||||
<PropertyRenderer
|
||||
key={prop.id}
|
||||
property={prop}
|
||||
blockId={blockId}
|
||||
isPreview={isPreview}
|
||||
onUpdateProperty={updateProperty}
|
||||
onAddProperty={addProperty}
|
||||
onRemoveProperty={removeProperty}
|
||||
onAddArrayItem={addArrayItem}
|
||||
onRemoveArrayItem={removeArrayItem}
|
||||
onUpdateArrayItem={updateArrayItem}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!hasConfiguredProperties && (
|
||||
<div className='py-4 text-center text-muted-foreground'>
|
||||
<p className='text-xs'>Build your JSON response format</p>
|
||||
<p className='text-xs'>
|
||||
Use <variable.name> in values or drag variables from above
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SharedResponseFormat
|
||||
blockId={blockId}
|
||||
subBlockId={subBlockId}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
disabled={disabled}
|
||||
isConnecting={isConnecting}
|
||||
config={config}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { ChevronDown, Plus, Trash } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -7,52 +8,83 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { formatDisplayText } from '@/components/ui/formatted-text'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
|
||||
|
||||
interface InputField {
|
||||
interface Field {
|
||||
id: string
|
||||
name: string
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array'
|
||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array'
|
||||
value?: string
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
interface InputFormatProps {
|
||||
interface FieldFormatProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
isPreview?: boolean
|
||||
previewValue?: InputField[] | null
|
||||
previewValue?: Field[] | null
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
placeholder?: string
|
||||
emptyMessage?: string
|
||||
showType?: boolean
|
||||
showValue?: boolean
|
||||
valuePlaceholder?: string
|
||||
isConnecting?: boolean
|
||||
config?: any
|
||||
}
|
||||
|
||||
// Default values
|
||||
const DEFAULT_FIELD: InputField = {
|
||||
const DEFAULT_FIELD: Field = {
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
type: 'string',
|
||||
collapsed: true,
|
||||
value: '',
|
||||
collapsed: false,
|
||||
}
|
||||
|
||||
export function InputFormat({
|
||||
export function FieldFormat({
|
||||
blockId,
|
||||
subBlockId,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
disabled = false,
|
||||
}: InputFormatProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<InputField[]>(blockId, subBlockId)
|
||||
title = 'Field',
|
||||
placeholder = 'fieldName',
|
||||
emptyMessage = 'No fields defined',
|
||||
showType = true,
|
||||
showValue = false,
|
||||
valuePlaceholder = 'Enter value or <variable.name>',
|
||||
isConnecting = false,
|
||||
config,
|
||||
}: FieldFormatProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<Field[]>(blockId, subBlockId)
|
||||
const [tagDropdownStates, setTagDropdownStates] = useState<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
visible: boolean
|
||||
cursorPosition: number
|
||||
}
|
||||
>
|
||||
>({})
|
||||
const [dragHighlight, setDragHighlight] = useState<Record<string, boolean>>({})
|
||||
const valueInputRefs = useRef<Record<string, HTMLInputElement>>({})
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
const fields: InputField[] = value || []
|
||||
const fields: Field[] = value || []
|
||||
|
||||
// Field operations
|
||||
const addField = () => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const newField: InputField = {
|
||||
const newField: Field = {
|
||||
...DEFAULT_FIELD,
|
||||
id: crypto.randomUUID(),
|
||||
}
|
||||
@@ -61,24 +93,127 @@ export function InputFormat({
|
||||
|
||||
const removeField = (id: string) => {
|
||||
if (isPreview || disabled) return
|
||||
setStoreValue(fields.filter((field: InputField) => field.id !== id))
|
||||
setStoreValue(fields.filter((field: Field) => field.id !== id))
|
||||
}
|
||||
|
||||
// Validate field name for API safety
|
||||
const validateFieldName = (name: string): string => {
|
||||
// Remove only truly problematic characters for JSON/API usage
|
||||
// Allow most characters but remove control characters, quotes, and backslashes
|
||||
return name.replace(/[\x00-\x1F"\\]/g, '').trim()
|
||||
}
|
||||
|
||||
// Tag dropdown handlers
|
||||
const handleValueInputChange = (fieldId: string, newValue: string) => {
|
||||
const input = valueInputRefs.current[fieldId]
|
||||
if (!input) return
|
||||
|
||||
const cursorPosition = input.selectionStart || 0
|
||||
const shouldShow = checkTagTrigger(newValue, cursorPosition)
|
||||
|
||||
setTagDropdownStates((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: {
|
||||
visible: shouldShow.show,
|
||||
cursorPosition,
|
||||
},
|
||||
}))
|
||||
|
||||
updateField(fieldId, 'value', newValue)
|
||||
}
|
||||
|
||||
const handleTagSelect = (fieldId: string, newValue: string) => {
|
||||
updateField(fieldId, 'value', newValue)
|
||||
setTagDropdownStates((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: { ...prev[fieldId], visible: false },
|
||||
}))
|
||||
}
|
||||
|
||||
const handleTagDropdownClose = (fieldId: string) => {
|
||||
setTagDropdownStates((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: { ...prev[fieldId], visible: false },
|
||||
}))
|
||||
}
|
||||
|
||||
// Drag and drop handlers for connection blocks
|
||||
const handleDragOver = (e: React.DragEvent, fieldId: string) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
setDragHighlight((prev) => ({ ...prev, [fieldId]: true }))
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent, fieldId: string) => {
|
||||
e.preventDefault()
|
||||
setDragHighlight((prev) => ({ ...prev, [fieldId]: false }))
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent, fieldId: string) => {
|
||||
e.preventDefault()
|
||||
setDragHighlight((prev) => ({ ...prev, [fieldId]: false }))
|
||||
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'))
|
||||
if (data.type === 'connectionBlock' && data.connectionData) {
|
||||
const input = valueInputRefs.current[fieldId]
|
||||
if (!input) return
|
||||
|
||||
// Focus the input first
|
||||
input.focus()
|
||||
|
||||
// Get current cursor position or use end of field
|
||||
const dropPosition = input.selectionStart ?? (input.value?.length || 0)
|
||||
|
||||
// Insert '<' at drop position to trigger the dropdown
|
||||
const currentValue = input.value || ''
|
||||
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
|
||||
|
||||
// Update the field value
|
||||
updateField(fieldId, 'value', newValue)
|
||||
|
||||
// Set cursor position and show dropdown
|
||||
setTimeout(() => {
|
||||
input.selectionStart = dropPosition + 1
|
||||
input.selectionEnd = dropPosition + 1
|
||||
|
||||
// Trigger dropdown by simulating the tag check
|
||||
const cursorPosition = dropPosition + 1
|
||||
const shouldShow = checkTagTrigger(newValue, cursorPosition)
|
||||
|
||||
setTagDropdownStates((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: {
|
||||
visible: shouldShow.show,
|
||||
cursorPosition,
|
||||
},
|
||||
}))
|
||||
}, 0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling drop:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Update handlers
|
||||
const updateField = (id: string, field: keyof InputField, value: any) => {
|
||||
const updateField = (id: string, field: keyof Field, value: any) => {
|
||||
if (isPreview || disabled) return
|
||||
setStoreValue(fields.map((f: InputField) => (f.id === id ? { ...f, [field]: value } : f)))
|
||||
|
||||
// Validate field name if it's being updated
|
||||
if (field === 'name' && typeof value === 'string') {
|
||||
value = validateFieldName(value)
|
||||
}
|
||||
|
||||
setStoreValue(fields.map((f: Field) => (f.id === id ? { ...f, [field]: value } : f)))
|
||||
}
|
||||
|
||||
const toggleCollapse = (id: string) => {
|
||||
if (isPreview || disabled) return
|
||||
setStoreValue(
|
||||
fields.map((f: InputField) => (f.id === id ? { ...f, collapsed: !f.collapsed } : f))
|
||||
)
|
||||
setStoreValue(fields.map((f: Field) => (f.id === id ? { ...f, collapsed: !f.collapsed } : f)))
|
||||
}
|
||||
|
||||
// Field header
|
||||
const renderFieldHeader = (field: InputField, index: number) => {
|
||||
const renderFieldHeader = (field: Field, index: number) => {
|
||||
const isUnconfigured = !field.name || field.name.trim() === ''
|
||||
|
||||
return (
|
||||
@@ -93,9 +228,9 @@ export function InputFormat({
|
||||
isUnconfigured ? 'text-muted-foreground/50' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{field.name ? field.name : `Field ${index + 1}`}
|
||||
{field.name ? field.name : `${title} ${index + 1}`}
|
||||
</span>
|
||||
{field.name && (
|
||||
{field.name && showType && (
|
||||
<Badge variant='outline' className='ml-2 h-5 bg-muted py-0 font-normal text-xs'>
|
||||
{field.type}
|
||||
</Badge>
|
||||
@@ -110,7 +245,7 @@ export function InputFormat({
|
||||
className='h-6 w-6 rounded-full'
|
||||
>
|
||||
<Plus className='h-3.5 w-3.5' />
|
||||
<span className='sr-only'>Add Field</span>
|
||||
<span className='sr-only'>Add {title}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -128,15 +263,12 @@ export function InputFormat({
|
||||
)
|
||||
}
|
||||
|
||||
// Check if any fields have been configured
|
||||
const hasConfiguredFields = fields.some((field) => field.name && field.name.trim() !== '')
|
||||
|
||||
// Main render
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
{fields.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center rounded-md border border-input/50 border-dashed py-8'>
|
||||
<p className='mb-3 text-muted-foreground text-sm'>No input fields defined</p>
|
||||
<p className='mb-3 text-muted-foreground text-sm'>{emptyMessage}</p>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
@@ -145,7 +277,7 @@ export function InputFormat({
|
||||
className='h-8'
|
||||
>
|
||||
<Plus className='mr-1.5 h-3.5 w-3.5' />
|
||||
Add Field
|
||||
Add {title}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -172,78 +304,165 @@ export function InputFormat({
|
||||
name='name'
|
||||
value={field.name}
|
||||
onChange={(e) => updateField(field.id, 'name', e.target.value)}
|
||||
placeholder='firstName'
|
||||
placeholder={placeholder}
|
||||
disabled={isPreview || disabled}
|
||||
className='h-9 placeholder:text-muted-foreground/50'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs'>Type</Label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
{showType && (
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs'>Type</Label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={isPreview || disabled}
|
||||
className='h-9 w-full justify-between font-normal'
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<span>{field.type}</span>
|
||||
</div>
|
||||
<ChevronDown className='h-4 w-4 opacity-50' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[200px]'>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'string')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>Aa</span>
|
||||
<span>String</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'number')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>123</span>
|
||||
<span>Number</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'boolean')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>0/1</span>
|
||||
<span>Boolean</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'object')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>{'{}'}</span>
|
||||
<span>Object</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'array')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>[]</span>
|
||||
<span>Array</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showValue && (
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs'>Value</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
if (el) valueInputRefs.current[field.id] = el
|
||||
}}
|
||||
name='value'
|
||||
value={field.value || ''}
|
||||
onChange={(e) => handleValueInputChange(field.id, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleTagDropdownClose(field.id)
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => handleDragOver(e, field.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e, field.id)}
|
||||
onDrop={(e) => handleDrop(e, field.id)}
|
||||
placeholder={valuePlaceholder}
|
||||
disabled={isPreview || disabled}
|
||||
className='h-9 w-full justify-between font-normal'
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<span>{field.type}</span>
|
||||
className={cn(
|
||||
'h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50',
|
||||
dragHighlight[field.id] && 'ring-2 ring-blue-500 ring-offset-2',
|
||||
isConnecting &&
|
||||
config?.connectionDroppable !== false &&
|
||||
'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500'
|
||||
)}
|
||||
/>
|
||||
{field.value && (
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center px-3 py-2'>
|
||||
<div className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm'>
|
||||
{formatDisplayText(field.value, true)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className='h-4 w-4 opacity-50' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[200px]'>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'string')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>Aa</span>
|
||||
<span>String</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'number')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>123</span>
|
||||
<span>Number</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'boolean')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>0/1</span>
|
||||
<span>Boolean</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'object')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>{'{}'}</span>
|
||||
<span>Object</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'array')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>[]</span>
|
||||
<span>Array</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
<TagDropdown
|
||||
visible={tagDropdownStates[field.id]?.visible || false}
|
||||
onSelect={(newValue) => handleTagSelect(field.id, newValue)}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={null}
|
||||
inputValue={field.value || ''}
|
||||
cursorPosition={tagDropdownStates[field.id]?.cursorPosition || 0}
|
||||
onClose={() => handleTagDropdownClose(field.id)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{fields.length > 0 && !hasConfiguredFields && (
|
||||
<div className='mt-1 px-1 text-muted-foreground/70 text-xs italic'>
|
||||
Define fields above to enable structured API input
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Export specific components for backward compatibility
|
||||
export function InputFormat(
|
||||
props: Omit<FieldFormatProps, 'title' | 'placeholder' | 'emptyMessage'>
|
||||
) {
|
||||
return (
|
||||
<FieldFormat
|
||||
{...props}
|
||||
title='Field'
|
||||
placeholder='firstName'
|
||||
emptyMessage='No input fields defined'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResponseFormat(
|
||||
props: Omit<
|
||||
FieldFormatProps,
|
||||
'title' | 'placeholder' | 'emptyMessage' | 'showType' | 'showValue' | 'valuePlaceholder'
|
||||
>
|
||||
) {
|
||||
return (
|
||||
<FieldFormat
|
||||
{...props}
|
||||
title='Field'
|
||||
placeholder='output'
|
||||
emptyMessage='No response fields defined'
|
||||
showType={false}
|
||||
showValue={true}
|
||||
valuePlaceholder='Enter value or <variable.name>'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type { Field as InputField, Field as ResponseField }
|
||||
|
||||
@@ -370,6 +370,8 @@ export function SubBlock({
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
disabled={isDisabled}
|
||||
isConnecting={isConnecting}
|
||||
config={config}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -380,6 +382,9 @@ export function SubBlock({
|
||||
subBlockId={config.id}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
isConnecting={isConnecting}
|
||||
config={config}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
)
|
||||
case 'channel-selector':
|
||||
|
||||
@@ -16,10 +16,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
const TOOLTIPS = {
|
||||
debugMode: 'Enable visual debugging information during execution.',
|
||||
autoConnect: 'Automatically connect nodes.',
|
||||
autoFillEnvVars: 'Automatically fill API keys.',
|
||||
autoPan: 'Automatically pan to active blocks during workflow execution.',
|
||||
consoleExpandedByDefault:
|
||||
'Show console entries expanded by default. When disabled, entries will be collapsed by default.',
|
||||
}
|
||||
|
||||
export function General() {
|
||||
@@ -29,15 +30,26 @@ export function General() {
|
||||
const error = useGeneralStore((state) => state.error)
|
||||
const theme = useGeneralStore((state) => state.theme)
|
||||
const isAutoConnectEnabled = useGeneralStore((state) => state.isAutoConnectEnabled)
|
||||
const isDebugModeEnabled = useGeneralStore((state) => state.isDebugModeEnabled)
|
||||
const isAutoFillEnvVarsEnabled = useGeneralStore((state) => state.isAutoFillEnvVarsEnabled)
|
||||
const isAutoPanEnabled = useGeneralStore((state) => state.isAutoPanEnabled)
|
||||
const isConsoleExpandedByDefault = useGeneralStore((state) => state.isConsoleExpandedByDefault)
|
||||
|
||||
// Loading states
|
||||
const isAutoConnectLoading = useGeneralStore((state) => state.isAutoConnectLoading)
|
||||
const isAutoFillEnvVarsLoading = useGeneralStore((state) => state.isAutoFillEnvVarsLoading)
|
||||
const isAutoPanLoading = useGeneralStore((state) => state.isAutoPanLoading)
|
||||
const isConsoleExpandedByDefaultLoading = useGeneralStore(
|
||||
(state) => state.isConsoleExpandedByDefaultLoading
|
||||
)
|
||||
const isThemeLoading = useGeneralStore((state) => state.isThemeLoading)
|
||||
|
||||
const setTheme = useGeneralStore((state) => state.setTheme)
|
||||
const toggleAutoConnect = useGeneralStore((state) => state.toggleAutoConnect)
|
||||
const toggleDebugMode = useGeneralStore((state) => state.toggleDebugMode)
|
||||
const toggleAutoFillEnvVars = useGeneralStore((state) => state.toggleAutoFillEnvVars)
|
||||
const toggleAutoPan = useGeneralStore((state) => state.toggleAutoPan)
|
||||
const toggleConsoleExpandedByDefault = useGeneralStore(
|
||||
(state) => state.toggleConsoleExpandedByDefault
|
||||
)
|
||||
const loadSettings = useGeneralStore((state) => state.loadSettings)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,31 +59,31 @@ export function General() {
|
||||
loadData()
|
||||
}, [loadSettings, retryCount])
|
||||
|
||||
const handleThemeChange = (value: 'system' | 'light' | 'dark') => {
|
||||
setTheme(value)
|
||||
const handleThemeChange = async (value: 'system' | 'light' | 'dark') => {
|
||||
await setTheme(value)
|
||||
}
|
||||
|
||||
const handleDebugModeChange = (checked: boolean) => {
|
||||
if (checked !== isDebugModeEnabled) {
|
||||
toggleDebugMode()
|
||||
const handleAutoConnectChange = async (checked: boolean) => {
|
||||
if (checked !== isAutoConnectEnabled && !isAutoConnectLoading) {
|
||||
await toggleAutoConnect()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoConnectChange = (checked: boolean) => {
|
||||
if (checked !== isAutoConnectEnabled) {
|
||||
toggleAutoConnect()
|
||||
const handleAutoFillEnvVarsChange = async (checked: boolean) => {
|
||||
if (checked !== isAutoFillEnvVarsEnabled && !isAutoFillEnvVarsLoading) {
|
||||
await toggleAutoFillEnvVars()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoFillEnvVarsChange = (checked: boolean) => {
|
||||
if (checked !== isAutoFillEnvVarsEnabled) {
|
||||
toggleAutoFillEnvVars()
|
||||
const handleAutoPanChange = async (checked: boolean) => {
|
||||
if (checked !== isAutoPanEnabled && !isAutoPanLoading) {
|
||||
await toggleAutoPan()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoPanChange = (checked: boolean) => {
|
||||
if (checked !== isAutoPanEnabled) {
|
||||
toggleAutoPan()
|
||||
const handleConsoleExpandedByDefaultChange = async (checked: boolean) => {
|
||||
if (checked !== isConsoleExpandedByDefault && !isConsoleExpandedByDefaultLoading) {
|
||||
await toggleConsoleExpandedByDefault()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +123,11 @@ export function General() {
|
||||
Theme
|
||||
</Label>
|
||||
</div>
|
||||
<Select value={theme} onValueChange={handleThemeChange} disabled={isLoading}>
|
||||
<Select
|
||||
value={theme}
|
||||
onValueChange={handleThemeChange}
|
||||
disabled={isLoading || isThemeLoading}
|
||||
>
|
||||
<SelectTrigger id='theme-select' className='w-[180px]'>
|
||||
<SelectValue placeholder='Select theme' />
|
||||
</SelectTrigger>
|
||||
@@ -122,35 +138,6 @@ export function General() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex items-center justify-between py-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='debug-mode' className='font-medium'>
|
||||
Debug mode
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 p-1 text-gray-500'
|
||||
aria-label='Learn more about debug mode'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Info className='h-5 w-5' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.debugMode}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
id='debug-mode'
|
||||
checked={isDebugModeEnabled}
|
||||
onCheckedChange={handleDebugModeChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center justify-between py-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='auto-connect' className='font-medium'>
|
||||
@@ -163,7 +150,7 @@ export function General() {
|
||||
size='sm'
|
||||
className='h-7 p-1 text-gray-500'
|
||||
aria-label='Learn more about auto-connect feature'
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isAutoConnectLoading}
|
||||
>
|
||||
<Info className='h-5 w-5' />
|
||||
</Button>
|
||||
@@ -177,7 +164,7 @@ export function General() {
|
||||
id='auto-connect'
|
||||
checked={isAutoConnectEnabled}
|
||||
onCheckedChange={handleAutoConnectChange}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isAutoConnectLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center justify-between py-1'>
|
||||
@@ -192,7 +179,7 @@ export function General() {
|
||||
size='sm'
|
||||
className='h-7 p-1 text-gray-500'
|
||||
aria-label='Learn more about auto-fill environment variables'
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isAutoFillEnvVarsLoading}
|
||||
>
|
||||
<Info className='h-5 w-5' />
|
||||
</Button>
|
||||
@@ -206,13 +193,13 @@ export function General() {
|
||||
id='auto-fill-env-vars'
|
||||
checked={isAutoFillEnvVarsEnabled}
|
||||
onCheckedChange={handleAutoFillEnvVarsChange}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isAutoFillEnvVarsLoading}
|
||||
/>
|
||||
</div>
|
||||
{/* <div className='flex items-center justify-between py-1'>
|
||||
<div className='flex items-center justify-between py-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='auto-pan' className='font-medium'>
|
||||
Auto-pan during execution
|
||||
<Label htmlFor='console-expanded-by-default' className='font-medium'>
|
||||
Console expanded by default
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -220,24 +207,24 @@ export function General() {
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 p-1 text-gray-500'
|
||||
aria-label='Learn more about auto-pan feature'
|
||||
disabled={isLoading}
|
||||
aria-label='Learn more about console expanded by default'
|
||||
disabled={isLoading || isConsoleExpandedByDefaultLoading}
|
||||
>
|
||||
<Info className='h-5 w-5' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.autoPan}</p>
|
||||
<p className='text-sm'>{TOOLTIPS.consoleExpandedByDefault}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
id='auto-pan'
|
||||
checked={isAutoPanEnabled}
|
||||
onCheckedChange={handleAutoPanChange}
|
||||
disabled={isLoading}
|
||||
id='console-expanded-by-default'
|
||||
checked={isConsoleExpandedByDefault}
|
||||
onCheckedChange={handleConsoleExpandedByDefaultChange}
|
||||
disabled={isLoading || isConsoleExpandedByDefaultLoading}
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Plus, Send, Trash2 } from 'lucide-react'
|
||||
import { LogOut, Plus, Send, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -43,7 +43,9 @@ interface WorkspaceSelectorProps {
|
||||
onSwitchWorkspace: (workspace: Workspace) => Promise<void>
|
||||
onCreateWorkspace: () => Promise<void>
|
||||
onDeleteWorkspace: (workspace: Workspace) => Promise<void>
|
||||
onLeaveWorkspace: (workspace: Workspace) => Promise<void>
|
||||
isDeleting: boolean
|
||||
isLeaving: boolean
|
||||
}
|
||||
|
||||
export function WorkspaceSelector({
|
||||
@@ -54,7 +56,9 @@ export function WorkspaceSelector({
|
||||
onSwitchWorkspace,
|
||||
onCreateWorkspace,
|
||||
onDeleteWorkspace,
|
||||
onLeaveWorkspace,
|
||||
isDeleting,
|
||||
isLeaving,
|
||||
}: WorkspaceSelectorProps) {
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
@@ -94,6 +98,16 @@ export function WorkspaceSelector({
|
||||
[onDeleteWorkspace]
|
||||
)
|
||||
|
||||
/**
|
||||
* Confirm leave workspace
|
||||
*/
|
||||
const confirmLeaveWorkspace = useCallback(
|
||||
async (workspaceToLeave: Workspace) => {
|
||||
await onLeaveWorkspace(workspaceToLeave)
|
||||
},
|
||||
[onLeaveWorkspace]
|
||||
)
|
||||
|
||||
// Render workspace list
|
||||
const renderWorkspaceList = () => {
|
||||
if (isWorkspacesLoading) {
|
||||
@@ -125,48 +139,95 @@ export function WorkspaceSelector({
|
||||
<div className='flex h-full min-w-0 flex-1 items-center text-left'>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium text-sm',
|
||||
'flex-1 truncate font-medium text-sm',
|
||||
activeWorkspace?.id === workspace.id ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
style={{ maxWidth: '168px' }}
|
||||
>
|
||||
{workspace.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex h-full w-6 flex-shrink-0 items-center justify-center'>
|
||||
{hoveredWorkspaceId === workspace.id && workspace.permissions === 'admin' && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:text-muted-foreground'
|
||||
>
|
||||
<Trash2 className='h-2 w-2' />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<div
|
||||
className='flex h-full items-center justify-center'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{hoveredWorkspaceId === workspace.id && (
|
||||
<>
|
||||
{/* Leave Workspace - for non-admin users */}
|
||||
{workspace.permissions !== 'admin' && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:text-muted-foreground'
|
||||
>
|
||||
<LogOut className='h-2 w-2' />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Workspace</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{workspace.name}"? This action cannot be
|
||||
undone and will permanently delete all workflows and data in this workspace.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => confirmDeleteWorkspace(workspace)}
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Leave Workspace</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to leave "{workspace.name}"? You will lose access
|
||||
to all workflows and data in this workspace.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => confirmLeaveWorkspace(workspace)}
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
disabled={isLeaving}
|
||||
>
|
||||
{isLeaving ? 'Leaving...' : 'Leave'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{/* Delete Workspace - for admin users */}
|
||||
{workspace.permissions === 'admin' && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:text-muted-foreground'
|
||||
>
|
||||
<Trash2 className='h-2 w-2' />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Workspace</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{workspace.name}"? This action cannot
|
||||
be undone and will permanently delete all workflows and data in this
|
||||
workspace.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => confirmDeleteWorkspace(workspace)}
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -117,6 +117,7 @@ export function Sidebar() {
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null)
|
||||
const [isWorkspacesLoading, setIsWorkspacesLoading] = useState(true)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isLeaving, setIsLeaving] = useState(false)
|
||||
|
||||
// Update activeWorkspace ref when state changes
|
||||
activeWorkspaceRef.current = activeWorkspace
|
||||
@@ -361,6 +362,66 @@ export function Sidebar() {
|
||||
[fetchWorkspaces, refreshWorkspaceList, workspaces, switchWorkspace]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle leave workspace
|
||||
*/
|
||||
const handleLeaveWorkspace = useCallback(
|
||||
async (workspaceToLeave: Workspace) => {
|
||||
setIsLeaving(true)
|
||||
try {
|
||||
logger.info('Leaving workspace:', workspaceToLeave.id)
|
||||
|
||||
// Use the existing member removal API with current user's ID
|
||||
const response = await fetch(`/api/workspaces/members/${sessionData?.user?.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId: workspaceToLeave.id,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to leave workspace')
|
||||
}
|
||||
|
||||
logger.info('Left workspace successfully:', workspaceToLeave.id)
|
||||
|
||||
// Check if we're leaving the current workspace (either active or in URL)
|
||||
const isLeavingCurrentWorkspace =
|
||||
workspaceIdRef.current === workspaceToLeave.id ||
|
||||
activeWorkspaceRef.current?.id === workspaceToLeave.id
|
||||
|
||||
if (isLeavingCurrentWorkspace) {
|
||||
// For current workspace leaving, use full fetchWorkspaces with URL validation
|
||||
logger.info(
|
||||
'Leaving current workspace - using full workspace refresh with URL validation'
|
||||
)
|
||||
await fetchWorkspaces()
|
||||
|
||||
// If we left the active workspace, switch to the first available workspace
|
||||
if (activeWorkspaceRef.current?.id === workspaceToLeave.id) {
|
||||
const remainingWorkspaces = workspaces.filter((w) => w.id !== workspaceToLeave.id)
|
||||
if (remainingWorkspaces.length > 0) {
|
||||
await switchWorkspace(remainingWorkspaces[0])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For non-current workspace leaving, just refresh the list without URL validation
|
||||
logger.info('Leaving non-current workspace - using simple list refresh')
|
||||
await refreshWorkspaceList()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error leaving workspace:', error)
|
||||
} finally {
|
||||
setIsLeaving(false)
|
||||
}
|
||||
},
|
||||
[fetchWorkspaces, refreshWorkspaceList, workspaces, switchWorkspace, sessionData?.user?.id]
|
||||
)
|
||||
|
||||
/**
|
||||
* Validate workspace exists before making API calls
|
||||
*/
|
||||
@@ -688,7 +749,9 @@ export function Sidebar() {
|
||||
onSwitchWorkspace={switchWorkspace}
|
||||
onCreateWorkspace={handleCreateWorkspace}
|
||||
onDeleteWorkspace={confirmDeleteWorkspace}
|
||||
onLeaveWorkspace={handleLeaveWorkspace}
|
||||
isDeleting={isDeleting}
|
||||
isLeaving={isLeaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -701,7 +764,7 @@ export function Sidebar() {
|
||||
className='flex h-12 w-full cursor-pointer items-center gap-2 rounded-[14px] border bg-card pr-[10px] pl-3 shadow-xs transition-colors hover:bg-muted/50'
|
||||
>
|
||||
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
|
||||
<span className='flex h-8 flex-1 items-center px-0 font-[350] text-muted-foreground text-sm leading-none'>
|
||||
<span className='flex h-8 flex-1 items-center px-0 text-muted-foreground text-sm leading-none'>
|
||||
Search anything
|
||||
</span>
|
||||
<kbd className='flex h-6 w-8 items-center justify-center rounded-[5px] border border-border bg-background font-mono text-[#CDCDCD] text-xs dark:text-[#454545]'>
|
||||
|
||||
@@ -2807,8 +2807,8 @@ export const ResponseIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='m1044.768 224 150.827 150.827-306.88 306.88h99.413c508.8 0 922.667 413.866 922.667 922.666v90.667H1697.46v-90.667c0-391.146-318.186-709.333-709.333-709.333h-100.16l307.627 307.733-150.827 150.827-564.8-564.8 564.8-564.8ZM564.8 224l150.827 150.827L301.653 788.8l413.974 413.973L564.8 1353.6 0 788.8 564.8 224Z'
|
||||
fillRule='evenodd'
|
||||
d='m1030.975 188 81.249 81.249-429.228 429.228h300.747c516.223 0 936.257 420.034 936.257 936.257v98.028h-114.92v-98.028c0-452.901-368.436-821.337-821.337-821.337H682.996l429.228 429.229-81.25 81.248-567.936-567.937L1030.975 188Zm-463.038.011 81.249 81.25-486.688 486.688 486.688 486.688-81.249 81.249L0 755.949 567.937 188.01Z'
|
||||
fill-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -182,8 +182,26 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
} else if (Object.keys(blockConfig.outputs).length === 0) {
|
||||
// Handle blocks with no outputs (like starter) - show as just <blockname>
|
||||
blockTags = [normalizedBlockName]
|
||||
// Handle blocks with no outputs (like starter) - check for custom input fields
|
||||
if (sourceBlock.type === 'starter') {
|
||||
// Check for custom input format fields
|
||||
const inputFormatValue = useSubBlockStore
|
||||
.getState()
|
||||
.getValue(activeSourceBlockId, 'inputFormat')
|
||||
|
||||
if (inputFormatValue && Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
|
||||
// Use custom input fields if they exist
|
||||
blockTags = inputFormatValue
|
||||
.filter((field: any) => field.name && field.name.trim() !== '')
|
||||
.map((field: any) => `${normalizedBlockName}.${field.name}`)
|
||||
} else {
|
||||
// Fallback to just the block name
|
||||
blockTags = [normalizedBlockName]
|
||||
}
|
||||
} else {
|
||||
// Other blocks with no outputs - show as just <blockname>
|
||||
blockTags = [normalizedBlockName]
|
||||
}
|
||||
} else {
|
||||
// Use default block outputs
|
||||
const outputPaths = generateOutputPaths(blockConfig.outputs)
|
||||
@@ -409,8 +427,26 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
} else if (Object.keys(blockConfig.outputs).length === 0) {
|
||||
// Handle blocks with no outputs (like starter) - show as just <blockname>
|
||||
blockTags = [normalizedBlockName]
|
||||
// Handle blocks with no outputs (like starter) - check for custom input fields
|
||||
if (accessibleBlock.type === 'starter') {
|
||||
// Check for custom input format fields
|
||||
const inputFormatValue = useSubBlockStore
|
||||
.getState()
|
||||
.getValue(accessibleBlockId, 'inputFormat')
|
||||
|
||||
if (inputFormatValue && Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
|
||||
// Use custom input fields if they exist
|
||||
blockTags = inputFormatValue
|
||||
.filter((field: any) => field.name && field.name.trim() !== '')
|
||||
.map((field: any) => `${normalizedBlockName}.${field.name}`)
|
||||
} else {
|
||||
// Fallback to just the block name
|
||||
blockTags = [normalizedBlockName]
|
||||
}
|
||||
} else {
|
||||
// Other blocks with no outputs - show as just <blockname>
|
||||
blockTags = [normalizedBlockName]
|
||||
}
|
||||
} else {
|
||||
// Use default block outputs
|
||||
const outputPaths = generateOutputPaths(blockConfig.outputs)
|
||||
|
||||
2
apps/sim/db/migrations/0055_amused_ender_wiggin.sql
Normal file
2
apps/sim/db/migrations/0055_amused_ender_wiggin.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "settings" ADD COLUMN "console_expanded_by_default" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "settings" DROP COLUMN "debug_mode";
|
||||
5539
apps/sim/db/migrations/meta/0055_snapshot.json
Normal file
5539
apps/sim/db/migrations/meta/0055_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -379,6 +379,13 @@
|
||||
"when": 1752708227343,
|
||||
"tag": "0054_naive_raider",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 55,
|
||||
"version": "7",
|
||||
"when": 1752720748565,
|
||||
"tag": "0055_amused_ender_wiggin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -392,10 +392,10 @@ export const settings = pgTable('settings', {
|
||||
|
||||
// General settings
|
||||
theme: text('theme').notNull().default('system'),
|
||||
debugMode: boolean('debug_mode').notNull().default(false),
|
||||
autoConnect: boolean('auto_connect').notNull().default(true),
|
||||
autoFillEnvVars: boolean('auto_fill_env_vars').notNull().default(true),
|
||||
autoPan: boolean('auto_pan').notNull().default(true),
|
||||
consoleExpandedByDefault: boolean('console_expanded_by_default').notNull().default(true),
|
||||
|
||||
// Privacy settings
|
||||
telemetryEnabled: boolean('telemetry_enabled').notNull().default(true),
|
||||
|
||||
@@ -110,6 +110,8 @@ export const setupExecutorCoreMocks = () => {
|
||||
InputResolver: vi.fn().mockImplementation(() => ({
|
||||
resolveInputs: vi.fn().mockReturnValue({}),
|
||||
resolveBlockReferences: vi.fn().mockImplementation((value) => value),
|
||||
resolveVariableReferences: vi.fn().mockImplementation((value) => value),
|
||||
resolveEnvVariables: vi.fn().mockImplementation((value) => value),
|
||||
})),
|
||||
}))
|
||||
|
||||
|
||||
@@ -85,8 +85,10 @@ describe('ConditionBlockHandler', () => {
|
||||
{}
|
||||
) as Mocked<InputResolver>
|
||||
|
||||
// Ensure the method exists as a mock function on the instance
|
||||
// Ensure the methods exist as mock functions on the instance
|
||||
mockResolver.resolveBlockReferences = vi.fn()
|
||||
mockResolver.resolveVariableReferences = vi.fn()
|
||||
mockResolver.resolveEnvVariables = vi.fn()
|
||||
|
||||
handler = new ConditionBlockHandler(mockPathTracker, mockResolver)
|
||||
|
||||
@@ -147,16 +149,23 @@ describe('ConditionBlockHandler', () => {
|
||||
selectedConditionId: 'cond1',
|
||||
}
|
||||
|
||||
// Mock directly in the test
|
||||
// Mock the full resolution pipeline
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('context.value > 5')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('context.value > 5')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('context.value > 5')
|
||||
|
||||
const result = await handler.execute(mockBlock, inputs, mockContext)
|
||||
|
||||
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
|
||||
'context.value > 5',
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
|
||||
'context.value > 5',
|
||||
mockContext,
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('context.value > 5', true)
|
||||
expect(result).toEqual(expectedOutput)
|
||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
|
||||
})
|
||||
@@ -180,16 +189,23 @@ describe('ConditionBlockHandler', () => {
|
||||
selectedConditionId: 'else1',
|
||||
}
|
||||
|
||||
// Mock directly in the test
|
||||
// Mock the full resolution pipeline
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('context.value < 0')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('context.value < 0')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('context.value < 0')
|
||||
|
||||
const result = await handler.execute(mockBlock, inputs, mockContext)
|
||||
|
||||
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
|
||||
'context.value < 0',
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
|
||||
'context.value < 0',
|
||||
mockContext,
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('context.value < 0', true)
|
||||
expect(result).toEqual(expectedOutput)
|
||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
|
||||
})
|
||||
@@ -209,16 +225,77 @@ describe('ConditionBlockHandler', () => {
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
// Mock directly in the test
|
||||
// Mock the full resolution pipeline
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('{{source-block-1.value}} > 5')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('10 > 5')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('10 > 5')
|
||||
|
||||
const _result = await handler.execute(mockBlock, inputs, mockContext)
|
||||
await handler.execute(mockBlock, inputs, mockContext)
|
||||
|
||||
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
|
||||
'{{source-block-1.value}} > 5',
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
|
||||
'{{source-block-1.value}} > 5',
|
||||
mockContext,
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('10 > 5', true)
|
||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
|
||||
})
|
||||
|
||||
it('should resolve variable references in conditions', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: '<variable.userName> !== null' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
// Mock the full resolution pipeline for variable resolution
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('"john" !== null')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('"john" !== null')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('"john" !== null')
|
||||
|
||||
await handler.execute(mockBlock, inputs, mockContext)
|
||||
|
||||
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
|
||||
'<variable.userName> !== null',
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
|
||||
'"john" !== null',
|
||||
mockContext,
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('"john" !== null', true)
|
||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
|
||||
})
|
||||
|
||||
it('should resolve environment variables in conditions', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: '{{POOP}} === "hi"' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
// Mock the full resolution pipeline for env variable resolution
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('{{POOP}} === "hi"')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('{{POOP}} === "hi"')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('"hi" === "hi"')
|
||||
|
||||
await handler.execute(mockBlock, inputs, mockContext)
|
||||
|
||||
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
|
||||
'{{POOP}} === "hi"',
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
|
||||
'{{POOP}} === "hi"',
|
||||
mockContext,
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('{{POOP}} === "hi"', true)
|
||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
|
||||
})
|
||||
|
||||
@@ -230,8 +307,8 @@ describe('ConditionBlockHandler', () => {
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
const resolutionError = new Error('Could not resolve reference: invalid-ref')
|
||||
// Mock directly in the test
|
||||
mockResolver.resolveBlockReferences.mockImplementation(() => {
|
||||
// Mock the pipeline to throw at the variable resolution stage
|
||||
mockResolver.resolveVariableReferences.mockImplementation(() => {
|
||||
throw resolutionError
|
||||
})
|
||||
|
||||
@@ -247,8 +324,12 @@ describe('ConditionBlockHandler', () => {
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
// Mock directly in the test
|
||||
// Mock the full resolution pipeline
|
||||
mockResolver.resolveVariableReferences.mockReturnValue(
|
||||
'context.nonExistentProperty.doSomething()'
|
||||
)
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('context.nonExistentProperty.doSomething()')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('context.nonExistentProperty.doSomething()')
|
||||
|
||||
await expect(handler.execute(mockBlock, inputs, mockContext)).rejects.toThrow(
|
||||
/^Evaluation error in condition "if": Cannot read properties of undefined \(reading 'doSomething'\)\. \(Resolved: context\.nonExistentProperty\.doSomething\(\)\)$/
|
||||
@@ -271,8 +352,10 @@ describe('ConditionBlockHandler', () => {
|
||||
|
||||
mockContext.workflow!.blocks = [mockSourceBlock, mockBlock, mockTargetBlock2]
|
||||
|
||||
// Mock directly in the test
|
||||
// Mock the full resolution pipeline
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('true')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('true')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('true')
|
||||
|
||||
await expect(handler.execute(mockBlock, inputs, mockContext)).rejects.toThrow(
|
||||
`Target block ${mockTargetBlock1.id} not found`
|
||||
@@ -295,10 +378,16 @@ describe('ConditionBlockHandler', () => {
|
||||
},
|
||||
]
|
||||
|
||||
// Mock directly in the test
|
||||
// Mock the full resolution pipeline
|
||||
mockResolver.resolveVariableReferences
|
||||
.mockReturnValueOnce('false')
|
||||
.mockReturnValueOnce('context.value === 99')
|
||||
mockResolver.resolveBlockReferences
|
||||
.mockReturnValueOnce('false')
|
||||
.mockReturnValueOnce('context.value === 99')
|
||||
mockResolver.resolveEnvVariables
|
||||
.mockReturnValueOnce('false')
|
||||
.mockReturnValueOnce('context.value === 99')
|
||||
|
||||
await expect(handler.execute(mockBlock, inputs, mockContext)).rejects.toThrow(
|
||||
`No matching path found for condition block "${mockBlock.metadata?.name}", and no 'else' block exists.`
|
||||
@@ -314,8 +403,10 @@ describe('ConditionBlockHandler', () => {
|
||||
|
||||
mockContext.loopItems.set(mockBlock.id, { item: 'apple' })
|
||||
|
||||
// Mock directly in the test
|
||||
// Mock the full resolution pipeline
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('context.item === "apple"')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('context.item === "apple"')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('context.item === "apple"')
|
||||
|
||||
const result = await handler.execute(mockBlock, inputs, mockContext)
|
||||
|
||||
|
||||
@@ -105,12 +105,10 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
// 2. Resolve references WITHIN the specific condition's value string
|
||||
let resolvedConditionValue = condition.value
|
||||
try {
|
||||
// Use the resolver instance to process block references within the condition string
|
||||
resolvedConditionValue = this.resolver.resolveBlockReferences(
|
||||
condition.value,
|
||||
context,
|
||||
block // Pass the current condition block as context
|
||||
)
|
||||
// Use full resolution pipeline: variables -> block references -> env vars
|
||||
const resolvedVars = this.resolver.resolveVariableReferences(condition.value, block)
|
||||
const resolvedRefs = this.resolver.resolveBlockReferences(resolvedVars, context, block)
|
||||
resolvedConditionValue = this.resolver.resolveEnvVariables(resolvedRefs, true)
|
||||
logger.info(
|
||||
`Resolved condition "${condition.title}" (${condition.id}): from "${condition.value}" to "${resolvedConditionValue}"`
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ const logger = createLogger('ResponseBlockHandler')
|
||||
|
||||
interface JSONProperty {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array'
|
||||
value: any
|
||||
collapsed?: boolean
|
||||
@@ -92,17 +92,44 @@ export class ResponseBlockHandler implements BlockHandler {
|
||||
const result: any = {}
|
||||
|
||||
for (const prop of builderData) {
|
||||
if (!prop.key.trim()) {
|
||||
return
|
||||
if (!prop.name || !prop.name.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const value = this.convertPropertyValue(prop)
|
||||
result[prop.key] = value
|
||||
result[prop.name] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Static method for UI conversion from Builder to Editor mode
|
||||
static convertBuilderDataToJsonString(builderData: JSONProperty[]): string {
|
||||
if (!Array.isArray(builderData) || builderData.length === 0) {
|
||||
return '{\n \n}'
|
||||
}
|
||||
|
||||
const result: any = {}
|
||||
|
||||
for (const prop of builderData) {
|
||||
if (!prop.name || !prop.name.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
// For UI display, keep variable references as-is without processing
|
||||
result[prop.name] = prop.value
|
||||
}
|
||||
|
||||
// Convert to JSON string, then replace quoted variable references with unquoted ones
|
||||
let jsonString = JSON.stringify(result, null, 2)
|
||||
|
||||
// Replace quoted variable references with unquoted ones
|
||||
// Pattern: "<variable.name>" -> <variable.name>
|
||||
jsonString = jsonString.replace(/"(<[^>]+>)"/g, '$1')
|
||||
|
||||
return jsonString
|
||||
}
|
||||
|
||||
private convertPropertyValue(prop: JSONProperty): any {
|
||||
switch (prop.type) {
|
||||
case 'object':
|
||||
|
||||
@@ -1446,7 +1446,130 @@ describe('InputResolver', () => {
|
||||
}
|
||||
|
||||
const result = connectionResolver.resolveInputs(testBlock, contextWithConnections)
|
||||
expect(result.code).toBe('return Hello World') // Should not be quoted for function blocks
|
||||
expect(result.code).toBe('return "Hello World"') // Should be quoted for function blocks
|
||||
})
|
||||
|
||||
it('should format start.input properly for different block types', () => {
|
||||
// Test function block - should quote strings
|
||||
const functionBlock: SerializedBlock = {
|
||||
id: 'test-function',
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Test Function' },
|
||||
position: { x: 100, y: 100 },
|
||||
config: {
|
||||
tool: BlockType.FUNCTION,
|
||||
params: {
|
||||
code: 'return <start.input>',
|
||||
},
|
||||
},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
// Test condition block - should quote strings
|
||||
const conditionBlock: SerializedBlock = {
|
||||
id: 'test-condition',
|
||||
metadata: { id: BlockType.CONDITION, name: 'Test Condition' },
|
||||
position: { x: 200, y: 100 },
|
||||
config: {
|
||||
tool: BlockType.CONDITION,
|
||||
params: {
|
||||
conditions: JSON.stringify([
|
||||
{ id: 'cond1', title: 'if', value: '<start.input> === "Hello World"' },
|
||||
]),
|
||||
},
|
||||
},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
// Test response block - should use raw string
|
||||
const responseBlock: SerializedBlock = {
|
||||
id: 'test-response',
|
||||
metadata: { id: BlockType.RESPONSE, name: 'Test Response' },
|
||||
position: { x: 300, y: 100 },
|
||||
config: {
|
||||
tool: BlockType.RESPONSE,
|
||||
params: {
|
||||
content: '<start.input>',
|
||||
},
|
||||
},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const functionResult = connectionResolver.resolveInputs(functionBlock, contextWithConnections)
|
||||
expect(functionResult.code).toBe('return "Hello World"') // Quoted for function
|
||||
|
||||
const conditionResult = connectionResolver.resolveInputs(
|
||||
conditionBlock,
|
||||
contextWithConnections
|
||||
)
|
||||
expect(conditionResult.conditions).toBe(
|
||||
'[{"id":"cond1","title":"if","value":"<start.input> === \\"Hello World\\""}]'
|
||||
) // Conditions not resolved at input level
|
||||
|
||||
const responseResult = connectionResolver.resolveInputs(responseBlock, contextWithConnections)
|
||||
expect(responseResult.content).toBe('Hello World') // Raw string for response
|
||||
})
|
||||
|
||||
it('should properly format start.input when resolved directly via resolveBlockReferences', () => {
|
||||
// Test that start.input gets proper formatting for different block types
|
||||
const functionBlock: SerializedBlock = {
|
||||
id: 'test-function',
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Test Function' },
|
||||
position: { x: 100, y: 100 },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const conditionBlock: SerializedBlock = {
|
||||
id: 'test-condition',
|
||||
metadata: { id: BlockType.CONDITION, name: 'Test Condition' },
|
||||
position: { x: 200, y: 100 },
|
||||
config: { tool: BlockType.CONDITION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
// Test function block - should quote strings
|
||||
const functionResult = connectionResolver.resolveBlockReferences(
|
||||
'return <start.input>',
|
||||
contextWithConnections,
|
||||
functionBlock
|
||||
)
|
||||
expect(functionResult).toBe('return "Hello World"')
|
||||
|
||||
// Test condition block - should quote strings
|
||||
const conditionResult = connectionResolver.resolveBlockReferences(
|
||||
'<start.input> === "test"',
|
||||
contextWithConnections,
|
||||
conditionBlock
|
||||
)
|
||||
expect(conditionResult).toBe('"Hello World" === "test"')
|
||||
|
||||
// Test other block types - should use raw string
|
||||
const otherBlock: SerializedBlock = {
|
||||
id: 'test-other',
|
||||
metadata: { id: 'other', name: 'Other Block' },
|
||||
position: { x: 300, y: 100 },
|
||||
config: { tool: 'other', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const otherResult = connectionResolver.resolveBlockReferences(
|
||||
'content: <start.input>',
|
||||
contextWithConnections,
|
||||
otherBlock
|
||||
)
|
||||
expect(otherResult).toBe('content: Hello World')
|
||||
})
|
||||
|
||||
it('should provide helpful error messages for unconnected blocks', () => {
|
||||
|
||||
@@ -439,21 +439,47 @@ export class InputResolver {
|
||||
else if (blockType === 'condition') {
|
||||
formattedValue = this.stringifyForCondition(replacementValue)
|
||||
}
|
||||
// For response blocks, preserve object structure as-is for proper JSON response
|
||||
else if (blockType === 'response') {
|
||||
formattedValue = replacementValue
|
||||
}
|
||||
// For all other blocks, stringify objects
|
||||
else {
|
||||
// Preserve full JSON structure for objects
|
||||
formattedValue = JSON.stringify(replacementValue)
|
||||
}
|
||||
} else {
|
||||
// For primitive values
|
||||
formattedValue = String(replacementValue)
|
||||
// For primitive values, format based on target block type
|
||||
if (blockType === 'function') {
|
||||
formattedValue = this.formatValueForCodeContext(
|
||||
replacementValue,
|
||||
currentBlock,
|
||||
isInTemplateLiteral
|
||||
)
|
||||
} else if (blockType === 'condition') {
|
||||
formattedValue = this.stringifyForCondition(replacementValue)
|
||||
} else {
|
||||
formattedValue = String(replacementValue)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standard handling for non-input references
|
||||
formattedValue =
|
||||
typeof replacementValue === 'object'
|
||||
? JSON.stringify(replacementValue)
|
||||
: String(replacementValue)
|
||||
const blockType = currentBlock.metadata?.id
|
||||
|
||||
if (blockType === 'response') {
|
||||
// For response blocks, properly quote string values for JSON context
|
||||
if (typeof replacementValue === 'string') {
|
||||
// Properly escape and quote the string for JSON
|
||||
formattedValue = JSON.stringify(replacementValue)
|
||||
} else {
|
||||
formattedValue = replacementValue
|
||||
}
|
||||
} else {
|
||||
formattedValue =
|
||||
typeof replacementValue === 'object'
|
||||
? JSON.stringify(replacementValue)
|
||||
: String(replacementValue)
|
||||
}
|
||||
}
|
||||
|
||||
resolvedValue = resolvedValue.replace(match, formattedValue)
|
||||
@@ -574,6 +600,8 @@ export class InputResolver {
|
||||
}
|
||||
}
|
||||
|
||||
const blockType = currentBlock.metadata?.id
|
||||
|
||||
let formattedValue: string
|
||||
|
||||
if (currentBlock.metadata?.id === 'condition') {
|
||||
@@ -589,12 +617,22 @@ export class InputResolver {
|
||||
value.includes('}') &&
|
||||
value.includes('`')
|
||||
|
||||
// For code blocks, use our formatter
|
||||
formattedValue = this.formatValueForCodeContext(
|
||||
replacementValue,
|
||||
currentBlock,
|
||||
isInTemplateLiteral
|
||||
)
|
||||
// For response blocks, properly quote string values for JSON context
|
||||
if (currentBlock.metadata?.id === 'response') {
|
||||
if (typeof replacementValue === 'string') {
|
||||
// Properly escape and quote the string for JSON
|
||||
formattedValue = JSON.stringify(replacementValue)
|
||||
} else {
|
||||
formattedValue = replacementValue
|
||||
}
|
||||
} else {
|
||||
// For code blocks, use our formatter
|
||||
formattedValue = this.formatValueForCodeContext(
|
||||
replacementValue,
|
||||
currentBlock,
|
||||
isInTemplateLiteral
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// The function execution API will handle variable resolution within code strings
|
||||
formattedValue =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import type { GeneralStore } from './types'
|
||||
import type { General, GeneralStore, UserSettings } from './types'
|
||||
|
||||
const logger = createLogger('GeneralStore')
|
||||
|
||||
@@ -15,50 +15,115 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
let lastLoadTime = 0
|
||||
let errorRetryCount = 0
|
||||
|
||||
return {
|
||||
const store: General = {
|
||||
isAutoConnectEnabled: true,
|
||||
isDebugModeEnabled: false,
|
||||
isAutoFillEnvVarsEnabled: true,
|
||||
isAutoPanEnabled: true,
|
||||
theme: 'system',
|
||||
isConsoleExpandedByDefault: true,
|
||||
isDebugModeEnabled: false,
|
||||
theme: 'system' as const,
|
||||
telemetryEnabled: true,
|
||||
telemetryNotifiedUser: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
// Individual loading states
|
||||
isAutoConnectLoading: false,
|
||||
isAutoFillEnvVarsLoading: false,
|
||||
isAutoPanLoading: false,
|
||||
isConsoleExpandedByDefaultLoading: false,
|
||||
isThemeLoading: false,
|
||||
isTelemetryLoading: false,
|
||||
}
|
||||
|
||||
// Basic Actions
|
||||
toggleAutoConnect: () => {
|
||||
// Optimistic update helper
|
||||
const updateSettingOptimistic = async <K extends keyof UserSettings>(
|
||||
key: K,
|
||||
value: UserSettings[K],
|
||||
loadingKey: keyof General,
|
||||
stateKey: keyof General
|
||||
) => {
|
||||
// Prevent multiple simultaneous updates
|
||||
if ((get() as any)[loadingKey]) return
|
||||
|
||||
const originalValue = (get() as any)[stateKey]
|
||||
|
||||
// Optimistic update
|
||||
set({ [stateKey]: value, [loadingKey]: true } as any)
|
||||
|
||||
try {
|
||||
await get().updateSetting(key, value)
|
||||
set({ [loadingKey]: false } as any)
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
set({ [stateKey]: originalValue, [loadingKey]: false } as any)
|
||||
logger.error(`Failed to update ${String(key)}, rolled back:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...store,
|
||||
// Basic Actions with optimistic updates
|
||||
toggleAutoConnect: async () => {
|
||||
if (get().isAutoConnectLoading) return
|
||||
const newValue = !get().isAutoConnectEnabled
|
||||
set({ isAutoConnectEnabled: newValue })
|
||||
get().updateSetting('autoConnect', newValue)
|
||||
await updateSettingOptimistic(
|
||||
'autoConnect',
|
||||
newValue,
|
||||
'isAutoConnectLoading',
|
||||
'isAutoConnectEnabled'
|
||||
)
|
||||
},
|
||||
|
||||
toggleAutoFillEnvVars: async () => {
|
||||
if (get().isAutoFillEnvVarsLoading) return
|
||||
const newValue = !get().isAutoFillEnvVarsEnabled
|
||||
await updateSettingOptimistic(
|
||||
'autoFillEnvVars',
|
||||
newValue,
|
||||
'isAutoFillEnvVarsLoading',
|
||||
'isAutoFillEnvVarsEnabled'
|
||||
)
|
||||
},
|
||||
|
||||
toggleAutoPan: async () => {
|
||||
if (get().isAutoPanLoading) return
|
||||
const newValue = !get().isAutoPanEnabled
|
||||
await updateSettingOptimistic(
|
||||
'autoPan',
|
||||
newValue,
|
||||
'isAutoPanLoading',
|
||||
'isAutoPanEnabled'
|
||||
)
|
||||
},
|
||||
|
||||
toggleConsoleExpandedByDefault: async () => {
|
||||
if (get().isConsoleExpandedByDefaultLoading) return
|
||||
const newValue = !get().isConsoleExpandedByDefault
|
||||
await updateSettingOptimistic(
|
||||
'consoleExpandedByDefault',
|
||||
newValue,
|
||||
'isConsoleExpandedByDefaultLoading',
|
||||
'isConsoleExpandedByDefault'
|
||||
)
|
||||
},
|
||||
|
||||
toggleDebugMode: () => {
|
||||
const newValue = !get().isDebugModeEnabled
|
||||
set({ isDebugModeEnabled: newValue })
|
||||
get().updateSetting('debugMode', newValue)
|
||||
set({ isDebugModeEnabled: !get().isDebugModeEnabled })
|
||||
},
|
||||
|
||||
toggleAutoFillEnvVars: () => {
|
||||
const newValue = !get().isAutoFillEnvVarsEnabled
|
||||
set({ isAutoFillEnvVarsEnabled: newValue })
|
||||
get().updateSetting('autoFillEnvVars', newValue)
|
||||
setTheme: async (theme) => {
|
||||
if (get().isThemeLoading) return
|
||||
await updateSettingOptimistic('theme', theme, 'isThemeLoading', 'theme')
|
||||
},
|
||||
|
||||
toggleAutoPan: () => {
|
||||
const newValue = !get().isAutoPanEnabled
|
||||
set({ isAutoPanEnabled: newValue })
|
||||
get().updateSetting('autoPan', newValue)
|
||||
},
|
||||
|
||||
setTheme: (theme) => {
|
||||
set({ theme })
|
||||
get().updateSetting('theme', theme)
|
||||
},
|
||||
|
||||
setTelemetryEnabled: (enabled) => {
|
||||
set({ telemetryEnabled: enabled })
|
||||
get().updateSetting('telemetryEnabled', enabled)
|
||||
setTelemetryEnabled: async (enabled) => {
|
||||
if (get().isTelemetryLoading) return
|
||||
await updateSettingOptimistic(
|
||||
'telemetryEnabled',
|
||||
enabled,
|
||||
'isTelemetryLoading',
|
||||
'telemetryEnabled'
|
||||
)
|
||||
},
|
||||
|
||||
setTelemetryNotifiedUser: (notified) => {
|
||||
@@ -101,9 +166,9 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
|
||||
set({
|
||||
isAutoConnectEnabled: data.autoConnect,
|
||||
isDebugModeEnabled: data.debugMode,
|
||||
isAutoFillEnvVarsEnabled: data.autoFillEnvVars,
|
||||
isAutoPanEnabled: data.autoPan ?? true, // Default to true if undefined
|
||||
isConsoleExpandedByDefault: data.consoleExpandedByDefault ?? true, // Default to true if undefined
|
||||
theme: data.theme,
|
||||
telemetryEnabled: data.telemetryEnabled,
|
||||
telemetryNotifiedUser: data.telemetryNotifiedUser,
|
||||
@@ -146,22 +211,14 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
}
|
||||
|
||||
set({ error: null })
|
||||
|
||||
lastLoadTime = Date.now()
|
||||
errorRetryCount = 0
|
||||
} catch (error) {
|
||||
logger.error(`Error updating setting ${key}:`, error)
|
||||
set({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
|
||||
if (errorRetryCount < MAX_ERROR_RETRIES) {
|
||||
errorRetryCount++
|
||||
logger.debug(`Retry attempt ${errorRetryCount} after error`)
|
||||
get().loadSettings(true)
|
||||
} else {
|
||||
logger.warn(
|
||||
`Max retries (${MAX_ERROR_RETRIES}) exceeded, skipping automatic loadSettings`
|
||||
)
|
||||
}
|
||||
// Don't auto-retry on individual setting updates to avoid conflicts
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
export interface General {
|
||||
isAutoConnectEnabled: boolean
|
||||
isDebugModeEnabled: boolean
|
||||
isAutoFillEnvVarsEnabled: boolean
|
||||
isAutoPanEnabled: boolean
|
||||
isConsoleExpandedByDefault: boolean
|
||||
isDebugModeEnabled: boolean
|
||||
theme: 'system' | 'light' | 'dark'
|
||||
telemetryEnabled: boolean
|
||||
telemetryNotifiedUser: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
// Individual loading states for optimistic updates
|
||||
isAutoConnectLoading: boolean
|
||||
isAutoFillEnvVarsLoading: boolean
|
||||
isAutoPanLoading: boolean
|
||||
isConsoleExpandedByDefaultLoading: boolean
|
||||
isThemeLoading: boolean
|
||||
isTelemetryLoading: boolean
|
||||
}
|
||||
|
||||
export interface GeneralActions {
|
||||
toggleAutoConnect: () => void
|
||||
toggleAutoConnect: () => Promise<void>
|
||||
toggleAutoFillEnvVars: () => Promise<void>
|
||||
toggleAutoPan: () => Promise<void>
|
||||
toggleConsoleExpandedByDefault: () => Promise<void>
|
||||
toggleDebugMode: () => void
|
||||
toggleAutoFillEnvVars: () => void
|
||||
toggleAutoPan: () => void
|
||||
setTheme: (theme: 'system' | 'light' | 'dark') => void
|
||||
setTelemetryEnabled: (enabled: boolean) => void
|
||||
setTheme: (theme: 'system' | 'light' | 'dark') => Promise<void>
|
||||
setTelemetryEnabled: (enabled: boolean) => Promise<void>
|
||||
setTelemetryNotifiedUser: (notified: boolean) => void
|
||||
loadSettings: (force?: boolean) => Promise<void>
|
||||
updateSetting: <K extends keyof UserSettings>(key: K, value: UserSettings[K]) => Promise<void>
|
||||
@@ -26,10 +35,10 @@ export type GeneralStore = General & GeneralActions
|
||||
|
||||
export type UserSettings = {
|
||||
theme: 'system' | 'light' | 'dark'
|
||||
debugMode: boolean
|
||||
autoConnect: boolean
|
||||
autoFillEnvVars: boolean
|
||||
autoPan: boolean
|
||||
consoleExpandedByDefault: boolean
|
||||
telemetryEnabled: boolean
|
||||
telemetryNotifiedUser: boolean
|
||||
}
|
||||
|
||||
16
bun.lock
16
bun.lock
@@ -20,7 +20,7 @@
|
||||
"lint-staged": "16.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"turbo": "2.5.4",
|
||||
"turbo": "2.5.5",
|
||||
},
|
||||
},
|
||||
"apps/docs": {
|
||||
@@ -2953,19 +2953,19 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"turbo": ["turbo@2.5.4", "", { "optionalDependencies": { "turbo-darwin-64": "2.5.4", "turbo-darwin-arm64": "2.5.4", "turbo-linux-64": "2.5.4", "turbo-linux-arm64": "2.5.4", "turbo-windows-64": "2.5.4", "turbo-windows-arm64": "2.5.4" }, "bin": { "turbo": "bin/turbo" } }, "sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA=="],
|
||||
"turbo": ["turbo@2.5.5", "", { "optionalDependencies": { "turbo-darwin-64": "2.5.5", "turbo-darwin-arm64": "2.5.5", "turbo-linux-64": "2.5.5", "turbo-linux-arm64": "2.5.5", "turbo-windows-64": "2.5.5", "turbo-windows-arm64": "2.5.5" }, "bin": { "turbo": "bin/turbo" } }, "sha512-eZ7wI6KjtT1eBqCnh2JPXWNUAxtoxxfi6VdBdZFvil0ychCOTxbm7YLRBi1JSt7U3c+u3CLxpoPxLdvr/Npr3A=="],
|
||||
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ah6YnH2dErojhFooxEzmvsoZQTMImaruZhFPfMKPBq8sb+hALRdvBNLqfc8NWlZq576FkfRZ/MSi4SHvVFT9PQ=="],
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.5.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-RYnTz49u4F5tDD2SUwwtlynABNBAfbyT2uU/brJcyh5k6lDLyNfYKdKmqd3K2ls4AaiALWrFKVSBsiVwhdFNzQ=="],
|
||||
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2+Nx6LAyuXw2MdXb7pxqle3MYignLvS7OwtsP9SgtSBaMlnNlxl9BovzqdYAgkUW3AsYiQMJ/wBRb7d+xemM5A=="],
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.5.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Tk+ZeSNdBobZiMw9aFypQt0DlLsWSFWu1ymqsAdJLuPoAH05qCfYtRxE1pJuYHcJB5pqI+/HOxtJoQ40726Btw=="],
|
||||
|
||||
"turbo-linux-64": ["turbo-linux-64@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-5May2kjWbc8w4XxswGAl74GZ5eM4Gr6IiroqdLhXeXyfvWEdm2mFYCSWOzz0/z5cAgqyGidF1jt1qzUR8hTmOA=="],
|
||||
"turbo-linux-64": ["turbo-linux-64@2.5.5", "", { "os": "linux", "cpu": "x64" }, "sha512-2/XvMGykD7VgsvWesZZYIIVXMlgBcQy+ZAryjugoTcvJv8TZzSU/B1nShcA7IAjZ0q7OsZ45uP2cOb8EgKT30w=="],
|
||||
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-/2yqFaS3TbfxV3P5yG2JUI79P7OUQKOUvAnx4MV9Bdz6jqHsHwc9WZPpO4QseQm+NvmgY6ICORnoVPODxGUiJg=="],
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.5.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-DW+8CjCjybu0d7TFm9dovTTVg1VRnlkZ1rceO4zqsaLrit3DgHnN4to4uwyuf9s2V/BwS3IYcRy+HG9BL596Iw=="],
|
||||
|
||||
"turbo-windows-64": ["turbo-windows-64@2.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-EQUO4SmaCDhO6zYohxIjJpOKRN3wlfU7jMAj3CgcyTPvQR/UFLEKAYHqJOnJtymbQmiiM/ihX6c6W6Uq0yC7mA=="],
|
||||
"turbo-windows-64": ["turbo-windows-64@2.5.5", "", { "os": "win32", "cpu": "x64" }, "sha512-q5p1BOy8ChtSZfULuF1BhFMYIx6bevXu4fJ+TE/hyNfyHJIfjl90Z6jWdqAlyaFLmn99X/uw+7d6T/Y/dr5JwQ=="],
|
||||
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-oQ8RrK1VS8lrxkLriotFq+PiF7iiGgkZtfLKF4DDKsmdbPo0O9R2mQxm7jHLuXraRCuIQDWMIw6dpcr7Iykf4A=="],
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-AXbF1KmpHUq3PKQwddMGoKMYhHsy5t1YBQO8HZ04HLMR0rWv9adYlQ8kaeQJTko1Ay1anOBFTqaxfVOOsu7+1Q=="],
|
||||
|
||||
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"lint-staged": "16.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"turbo": "2.5.4"
|
||||
"turbo": "2.5.5"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,json,css,scss}": [
|
||||
|
||||
Reference in New Issue
Block a user