Spaces:
Paused
Paused
| 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'; | |
| ({ | |
| 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: ` | |
| <mat-form-field appearance="outline" | |
| class="full-width" | |
| [class.json-valid]="isValidJson()" | |
| [class.json-invalid]="!isValidJson() && value"> | |
| <mat-label>{{ label }}</mat-label> | |
| <textarea matInput | |
| #textareaRef | |
| [(ngModel)]="value" | |
| [rows]="rows" | |
| [placeholder]="placeholder" | |
| (keydown)="handleKeydown($event)" | |
| (click)="handleCursorMove($event)" | |
| (keyup)="handleCursorMove($event)" | |
| (blur)="onTouched()" | |
| class="code-editor"></textarea> | |
| <mat-hint>{{ hint }}</mat-hint> | |
| @if (!isValidJson() && value) { | |
| <mat-error>Invalid JSON format</mat-error> | |
| } | |
| </mat-form-field> | |
| <!-- JSON Validation Indicator --> | |
| <div class="json-validation-status"> | |
| @if (isValidJson()) { | |
| <mat-icon class="valid">check_circle</mat-icon> | |
| <span class="valid">Valid JSON</span> | |
| } @else if (value) { | |
| <mat-icon class="invalid">error</mat-icon> | |
| <span class="invalid">Invalid JSON</span> | |
| } | |
| </div> | |
| <!-- Collapsible Variables Panel --> | |
| @if (availableVariables && availableVariables.length > 0) { | |
| <mat-expansion-panel class="variables-panel"> | |
| <mat-expansion-panel-header> | |
| <mat-panel-title> | |
| <mat-icon>code</mat-icon> | |
| Available Variables | |
| </mat-panel-title> | |
| <mat-panel-description> | |
| Click to insert template variables | |
| </mat-panel-description> | |
| </mat-expansion-panel-header> | |
| <mat-chip-set> | |
| @for (variable of availableVariables; track variable) { | |
| <mat-chip (click)="insertVariable(variable)"> | |
| {{ variable }} | |
| </mat-chip> | |
| } | |
| </mat-chip-set> | |
| </mat-expansion-panel> | |
| } | |
| `, | |
| 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 { | |
| ('textareaRef') textareaRef!: ElementRef<HTMLTextAreaElement>; | |
| () label = 'JSON Editor'; | |
| () placeholder = '{}'; | |
| () hint = 'Enter valid JSON'; | |
| () rows = 8; | |
| () availableVariables: string[] = []; | |
| () 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); | |
| } | |
| } |