import { useState, useEffect, useMemo } from 'react' import { Combobox, Listbox } from '@headlessui/react' import { api, getProjectionDefs, getObjects } from '../api' import { Spinner, HashBadge } from './Common' interface ProjectionDef { name: string hash: string params: Record value_schema: { type: string } initial_value: any sources: Array<{ event_def_hash: string; bindings: Record; expression: string }> } export default function Projections() { const [defs, setDefs] = useState([]) const [selectedDef, setSelectedDef] = useState(null) const [params, setParams] = useState>({}) const [objects, setObjects] = useState>([]) const [result, setResult] = useState(null) const [error, setError] = useState('') const [loading, setLoading] = useState(false) const [initialLoading, setInitialLoading] = useState(true) useEffect(() => { Promise.all([getProjectionDefs(), getObjects()]) .then(([defsRes, objsRes]) => { setDefs(defsRes.projection_defs) setObjects(objsRes.objects.map((o: any) => ({ ...o, id: String(o.id) }))) }) .catch(() => {}) .finally(() => setInitialLoading(false)) }, []) const handleSelectDef = (def: ProjectionDef | null) => { setSelectedDef(def) setResult(null) setError('') if (def) { const initial: Record = {} for (const key of Object.keys(def.params)) { initial[key] = '' } setParams(initial) } else { setParams({}) } } const handleQuery = async () => { if (!selectedDef) return setLoading(true) setError('') setResult(null) const queryParams = new URLSearchParams() for (const [k, v] of Object.entries(params)) { if (v.trim()) queryParams.set(k, v.trim()) } try { const res = await api<{ value: any }>(`/projections/${selectedDef.name}?${queryParams.toString()}`) setResult(res.value) } catch (e: any) { setError(e.message) } finally { setLoading(false) } } const objectsByType = useMemo(() => { const map: Record = {} for (const obj of objects) { if (!map[obj.type]) map[obj.type] = [] map[obj.type].push(String(obj.id)) } return map }, [objects]) if (initialLoading) return return (

Query Projections

{/* Projection selector with Listbox */}
{selectedDef?.name || 'Select a projection...'} {defs.map((d) => ( `cursor-pointer select-none px-4 py-2.5 transition-colors ${ active ? 'bg-blue-600 text-white' : 'text-gray-100' }` } > {({ selected }) => {d.name}} ))}
{/* Auto-generated params form */} {selectedDef && (
→ {selectedDef.value_schema?.type || 'any'} {selectedDef.initial_value !== undefined && ( (initial: {JSON.stringify(selectedDef.initial_value)}) )}
{Object.entries(selectedDef.params).map(([key, schema]) => (
{schema.type === 'ref' ? ( setParams({ ...params, [key]: v })} objects={objects} objectsByType={objectsByType} objectType={schema.object_type} paramName={key} /> ) : ( setParams({ ...params, [key]: e.target.value })} /> )}
))}
)} {/* Projection info */} {selectedDef && (
sources:
{selectedDef.sources?.map((s, i) => (
bindings:{' '} {Object.keys(s.bindings).length === 0 ? ( none ) : ( {Object.entries(s.bindings) .map(([k, v]) => `${k}=${v}`) .join(', ')} )}
expression:{' '} {s.expression}
))}
)} {error && (
{error}
)} {result !== null && (

Result

              {JSON.stringify(result, null, 2)}
            
)}
) } // Headless UI Combobox for object ref params function RefCombobox({ value, onChange, objects, objectsByType, objectType, paramName, }: { value: string onChange: (v: string) => void objects: Array<{ id: string; type: string }> objectsByType: Record objectType?: string paramName?: string }) { const [query, setQuery] = useState('') // Filter objects by object_type if specified, or infer from param name // Convention: param name often matches the object type (e.g. param "agent" → type "agent") const effectiveObjectType = useMemo(() => { if (objectType) return objectType if (paramName) { const types = new Set(objects.map(o => o.type)) if (types.has(paramName)) return paramName } return undefined }, [objectType, paramName, objects]) const relevantObjects = useMemo(() => { if (!effectiveObjectType) return objects return objects.filter((o) => o.type === effectiveObjectType) }, [objects, effectiveObjectType]) const relevantByType = useMemo(() => { if (!effectiveObjectType) return objectsByType const filtered: Record = {} if (objectsByType[effectiveObjectType]) { filtered[effectiveObjectType] = objectsByType[effectiveObjectType] } return filtered }, [objectsByType, effectiveObjectType]) const filtered = useMemo(() => { if (!query) return relevantObjects const q = query.toLowerCase() return relevantObjects.filter((o) => String(o.id).toLowerCase().includes(q) || o.type.toLowerCase().includes(q)) }, [relevantObjects, query]) return ( onChange(v || '')}>
setQuery(e.target.value)} displayValue={(val: string) => val} />
{Object.entries(relevantByType).map(([type, ids]) => { const filteredIds = ids.filter((id) => !query || id.toLowerCase().includes(query.toLowerCase())) if (filteredIds.length === 0) return null return (
{type}
{filteredIds.map((id) => ( `cursor-pointer select-none px-3 py-2 text-sm transition-colors ${ active ? 'bg-blue-600 text-white' : 'text-gray-100' }` } > {({ selected }) => {id}} ))}
) })} {filtered.length === 0 &&
No objects found
}
) }