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