feat(app): progress on templates

This commit is contained in:
dextmorgn
2026-02-11 08:03:21 +01:00
parent 3f252ea6ab
commit 89f32e2182
5 changed files with 523 additions and 422 deletions

View File

@@ -37,6 +37,7 @@ export interface TestTemplateResponse {
duration_ms: number
status_code?: number
url: string
raw_results?: Record<string, unknown>
}
export interface GenerateTemplateResponse {

View File

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

View 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>
)
}

View File

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

View 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>
)
}