import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
effect,
ElementRef,
inject,
input,
OnDestroy,
output
} from '@angular/core';
import {
DiffEditorModel,
MonacoEditorModule,
NGX_MONACO_EDITOR_CONFIG
} from '@allianz/taly-core/monaco-editor';
import { editor, IDisposable, Uri } from 'monaco-editor';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import type * as monaco from 'monaco-editor';
import { debounceTime } from 'rxjs';
import { distinctUntilChanged, filter } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { dynamicFormConfigSchema, DynamicFormConfiguration } from '@allianz/taly-core/schemas';
import { NxStatusIconComponent } from '@allianz/ng-aquila/icon';
import { NxHeadlineComponent } from '@allianz/ng-aquila/headline';
import { NxLinkComponent } from '@allianz/ng-aquila/link';
declare global {
interface Window {
monaco: typeof monaco;
}
}
@Component({
selector: 'taly-dynamic-form-monaco-editor',
templateUrl: './dynamic-form-monaco-editor.component.html',
styleUrls: ['./dynamic-form-monaco-editor.component.scss'],
imports: [
MonacoEditorModule,
ReactiveFormsModule,
NxStatusIconComponent,
NxHeadlineComponent,
NxLinkComponent
],
providers: [
{
provide: NGX_MONACO_EDITOR_CONFIG,
useValue: {
baseUrl: window.location.origin + '/assets/monaco/min/vs'
}
}
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DynamicFormMonacoEditorComponent implements OnDestroy {
formConfig = input.required<DynamicFormConfiguration>();
originalFormConfig = input<DynamicFormConfiguration>();
showDiffEditor = input<boolean>();
formConfigChange = output<DynamicFormConfiguration>();
hasFormConfigErrors = output<boolean>();
highlightedPosition = output<number | undefined>();
editorOptions = {
theme: 'vs-light',
language: 'json',
automaticLayout: false,
scrollBeyondLastLine: false
};
diffEditorOptions = {
theme: 'vs-light',
language: 'json',
readOnly: true,
automaticLayout: false,
scrollBeyondLastLine: false
};
originalModel!: DiffEditorModel;
modifiedModel!: DiffEditorModel;
formControl = new FormControl<string>('');
editorInitialized = false;
private editor?: editor.IStandaloneCodeEditor;
private editorUri?: Uri;
private listeners: IDisposable[] = [];
private elementRef = inject(ElementRef);
private resizeObserver?: ResizeObserver;
private destroyRef = inject(DestroyRef);
private cdr = inject(ChangeDetectorRef);
constructor() {
this.handleFormConfigControlChanges();
effect(() => {
this.updateFormConfigEditorContent(this.formConfig());
this.setDiffConfigurations();
});
}
ngOnDestroy(): void {
this.editor?.dispose();
this.resizeObserver?.disconnect();
this.listeners.forEach((listener) => {
listener.dispose();
});
}
onInitMonaco(editor: editor.IStandaloneCodeEditor) {
this.editor = editor;
this.setFormSchema();
this.validityConfiguration();
this.configureFormfieldHighlight();
this.resizeObserver = new ResizeObserver(() => editor.layout());
this.resizeObserver.observe(this.elementRef.nativeElement);
this.editorInitialized = true;
this.cdr.detectChanges();
}
private setDiffConfigurations() {
const originalConfigValue = JSON.stringify(this.originalFormConfig(), null, 2);
const modifiedConfigValue = JSON.stringify(this.formConfig(), null, 2);
this.originalModel = { code: originalConfigValue, language: 'json' };
this.modifiedModel = { code: modifiedConfigValue, language: 'json' };
}
private handleFormConfigControlChanges() {
this.formControl.valueChanges
.pipe(
debounceTime(500),
distinctUntilChanged(),
filter((data): data is NonNullable<typeof data> => data !== undefined),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((data) => {
try {
const parsedData = JSON.parse(data);
this.formConfigChange.emit(parsedData);
} catch {
// Invalid JSON, do nothing
}
});
}
private updateFormConfigEditorContent(formConfig: DynamicFormConfiguration) {
const configValue = JSON.stringify(formConfig, null, 2);
const currentConfig = JSON.stringify(JSON.parse(this.formControl.value || '{}'), null, 2);
if (configValue !== currentConfig) {
this.formControl.setValue(configValue);
}
}
private setFormSchema() {
this.editorUri = this.editor?.getModel()?.uri;
if (!this.editorUri) return;
const existingSchemas = window.monaco.json.jsonDefaults.diagnosticsOptions?.schemas ?? [];
window.monaco.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemas: [
...existingSchemas,
{
fileMatch: [this.editorUri.toString()],
uri: 'dynamic-form-config-schema',
schema: dynamicFormConfigSchema
}
]
});
}
private validityConfiguration() {
if (this.editorUri) {
this.hasFormConfigErrors.emit(this.modelHasErrors(this.editorUri));
}
const markersListener = window.monaco?.editor.onDidChangeMarkers((uris) => {
if (this.editorUri && uris.some((uri) => uri.toString() === this.editorUri?.toString())) {
this.hasFormConfigErrors.emit(this.modelHasErrors(this.editorUri));
this.cdr.markForCheck();
}
});
if (markersListener) {
this.listeners.push(markersListener);
}
}
private modelHasErrors(uri: Uri): boolean {
const markers = window.monaco?.editor.getModelMarkers({ resource: uri }) ?? [];
return markers.length > 0;
}
private configureFormfieldHighlight() {
if (this.editor && !this.showDiffEditor()) {
// On focus lost
this.listeners.push(
this.editor?.onDidBlurEditorText(() => {
this.highlightedPosition.emit(undefined);
this.cdr.markForCheck();
})
);
// On cursor position change
this.listeners.push(
this.editor?.onDidChangeCursorPosition((cursor) => {
const position = cursor.position;
this.highlightedPosition.emit(this.getFocusedFormfieldIndex(position));
this.cdr.markForCheck();
})
);
}
}
private getFocusedFormfieldIndex(position: monaco.Position): number | undefined {
if (!this.editor) return undefined;
const model = this.editor.getModel();
if (!model) return undefined;
try {
const content = model.getValue();
const offset = model.getOffsetAt(position);
const parsed = JSON.parse(content) as DynamicFormConfiguration;
const fields = parsed.fields || [];
if (fields.length === 0) return undefined;
// Find the fields array in the string
const fieldsMatch = content.match(/"fields"\s*:\s*\[/);
if (!fieldsMatch || !fieldsMatch.index) return undefined;
const fieldsStartIndex = fieldsMatch.index + fieldsMatch[0].length;
if (offset < fieldsStartIndex) return undefined;
// Count opening braces to determine which field we're in
let braceCount = 0;
let fieldIndex = -1;
let inString = false;
let escapeNext = false;
for (let i = fieldsStartIndex; i < offset && i < content.length; i++) {
const char = content[i];
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\') {
escapeNext = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (char === '{') {
if (braceCount === 0) {
fieldIndex++;
}
braceCount++;
} else if (char === '}') {
braceCount--;
}
}
return fieldIndex >= 0 && fieldIndex < fields.length ? fieldIndex : undefined;
} catch {
return undefined;
}
}
}