import { Component, Input, forwardRef, OnInit, ViewChild, ElementRef } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormControl } from '@angular/forms'; import { CommonModule } from '@angular/common'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatIconModule } from '@angular/material/icon'; import { MatChipsModule } from '@angular/material/chips'; import { MatExpansionModule } from '@angular/material/expansion'; @Component({ selector: 'app-json-editor', standalone: true, imports: [ CommonModule, FormsModule, ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatIconModule, MatChipsModule, MatExpansionModule ], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => JsonEditorComponent), multi: true } ], template: ` {{ label }} {{ hint }} @if (!isValidJson() && value) { Invalid JSON format }
@if (isValidJson()) { check_circle Valid JSON } @else if (value) { error Invalid JSON }
@if (availableVariables && availableVariables.length > 0) { code Available Variables Click to insert template variables @for (variable of availableVariables; track variable) { {{ variable }} } } `, styles: [` :host { display: block; margin-bottom: 16px; } .full-width { width: 100%; } .code-editor { font-family: 'Consolas', 'Monaco', 'Courier New', monospace !important; font-size: 13px; line-height: 1.5; tab-size: 2; -moz-tab-size: 2; white-space: pre; } .json-validation-status { display: flex; align-items: center; gap: 4px; margin-top: -6px; margin-bottom: 16px; font-size: 12px; mat-icon { font-size: 16px; width: 16px; height: 20px; margin-top:-2px; &.valid { color: #4caf50; } &.invalid { color: #f44336; } } span { &.valid { color: #4caf50; } &.invalid { color: #f44336; } } } .variables-panel { margin-bottom: 16px; box-shadow: none; border: 1px solid rgba(0, 0, 0, 0.12); .mat-expansion-panel-header { padding: 0 16px; height: 40px; .mat-panel-title { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #666; mat-icon { font-size: 18px; width: 18px; height: 18px; } } .mat-panel-description { font-size: 12px; color: #999; } } .mat-expansion-panel-body { padding: 8px 16px 16px; } mat-chip-set { mat-chip { font-size: 12px; min-height: 24px; padding: 4px 8px; cursor: pointer; transition: all 0.2s; &:hover { background-color: #e3f2fd; color: #1976d2; } } } } // JSON field background colors .json-valid { textarea { background-color: rgba(76, 175, 80, 0.05) !important; } } .json-invalid { textarea { background-color: rgba(244, 67, 54, 0.05) !important; } } `] }) export class JsonEditorComponent implements ControlValueAccessor, OnInit { @ViewChild('textareaRef') textareaRef!: ElementRef; @Input() label = 'JSON Editor'; @Input() placeholder = '{}'; @Input() hint = 'Enter valid JSON'; @Input() rows = 8; @Input() availableVariables: string[] = []; @Input() variableReplacer?: (json: string) => string; value = ''; onChange: (value: string) => void = () => {}; onTouched: () => void = () => {}; private bracketPairs: { [key: string]: string } = { '{': '}', '[': ']', '(': ')' }; private cursorPosition = 0; ngOnInit() {} writeValue(value: string): void { this.value = value || ''; } registerOnChange(fn: (value: string) => void): void { this.onChange = fn; } registerOnTouched(fn: () => void): void { this.onTouched = fn; } setDisabledState?(isDisabled: boolean): void { // Handle disabled state if needed } isValidJson(): boolean { if (!this.value || !this.value.trim()) return true; try { let jsonStr = this.value; // If variableReplacer is provided, use it to replace variables for validation if (this.variableReplacer) { jsonStr = this.variableReplacer(jsonStr); } else { // Default variable replacement for validation jsonStr = this.replaceVariablesForValidation(jsonStr); } JSON.parse(jsonStr); return true; } catch { return false; } } private replaceVariablesForValidation(jsonStr: string): string { let processed = jsonStr; processed = processed.replace(/\{\{([^}]+)\}\}/g, (match, variablePath) => { if (variablePath.includes('variables.')) { const varName = variablePath.split('.').pop()?.toLowerCase() || ''; const numericVars = ['count', 'passenger_count', 'timeout_seconds', 'retry_count', 'amount', 'price', 'quantity', 'age', 'id']; const booleanVars = ['enabled', 'published', 'is_active', 'confirmed', 'canceled', 'deleted', 'required']; if (numericVars.some(v => varName.includes(v))) { return '1'; } else if (booleanVars.some(v => varName.includes(v))) { return 'true'; } else { return '"placeholder"'; } } return '"placeholder"'; }); return processed; } handleKeydown(event: KeyboardEvent): void { if (event.key === 'Tab') { this.handleTabKey(event); } else if (event.key === 'Enter') { this.handleEnterKey(event); } else if (event.key in this.bracketPairs || Object.values(this.bracketPairs).includes(event.key)) { this.handleBracketInput(event); } } handleTabKey(event: KeyboardEvent): void { event.preventDefault(); const textarea = event.target as HTMLTextAreaElement; const start = textarea.selectionStart; const end = textarea.selectionEnd; if (start !== end) { this.handleBlockIndent(textarea, !event.shiftKey); } else { const newValue = this.value.substring(0, start) + '\t' + this.value.substring(end); this.updateValue(newValue); setTimeout(() => { textarea.selectionStart = textarea.selectionEnd = start + 1; textarea.focus(); }, 0); } } private handleBlockIndent(textarea: HTMLTextAreaElement, indent: boolean): void { const start = textarea.selectionStart; const end = textarea.selectionEnd; const value = this.value; const lineStart = value.lastIndexOf('\n', start - 1) + 1; const lineEnd = value.indexOf('\n', end); const actualEnd = lineEnd === -1 ? value.length : lineEnd; const selectedLines = value.substring(lineStart, actualEnd); const lines = selectedLines.split('\n'); let newLines: string[]; if (indent) { newLines = lines.map(line => '\t' + line); } else { newLines = lines.map(line => line.startsWith('\t') ? line.substring(1) : line); } const newText = newLines.join('\n'); const newValue = value.substring(0, lineStart) + newText + value.substring(actualEnd); this.updateValue(newValue); setTimeout(() => { const lengthDiff = newText.length - selectedLines.length; textarea.selectionStart = lineStart; textarea.selectionEnd = actualEnd + lengthDiff; textarea.focus(); }, 0); } handleEnterKey(event: KeyboardEvent): void { event.preventDefault(); const textarea = event.target as HTMLTextAreaElement; const start = textarea.selectionStart; const value = this.value; const lineStart = value.lastIndexOf('\n', start - 1) + 1; const currentLine = value.substring(lineStart, start); const indent = currentLine.match(/^[\t ]*/)?.[0] || ''; const prevChar = value[start - 1]; const nextChar = value[start]; let newLineContent = '\n' + indent; let cursorOffset = newLineContent.length; if (prevChar in this.bracketPairs) { newLineContent = '\n' + indent + '\t'; cursorOffset = newLineContent.length; if (nextChar === this.bracketPairs[prevChar]) { newLineContent += '\n' + indent; } } const newValue = value.substring(0, start) + newLineContent + value.substring(start); this.updateValue(newValue); setTimeout(() => { textarea.selectionStart = textarea.selectionEnd = start + cursorOffset; textarea.focus(); }, 0); } handleBracketInput(event: KeyboardEvent): void { const textarea = event.target as HTMLTextAreaElement; const char = event.key; if (char in this.bracketPairs) { event.preventDefault(); const start = textarea.selectionStart; const end = textarea.selectionEnd; const value = this.value; const selectedText = value.substring(start, end); const closingBracket = this.bracketPairs[char]; let newValue: string; let cursorPos: number; if (selectedText) { newValue = value.substring(0, start) + char + selectedText + closingBracket + value.substring(end); cursorPos = start + 1 + selectedText.length; } else { newValue = value.substring(0, start) + char + closingBracket + value.substring(end); cursorPos = start + 1; } this.updateValue(newValue); setTimeout(() => { textarea.selectionStart = textarea.selectionEnd = cursorPos; textarea.focus(); }, 0); } else if (Object.values(this.bracketPairs).includes(char)) { const start = textarea.selectionStart; const value = this.value; const nextChar = value[start]; if (nextChar === char) { event.preventDefault(); textarea.selectionStart = textarea.selectionEnd = start + 1; } } } handleCursorMove(event: any): void { this.cursorPosition = event.target.selectionStart; } insertVariable(variable: string): void { const textarea = this.textareaRef.nativeElement; const start = this.cursorPosition; const variableText = `{{${variable}}}`; const newValue = this.value.substring(0, start) + variableText + this.value.substring(start); this.updateValue(newValue); setTimeout(() => { const newPos = start + variableText.length; textarea.selectionStart = textarea.selectionEnd = newPos; textarea.focus(); }, 0); } private updateValue(newValue: string): void { this.value = newValue; this.onChange(newValue); } }