mirror of
https://github.com/reconurge/flowsint.git
synced 2026-03-09 07:17:07 -05:00
feat(app): progress on templates
This commit is contained in:
@@ -37,6 +37,7 @@ export interface TestTemplateResponse {
|
||||
duration_ms: number
|
||||
status_code?: number
|
||||
url: string
|
||||
raw_results?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface GenerateTemplateResponse {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export { YamlEditor } from './yaml-editor'
|
||||
export { JsonViewer } from './json-viewer'
|
||||
export { TemplateEditor } from './template-editor'
|
||||
export { TemplateEditorHeader } from './template-editor-header'
|
||||
export { TemplateTestPanel } from './template-test-panel'
|
||||
export type { TestResult } from './template-test-panel'
|
||||
export { AIChatPanel } from './ai-chat-panel'
|
||||
export type { AIChatPanelHandle } from './ai-chat-panel'
|
||||
export { templateSchema, defaultTemplate } from './template-schema'
|
||||
|
||||
233
flowsint-app/src/components/templates/template-editor-header.tsx
Normal file
233
flowsint-app/src/components/templates/template-editor-header.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Keyboard,
|
||||
Sparkles,
|
||||
Save
|
||||
} from 'lucide-react'
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
interface TemplateEditorHeaderProps {
|
||||
isEditMode: boolean
|
||||
templateName: string
|
||||
hasChanges: boolean
|
||||
hasErrors: boolean
|
||||
totalErrors: number
|
||||
validationErrors: string[]
|
||||
editorErrors: editor.IMarkerData[]
|
||||
isSaving: boolean
|
||||
copied: boolean
|
||||
deletePending: boolean
|
||||
onSave: () => void
|
||||
onDelete: () => void
|
||||
onCopy: () => void
|
||||
onGenerateClick: () => void
|
||||
onNavigateBack: () => void
|
||||
}
|
||||
|
||||
export function TemplateEditorHeader({
|
||||
isEditMode,
|
||||
templateName,
|
||||
hasChanges,
|
||||
hasErrors,
|
||||
totalErrors,
|
||||
validationErrors,
|
||||
editorErrors,
|
||||
isSaving,
|
||||
copied,
|
||||
deletePending,
|
||||
onSave,
|
||||
onDelete,
|
||||
onCopy,
|
||||
onGenerateClick,
|
||||
onNavigateBack
|
||||
}: TemplateEditorHeaderProps) {
|
||||
return (
|
||||
<header className="shrink-0 flex items-center justify-between px-4 py-2 border-b bg-card/30">
|
||||
{/* Left: Breadcrumb + Status */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Templates
|
||||
</button>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/40" />
|
||||
<span className="font-medium">{isEditMode ? templateName : 'New Template'}</span>
|
||||
|
||||
{isEditMode ? (
|
||||
hasChanges ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 text-[10px] bg-amber-500/10 text-amber-600 border-amber-500/20"
|
||||
>
|
||||
Unsaved
|
||||
</Badge>
|
||||
) : (
|
||||
!hasErrors && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 text-[10px] bg-emerald-500/10 text-emerald-600 border-emerald-500/20"
|
||||
>
|
||||
<CheckCircle2 className="h-2.5 w-2.5 mr-0.5" />
|
||||
Saved
|
||||
</Badge>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 text-[10px] bg-blue-500/10 text-blue-600 border-blue-500/20"
|
||||
>
|
||||
Draft
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Toolbar */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Validate (popover with errors) */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-7 gap-1.5 text-xs ${hasErrors ? 'text-destructive hover:text-destructive' : 'text-emerald-500 hover:text-emerald-500'}`}
|
||||
>
|
||||
{hasErrors ? (
|
||||
<>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
{totalErrors} error{totalErrors !== 1 ? 's' : ''}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Valid
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<div className="p-3 border-b">
|
||||
<p className="text-sm font-medium">
|
||||
{hasErrors ? 'Validation Errors' : 'Validation Passed'}
|
||||
</p>
|
||||
</div>
|
||||
{hasErrors ? (
|
||||
<div className="p-3 space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{validationErrors.map((error, i) => (
|
||||
<div key={i} className="flex gap-2 text-xs">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0 mt-0.5" />
|
||||
<span className="text-destructive">{error}</span>
|
||||
</div>
|
||||
))}
|
||||
{editorErrors
|
||||
.filter((e) => e.severity >= 8)
|
||||
.map((error, i) => (
|
||||
<div key={`e-${i}`} className="flex gap-2 text-xs">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0 mt-0.5" />
|
||||
<span className="text-destructive">
|
||||
Line {error.startLineNumber}: {error.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All required fields are present and valid.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="w-px h-4 bg-border mx-0.5" />
|
||||
|
||||
{/* Generate */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onGenerateClick}
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Generate
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Focus AI assistant</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px h-4 bg-border mx-0.5" />
|
||||
|
||||
{/* Copy */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={onCopy} className="h-7 w-7 p-0">
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy YAML</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Save */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
disabled={(isEditMode && !hasChanges) || hasErrors || isSaving}
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Save Enricher
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Keyboard className="h-3 w-3" /> ⌘S
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Delete */}
|
||||
{isEditMode && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-border mx-0.5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
disabled={deletePending}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete template</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -4,61 +4,31 @@ import { useNavigate } from '@tanstack/react-router'
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
Play,
|
||||
FileCode2,
|
||||
FlaskConical,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Keyboard,
|
||||
Sparkles,
|
||||
Save
|
||||
XCircle
|
||||
} from 'lucide-react'
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'
|
||||
import { useConfirm } from '@/components/use-confirm-dialog'
|
||||
|
||||
import { YamlEditor } from './yaml-editor'
|
||||
import { JsonViewer } from './json-viewer'
|
||||
import { AIChatPanel, type AIChatPanelHandle } from './ai-chat-panel'
|
||||
import { defaultTemplate, templateSchema, type TemplateData } from './template-schema'
|
||||
import { TemplateEditorHeader } from './template-editor-header'
|
||||
import { TemplateTestPanel, type TestResult } from './template-test-panel'
|
||||
import { templateService } from '@/api/template-service'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TemplateEditorProps {
|
||||
templateId?: string
|
||||
initialContent?: TemplateData
|
||||
importedYaml?: string
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
success: boolean
|
||||
data?: unknown
|
||||
error?: string
|
||||
duration?: number
|
||||
url?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function validateTemplate(content: string): {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
@@ -114,10 +84,6 @@ function validateTemplate(content: string): {
|
||||
return { valid: errors.length === 0, errors, data: parsed }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TemplateEditor({ templateId, initialContent, importedYaml }: TemplateEditorProps) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -130,7 +96,6 @@ export function TemplateEditor({ templateId, initialContent, importedYaml }: Tem
|
||||
[initialContent, importedYaml]
|
||||
)
|
||||
|
||||
// ── Editor state ──────────────────────────────────────────────────────────
|
||||
const [content, setContent] = useState(initialYaml)
|
||||
const [savedContent, setSavedContent] = useState(initialYaml)
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -138,17 +103,14 @@ export function TemplateEditor({ templateId, initialContent, importedYaml }: Tem
|
||||
const [validationResult, setValidationResult] = useState(() => validateTemplate(initialYaml))
|
||||
const [activeTab, setActiveTab] = useState<'editor' | 'test'>('editor')
|
||||
|
||||
// ── Test state ────────────────────────────────────────────────────────────
|
||||
const [testInput, setTestInput] = useState('')
|
||||
const [testParams, setTestParams] = useState<Record<string, string>>({})
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
|
||||
// ── Refs ──────────────────────────────────────────────────────────────────
|
||||
const chatRef = useRef<AIChatPanelHandle>(null)
|
||||
const contentBeingSavedRef = useRef<string | null>(null)
|
||||
|
||||
// ── Derived state ─────────────────────────────────────────────────────────
|
||||
const hasChanges = content !== savedContent
|
||||
const hasErrors = !validationResult.valid || editorErrors.some((e) => e.severity >= 8)
|
||||
const templateName = validationResult.data?.name || 'Untitled'
|
||||
@@ -158,8 +120,6 @@ export function TemplateEditor({ templateId, initialContent, importedYaml }: Tem
|
||||
const stateRef = useRef({ hasErrors, hasChanges, data: validationResult.data, content })
|
||||
stateRef.current = { hasErrors, hasChanges, data: validationResult.data, content }
|
||||
|
||||
// ── Mutations ─────────────────────────────────────────────────────────────
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: TemplateData) =>
|
||||
templateService.create({
|
||||
@@ -209,8 +169,6 @@ export function TemplateEditor({ templateId, initialContent, importedYaml }: Tem
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const handleChange = useCallback((value: string) => {
|
||||
setContent(value)
|
||||
setValidationResult(validateTemplate(value))
|
||||
@@ -279,10 +237,12 @@ export function TemplateEditor({ templateId, initialContent, importedYaml }: Tem
|
||||
setTestResult({
|
||||
success: response.success,
|
||||
data: response.data,
|
||||
raw_results: response.raw_results,
|
||||
error: response.error,
|
||||
duration: response.duration_ms,
|
||||
url: response.url
|
||||
})
|
||||
console.log(response)
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
@@ -294,7 +254,7 @@ export function TemplateEditor({ templateId, initialContent, importedYaml }: Tem
|
||||
}
|
||||
}, [isEditMode, templateId, testInput, validationResult.data])
|
||||
|
||||
const buildPreviewUrl = () => {
|
||||
const buildPreviewUrl = useCallback(() => {
|
||||
if (!validationResult.data?.request?.url) return ''
|
||||
const inputKey = validationResult.data.input?.key || 'value'
|
||||
let url = validationResult.data.request.url.replace(
|
||||
@@ -315,9 +275,7 @@ export function TemplateEditor({ templateId, initialContent, importedYaml }: Tem
|
||||
url += (url.includes('?') ? '&' : '?') + searchParams.toString()
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// ── Keyboard shortcuts ────────────────────────────────────────────────────
|
||||
}, [validationResult.data, testInput, paramKeys, templateParams, testParams])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -330,197 +288,34 @@ export function TemplateEditor({ templateId, initialContent, importedYaml }: Tem
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleSave])
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────────────
|
||||
|
||||
const totalErrors =
|
||||
validationResult.errors.length + editorErrors.filter((e) => e.severity >= 8).length
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
{/* ── Header ──────────────────────────────────────────────── */}
|
||||
<header className="shrink-0 flex items-center justify-between px-4 py-2 border-b bg-card/30">
|
||||
{/* Left: Breadcrumb + Status */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
onClick={() => navigate({ to: '/dashboard/enrichers' })}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Templates
|
||||
</button>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/40" />
|
||||
<span className="font-medium">{isEditMode ? templateName : 'New Template'}</span>
|
||||
<TemplateEditorHeader
|
||||
isEditMode={isEditMode}
|
||||
templateName={templateName}
|
||||
hasChanges={hasChanges}
|
||||
hasErrors={hasErrors}
|
||||
totalErrors={totalErrors}
|
||||
validationErrors={validationResult.errors}
|
||||
editorErrors={editorErrors}
|
||||
isSaving={isSaving}
|
||||
copied={copied}
|
||||
deletePending={deleteMutation.isPending}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
onCopy={handleCopy}
|
||||
onGenerateClick={() => {
|
||||
setActiveTab('editor')
|
||||
setTimeout(() => chatRef.current?.focusInput(), 50)
|
||||
}}
|
||||
onNavigateBack={() => navigate({ to: '/dashboard/enrichers' })}
|
||||
/>
|
||||
|
||||
{isEditMode ? (
|
||||
hasChanges ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 text-[10px] bg-amber-500/10 text-amber-600 border-amber-500/20"
|
||||
>
|
||||
Unsaved
|
||||
</Badge>
|
||||
) : (
|
||||
!hasErrors && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 text-[10px] bg-emerald-500/10 text-emerald-600 border-emerald-500/20"
|
||||
>
|
||||
<CheckCircle2 className="h-2.5 w-2.5 mr-0.5" />
|
||||
Saved
|
||||
</Badge>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 text-[10px] bg-blue-500/10 text-blue-600 border-blue-500/20"
|
||||
>
|
||||
Draft
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Toolbar */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Validate (popover with errors) */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-7 gap-1.5 text-xs ${hasErrors ? 'text-destructive hover:text-destructive' : 'text-emerald-500 hover:text-emerald-500'}`}
|
||||
>
|
||||
{hasErrors ? (
|
||||
<>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
{totalErrors} error{totalErrors !== 1 ? 's' : ''}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Valid
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<div className="p-3 border-b">
|
||||
<p className="text-sm font-medium">
|
||||
{hasErrors ? 'Validation Errors' : 'Validation Passed'}
|
||||
</p>
|
||||
</div>
|
||||
{hasErrors ? (
|
||||
<div className="p-3 space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{validationResult.errors.map((error, i) => (
|
||||
<div key={i} className="flex gap-2 text-xs">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0 mt-0.5" />
|
||||
<span className="text-destructive">{error}</span>
|
||||
</div>
|
||||
))}
|
||||
{editorErrors
|
||||
.filter((e) => e.severity >= 8)
|
||||
.map((error, i) => (
|
||||
<div key={`e-${i}`} className="flex gap-2 text-xs">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0 mt-0.5" />
|
||||
<span className="text-destructive">
|
||||
Line {error.startLineNumber}: {error.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All required fields are present and valid.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="w-px h-4 bg-border mx-0.5" />
|
||||
|
||||
{/* Generate */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setActiveTab('editor')
|
||||
setTimeout(() => chatRef.current?.focusInput(), 50)
|
||||
}}
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Generate
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Focus AI assistant</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px h-4 bg-border mx-0.5" />
|
||||
|
||||
{/* Copy */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={handleCopy} className="h-7 w-7 p-0">
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy YAML</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Save */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={(isEditMode && !hasChanges) || hasErrors || isSaving}
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Save Enricher
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Keyboard className="h-3 w-3" /> ⌘S
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Delete */}
|
||||
{isEditMode && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-border mx-0.5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete template</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Tab Bar ─────────────────────────────────────────────── */}
|
||||
{/* Tab Bar */}
|
||||
<div className="shrink-0 border-b bg-card/30">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
@@ -540,13 +335,11 @@ export function TemplateEditor({ templateId, initialContent, importedYaml }: Tem
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* ── Tab Content ─────────────────────────────────────────── */}
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{/* ── Editor Tab ───────────────────────────────────────── */}
|
||||
{activeTab === 'editor' && (
|
||||
<div className="h-full">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* Left: Code Editor */}
|
||||
<ResizablePanel defaultSize={60} minSize={30}>
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex-1 min-h-0">
|
||||
@@ -580,198 +373,28 @@ export function TemplateEditor({ templateId, initialContent, importedYaml }: Tem
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<ResizableHandle className="hover:bg-primary/20 active:bg-primary/30 transition-colors data-[resize-handle-state=drag]:bg-primary/30" />
|
||||
|
||||
{/* Right: AI Chat */}
|
||||
<ResizablePanel defaultSize={40} minSize={25}>
|
||||
<AIChatPanel
|
||||
ref={chatRef}
|
||||
onApplyYaml={handleApplyYaml}
|
||||
currentYaml={content}
|
||||
/>
|
||||
<AIChatPanel ref={chatRef} onApplyYaml={handleApplyYaml} currentYaml={content} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Test Tab ─────────────────────────────────────────── */}
|
||||
{activeTab === 'test' && (
|
||||
<div className="h-full flex overflow-hidden">
|
||||
{/* Test Input */}
|
||||
<div className="w-80 shrink-0 border-r p-6 flex flex-col gap-6 overflow-y-auto">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-1">Test your template</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter a test value to simulate the enricher request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-input">
|
||||
Input Value
|
||||
{validationResult.data?.input?.key && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({validationResult.data.input.key})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="test-input"
|
||||
placeholder={`Enter ${validationResult.data?.input?.type || 'value'}...`}
|
||||
value={testInput}
|
||||
onChange={(e) => setTestInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleTest()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{paramKeys.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Query Parameters</Label>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="text-left px-3 py-1.5 text-xs font-medium text-muted-foreground w-1/3">
|
||||
Key
|
||||
</th>
|
||||
<th className="text-left px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paramKeys.map((paramKey, idx) => {
|
||||
const defaultValue = templateParams[paramKey] ?? ''
|
||||
return (
|
||||
<tr
|
||||
key={paramKey}
|
||||
className={idx < paramKeys.length - 1 ? 'border-b' : ''}
|
||||
>
|
||||
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground">
|
||||
{paramKey}
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<Input
|
||||
placeholder={String(defaultValue) || 'Value'}
|
||||
value={testParams[paramKey] ?? ''}
|
||||
onChange={(e) =>
|
||||
setTestParams((prev) => ({
|
||||
...prev,
|
||||
[paramKey]: e.target.value
|
||||
}))
|
||||
}
|
||||
className="h-7 text-xs border-0 bg-transparent focus-visible:ring-1 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationResult.data?.request?.url && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Request URL</Label>
|
||||
<code className="block text-xs bg-muted p-2 rounded break-all">
|
||||
{buildPreviewUrl()}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleTest}
|
||||
disabled={isTesting || hasErrors || !testInput.trim()}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
Run test
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasErrors && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20">
|
||||
<p className="text-sm text-destructive">
|
||||
Fix validation errors before testing.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Result */}
|
||||
<div className="flex-1 min-w-0 p-6 bg-muted/20 overflow-y-auto">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Response</h3>
|
||||
{testResult?.duration !== undefined && testResult.duration > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{testResult.duration}ms
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!testResult ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<FlaskConical className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p className="text-sm">Run a test to see the response here.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-4">
|
||||
<div
|
||||
className={`shrink-0 p-3 rounded-lg border ${
|
||||
testResult.success
|
||||
? 'bg-emerald-500/10 border-emerald-500/20'
|
||||
: 'bg-destructive/10 border-destructive/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{testResult.success ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
<span className="font-medium text-emerald-600">
|
||||
Request Successful
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-5 w-5 text-destructive" />
|
||||
<span className="font-medium text-destructive">Request Failed</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{testResult.error && (
|
||||
<p className="text-sm text-destructive mt-1">{testResult.error}</p>
|
||||
)}
|
||||
</div>
|
||||
{testResult.data != null && (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<Label className="shrink-0 mb-2 block">Response Data</Label>
|
||||
<div className="flex-1 min-h-[200px] rounded-md border overflow-hidden">
|
||||
<JsonViewer data={testResult.data} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TemplateTestPanel
|
||||
testInput={testInput}
|
||||
testParams={testParams}
|
||||
testResult={testResult}
|
||||
isTesting={isTesting}
|
||||
hasErrors={hasErrors}
|
||||
validationData={validationResult.data}
|
||||
paramKeys={paramKeys}
|
||||
templateParams={templateParams}
|
||||
onTestInputChange={setTestInput}
|
||||
onTestParamsChange={setTestParams}
|
||||
onRunTest={handleTest}
|
||||
buildPreviewUrl={buildPreviewUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
241
flowsint-app/src/components/templates/template-test-panel.tsx
Normal file
241
flowsint-app/src/components/templates/template-test-panel.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { Play, FlaskConical, Loader2, CheckCircle2, XCircle } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'
|
||||
|
||||
import { JsonViewer } from './json-viewer'
|
||||
import type { TemplateData } from './template-schema'
|
||||
|
||||
export interface TestResult {
|
||||
success: boolean
|
||||
data?: unknown
|
||||
error?: string
|
||||
duration?: number
|
||||
url?: string
|
||||
raw_results?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface TemplateTestPanelProps {
|
||||
testInput: string
|
||||
testParams: Record<string, string>
|
||||
testResult: TestResult | null
|
||||
isTesting: boolean
|
||||
hasErrors: boolean
|
||||
validationData: TemplateData | undefined
|
||||
paramKeys: string[]
|
||||
templateParams: Record<string, unknown>
|
||||
onTestInputChange: (value: string) => void
|
||||
onTestParamsChange: (params: Record<string, string>) => void
|
||||
onRunTest: () => void
|
||||
buildPreviewUrl: () => string
|
||||
}
|
||||
|
||||
export function TemplateTestPanel({
|
||||
testInput,
|
||||
testParams,
|
||||
testResult,
|
||||
isTesting,
|
||||
hasErrors,
|
||||
validationData,
|
||||
paramKeys,
|
||||
templateParams,
|
||||
onTestInputChange,
|
||||
onTestParamsChange,
|
||||
onRunTest,
|
||||
buildPreviewUrl
|
||||
}: TemplateTestPanelProps) {
|
||||
return (
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full">
|
||||
{/* Test Input */}
|
||||
<ResizablePanel defaultSize={30} minSize={20}>
|
||||
<div className="h-full p-6 flex flex-col gap-6 overflow-y-auto">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-1">Test your template</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter a test value to simulate the enricher request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-input">
|
||||
Input Value
|
||||
{validationData?.input?.key && (
|
||||
<span className="text-muted-foreground ml-1">({validationData.input.key})</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="test-input"
|
||||
placeholder={`Enter ${validationData?.input?.type || 'value'}...`}
|
||||
value={testInput}
|
||||
onChange={(e) => onTestInputChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onRunTest()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{paramKeys.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Query Parameters</Label>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="text-left px-3 py-1.5 text-xs font-medium text-muted-foreground w-1/3">
|
||||
Key
|
||||
</th>
|
||||
<th className="text-left px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paramKeys.map((paramKey, idx) => {
|
||||
const defaultValue = templateParams[paramKey] ?? ''
|
||||
return (
|
||||
<tr key={paramKey} className={idx < paramKeys.length - 1 ? 'border-b' : ''}>
|
||||
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground">
|
||||
{paramKey}
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<Input
|
||||
placeholder={String(defaultValue) || 'Value'}
|
||||
value={testParams[paramKey] ?? ''}
|
||||
onChange={(e) =>
|
||||
onTestParamsChange({
|
||||
...testParams,
|
||||
[paramKey]: e.target.value
|
||||
})
|
||||
}
|
||||
className="h-7 text-xs border-0 bg-transparent focus-visible:ring-1 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationData?.request?.url && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Request URL</Label>
|
||||
<code className="block text-xs bg-muted p-2 rounded break-all">
|
||||
{buildPreviewUrl()}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={onRunTest}
|
||||
disabled={isTesting || hasErrors || !testInput.trim()}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
Run test
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasErrors && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20">
|
||||
<p className="text-sm text-destructive">Fix validation errors before testing.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="hover:bg-primary/20 active:bg-primary/30 transition-colors data-[resize-handle-state=drag]:bg-primary/30" />
|
||||
{/* Test Result */}
|
||||
<ResizablePanel defaultSize={70} minSize={30}>
|
||||
<div className="h-full p-4 bg-muted/20 overflow-y-auto">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between mb-4">
|
||||
{testResult?.duration !== undefined && testResult.duration > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{testResult.duration}ms
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!testResult ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<FlaskConical className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p className="text-sm">Run a test to see the response here.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-4">
|
||||
<div
|
||||
className={`shrink-0 p-2 rounded-lg border ${
|
||||
testResult.success
|
||||
? 'bg-emerald-500/10 border-emerald-500/20'
|
||||
: 'bg-destructive/10 border-destructive/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{testResult.success ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
<span className="font-medium text-emerald-600">Request Successful</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-5 w-5 text-destructive" />
|
||||
<span className="font-medium text-destructive">Request Failed</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{testResult.error && (
|
||||
<p className="text-sm text-destructive mt-1">{testResult.error}</p>
|
||||
)}
|
||||
</div>
|
||||
{(testResult?.data?.raw_results != null || testResult?.data?.results != null) && (
|
||||
<ResizablePanelGroup direction="vertical" className="flex-1 min-h-0">
|
||||
{testResult?.data?.raw_results != null && (
|
||||
<>
|
||||
<ResizablePanel defaultSize={50} minSize={20}>
|
||||
<div className="h-full flex flex-col">
|
||||
<Label className="shrink-0 mb-2 block">Raw response</Label>
|
||||
<div className="flex-1 min-h-0 rounded-md border overflow-hidden">
|
||||
<JsonViewer data={testResult.data.raw_results} />
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
{testResult?.data?.results != null && (
|
||||
<ResizableHandle className="my-2 hover:bg-primary/20 active:bg-primary/30 transition-colors data-[resize-handle-state=drag]:bg-primary/30" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{testResult?.data?.results != null && (
|
||||
<ResizablePanel defaultSize={50} minSize={20}>
|
||||
<div className="h-full flex flex-col">
|
||||
<Label className="shrink-0 mb-2 block">Inserted nodes</Label>
|
||||
<div className="flex-1 min-h-0 rounded-md border overflow-hidden">
|
||||
<JsonViewer data={testResult.data.results} />
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user