Skip to main content
Glama
TemplateAwareJsonEditor.tsx10.2 kB
import { useEditor, EditorContent } from '@tiptap/react'; import Document from '@tiptap/extension-document'; import Paragraph from '@tiptap/extension-paragraph'; import Text from '@tiptap/extension-text'; import History from '@tiptap/extension-history'; import HardBreak from '@tiptap/extension-hard-break'; import { cn } from '@/src/lib/general-utils'; import { useEffect, useRef, useState, useMemo } from 'react'; import { TemplateExtension, TemplateContextProvider, useTemplateContext, type CategorizedVariables, type CategorizedSources } from '../tools/templates/tiptap'; import { VariableSuggestion } from '../tools/templates/TemplateVariableSuggestion'; import { TemplateEditPopover } from '../tools/templates/TemplateEditPopover'; import { templateStringToTiptap, tiptapToTemplateString } from '../tools/templates/tiptap/serialization'; import { CopyButton } from '../tools/shared/CopyButton'; import { evaluateTemplate, parseTemplateString, prepareSourceData } from '@/src/lib/templating-utils'; import { maskCredentials } from '@superglue/shared'; import { useTemplateAwareEditor } from '../tools/hooks/use-template-aware-editor'; interface TemplateAwareJsonEditorProps { value: string; onChange?: (value: string) => void; stepData: any; dataSelectorOutput?: any; canExecute?: boolean; categorizedVariables?: CategorizedVariables; categorizedSources?: CategorizedSources; readOnly?: boolean; minHeight?: string; maxHeight?: string; placeholder?: string; resizable?: boolean; showValidation?: boolean; sourceDataVersion?: number; stepId?: string; } function TemplateAwareJsonEditorInner({ value, onChange, readOnly = false, minHeight = '75px', maxHeight = '300px', placeholder = '{}', resizable = false, showValidation = false, stepData, dataSelectorOutput, canExecute = true, }: TemplateAwareJsonEditorProps) { const isUpdatingRef = useRef(false); const lastValueRef = useRef(value); const [currentHeight, setCurrentHeight] = useState(minHeight); const [jsonError, setJsonError] = useState<string | null>(null); const { categorizedVariables, categorizedSources, sourceDataVersion } = useTemplateContext(); const { sourceData, suggestionConfig, codePopoverOpen, setCodePopoverOpen, popoverAnchorRect, handleCodeSave, editorRef, cleanupSuggestion, } = useTemplateAwareEditor({ stepData, dataSelectorOutput, categorizedVariables, categorizedSources }); const credentials = useMemo(() => { if (!sourceData || typeof sourceData !== 'object') return {}; const pattern = /^[a-zA-Z_$][a-zA-Z0-9_$]*_[a-zA-Z0-9_$]+$/; return Object.entries(sourceData).reduce((acc, [key, val]) => { if (pattern.test(key) && typeof val === 'string' && val.length > 0) { acc[key] = val; } return acc; }, {} as Record<string, string>); }, [sourceData]); useEffect(() => cleanupSuggestion, [cleanupSuggestion]); const editor = useEditor({ extensions: [ Document, Paragraph, Text, History, HardBreak, TemplateExtension, VariableSuggestion.configure({ suggestion: suggestionConfig }), ], content: templateStringToTiptap(value), editable: !readOnly, immediatelyRender: false, editorProps: { attributes: { class: cn( 'w-full px-3 py-2 text-xs font-mono bg-transparent', 'focus:outline-none', readOnly && 'cursor-not-allowed' ), }, }, onUpdate: ({ editor }) => { if (isUpdatingRef.current) return; const newValue = tiptapToTemplateString(editor.getJSON()); if (newValue !== lastValueRef.current) { lastValueRef.current = newValue; onChange?.(newValue); } }, }); useEffect(() => { editorRef.current = editor; }, [editor, editorRef]); useEffect(() => { if (!editor || value === lastValueRef.current) return; isUpdatingRef.current = true; lastValueRef.current = value; // Defer to microtask to avoid flushSync during React render queueMicrotask(() => { editor.commands.setContent(templateStringToTiptap(value)); isUpdatingRef.current = false; }); }, [editor, value]); useEffect(() => { editor?.setEditable(!readOnly); }, [editor, readOnly]); useEffect(() => { if (!showValidation || !value?.trim()) { setJsonError(null); return; } let cancelled = false; const sourceData = prepareSourceData(stepData, dataSelectorOutput); const escapeForJson = (str: string) => str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); const toJsonValue = (val: unknown, isInsideQuotes: boolean): string => { if (typeof val === 'string') { return isInsideQuotes ? escapeForJson(val) : val; } return JSON.stringify(val); }; const validateJson = async () => { let json = value; for (const part of parseTemplateString(value)) { if (cancelled) return; if (part.type !== 'template' || !part.rawTemplate) continue; const expression = part.rawTemplate.slice(2, -2).trim(); const result = canExecute ? await evaluateTemplate(expression, sourceData).catch(() => null) : null; if (cancelled) return; const evaluated = result?.success ? result.value : null; const insertPos = json.indexOf(part.rawTemplate); const isInsideQuotes = insertPos > 0 && json[insertPos - 1] === '"'; json = json.replace(part.rawTemplate, toJsonValue(evaluated, isInsideQuotes)); } if (cancelled) return; try { JSON.parse(json); setJsonError(null); } catch (e) { setJsonError((e as Error).message); } }; const timer = setTimeout(validateJson, 300); return () => { cancelled = true; clearTimeout(timer); }; }, [showValidation, value, stepData, dataSelectorOutput, canExecute]); const handleResize = (e: React.MouseEvent) => { e.preventDefault(); const startY = e.clientY; const startHeight = parseInt(currentHeight); const minH = parseInt(minHeight); const maxH = parseInt(maxHeight); const onMove = (e: MouseEvent) => { const newHeight = Math.max(minH, Math.min(maxH, startHeight + (e.clientY - startY))); setCurrentHeight(`${newHeight}px`); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }; return ( <div className={cn('relative rounded-lg border shadow-sm bg-muted/30')}> <div className="absolute top-1 right-1 z-10 mr-1"> <CopyButton text={value || placeholder} /> </div> {resizable && ( <div className="absolute bottom-1 right-1 w-3 h-3 cursor-se-resize z-10" style={{ background: 'linear-gradient(135deg, transparent 50%, rgba(100,100,100,0.3) 50%)' }} onMouseDown={handleResize} /> )} <div className={cn('relative', readOnly ? 'cursor-not-allowed' : 'cursor-text')} style={{ height: resizable ? currentHeight : 'auto', minHeight, maxHeight, overflow: 'auto' }} > <EditorContent editor={editor} className={cn( resizable ? "h-full" : "", jsonError && "pb-10" )} /> {!value?.trim() && placeholder && ( <div className="absolute top-2 left-3 text-xs pointer-events-none font-mono json-placeholder-bracket"> {placeholder} </div> )} </div> {showValidation && jsonError && ( <div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-destructive/10 text-destructive text-xs max-h-24 overflow-y-auto border-t z-10"> Error: {Object.keys(credentials).length > 0 ? maskCredentials(jsonError, credentials) : jsonError} </div> )} <TemplateEditPopover template="" sourceData={sourceData} onSave={handleCodeSave} externalOpen={codePopoverOpen} onExternalOpenChange={setCodePopoverOpen} anchorRect={popoverAnchorRect} canExecute={canExecute} sourceDataVersion={sourceDataVersion} /> </div> ); } export function TemplateAwareJsonEditor(props: TemplateAwareJsonEditorProps) { return ( <TemplateContextProvider stepData={props.stepData} dataSelectorOutput={props.dataSelectorOutput} readOnly={props.readOnly} canExecute={props.canExecute} categorizedVariables={props.categorizedVariables} categorizedSources={props.categorizedSources} sourceDataVersion={props.sourceDataVersion} stepId={props.stepId} > <TemplateAwareJsonEditorInner {...props} /> </TemplateContextProvider> ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/superglue-ai/superglue'

If you have feedback or need assistance with the MCP directory API, please join our Discord server