src/app/core/admin/json-editor/json-editor.component.ts
Component for editing JSON data.
CustomFormControlDirective<object>
| changeDetection | ChangeDetectionStrategy.OnPush |
| providers |
JsonEditorComponent
|
| selector | app-json-editor |
| standalone | true |
| styleUrls | ./json-editor.component.scss |
| templateUrl | ./json-editor.component.html |
| styleUrl | ./json-editor.component.scss |
Properties |
|
Methods |
Inputs |
Outputs |
| aria-describedby | |
Type : string
|
|
|
Inherited from
CustomFormControlDirective
|
|
| disabled | |
Type : boolean
|
|
|
Inherited from
CustomFormControlDirective
|
|
| ngControl | |
Type : any
|
|
Default value : inject(NgControl, { optional: true, self: true })
|
|
|
Inherited from
CustomFormControlDirective
|
|
| placeholder | |
Type : string
|
|
|
Inherited from
CustomFormControlDirective
|
|
| required | |
Type : boolean
|
|
|
Inherited from
CustomFormControlDirective
|
|
| value | |
Type : T
|
|
|
Inherited from
CustomFormControlDirective
|
|
| valueChange | |
Type : EventEmitter
|
|
|
Inherited from
CustomFormControlDirective
|
|
| handleJSONChange | ||||||||
handleJSONChange(updatedJSON: object)
|
||||||||
|
Handle the change event from the JSON editor.
Parameters :
Returns :
void
|
| handleTextChange | ||||||||
handleTextChange(updatedText: string)
|
||||||||
|
Handle the change event from the text editor.
Parameters :
Returns :
void
|
| blur |
blur()
|
|
Inherited from
CustomFormControlDirective
|
|
Returns :
void
|
| focus |
focus()
|
|
Inherited from
CustomFormControlDirective
|
|
Returns :
void
|
| onContainerClick | ||||||
onContainerClick(event: MouseEvent)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| registerOnChange | ||||||
registerOnChange(fn: any)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| registerOnTouched | ||||||
registerOnTouched(fn: any)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| setDescribedByIds | ||||||
setDescribedByIds(ids: string[])
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| setDisabledState | ||||||
setDisabledState(isDisabled: boolean)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| writeValue | |||||||||||||||
writeValue(value: T, notifyFormControl: unknown)
|
|||||||||||||||
|
Inherited from
CustomFormControlDirective
|
|||||||||||||||
|
Implementation for Angular ControlValueAccessor interface that links the form control value to the component value
Parameters :
Returns :
void
|
| json |
Type : ElementRef<HTMLDivElement>
|
Decorators :
@ViewChild('json', {static: true})
|
| controlType |
Type : string
|
Default value : "custom-control"
|
|
Inherited from
CustomFormControlDirective
|
| elementRef |
Type : unknown
|
Default value : inject<ElementRef<HTMLElement>>(ElementRef)
|
|
Inherited from
CustomFormControlDirective
|
| Readonly enabled |
Type : Signal<boolean>
|
Default value : computed(() => !this._disabled())
|
|
Inherited from
CustomFormControlDirective
|
|
Whether the control is currently enabled, as a signal (tracks disabled). |
| errorStateMatcher |
Type : unknown
|
Default value : inject(ErrorStateMatcher)
|
|
Inherited from
CustomFormControlDirective
|
| id |
Type : unknown
|
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
|
|
Inherited from
CustomFormControlDirective
|
| Static nextId |
Type : number
|
Default value : 0
|
|
Inherited from
CustomFormControlDirective
|
| onChange |
Type : unknown
|
Default value : () => {...}
|
|
Inherited from
CustomFormControlDirective
|
| onTouched |
Type : unknown
|
Default value : () => {...}
|
|
Inherited from
CustomFormControlDirective
|
| parentForm |
Type : unknown
|
Default value : inject(NgForm, { optional: true })
|
|
Inherited from
CustomFormControlDirective
|
| parentFormGroup |
Type : unknown
|
Default value : inject(FormGroupDirective, { optional: true })
|
|
Inherited from
CustomFormControlDirective
|
| stateChanges |
Type : unknown
|
Default value : new Subject<void>()
|
|
Inherited from
CustomFormControlDirective
|
| Readonly valueSignal |
Type : Signal<T>
|
Default value : computed(() => this._value())
|
|
Inherited from
CustomFormControlDirective
|
|
The current value of the control as a signal.
Authoritative in both modes: it reflects the bound |
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
effect,
ElementRef,
OnDestroy,
ViewChild,
} from "@angular/core";
import {
Content,
createJSONEditor,
Mode,
} from "vanilla-jsoneditor/standalone.js";
import { MatFormFieldControl } from "@angular/material/form-field";
import { CustomFormControlDirective } from "app/core/common-components/basic-autocomplete/custom-form-control.directive";
import { Logging } from "../../logging/logging.service";
/**
* Component for editing JSON data.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-json-editor",
standalone: true,
imports: [],
templateUrl: "./json-editor.component.html",
styleUrl: "./json-editor.component.scss",
providers: [
{ provide: MatFormFieldControl, useExisting: JsonEditorComponent },
],
})
export class JsonEditorComponent
extends CustomFormControlDirective<object>
implements AfterViewInit, OnDestroy
{
/**
* Text mode uses CodeMirror internally, which virtualises rendering by only keeping
* DOM "tiles" for the visible viewport. When a JSON value contains very long strings
* (e.g. a lengthy SQL query stored as a report definition), the full document can
* exceed what CodeMirror renders, causing a "No tile at position X" crash on any
* keyboard or mouse event that tries to measure an off-screen position.
* Switching to tree mode avoids the CodeMirror text-editor entirely for such content.
* See https://github.com/Aam-Digital/ndb-core/issues/3821
*/
private static readonly RISKY_TEXT_MODE_STRING_LENGTH = 1000;
@ViewChild("json", { static: true }) json!: ElementRef<HTMLDivElement>;
private editor?: ReturnType<typeof createJSONEditor>;
private textModeDisabledForSession = false;
/**
* Keep the editor's read-only state in sync with the control's enabled state
* so it is not editable when the surrounding form is in view mode.
*/
private readonly _readOnlySync = effect(() => {
// Read the signal first so the effect always tracks it — an optional-chained
// `this.editor?.updateProps({ readOnly: !this.enabled() })` would short-circuit
// (and skip the signal read) while the editor is not yet created.
const readOnly = !this.enabled();
this.editor?.updateProps({ readOnly });
});
/**
* Initialize the JSON editor.
* This method is called after the component view is initialized.
*/
ngAfterViewInit(): void {
this.initializeJSONEditor();
}
override ngOnDestroy(): void {
this.editor?.destroy();
this.editor = undefined;
super.ngOnDestroy();
}
/**
* Initializes the JSON editor and sets up event handlers.
*/
private initializeJSONEditor(): void {
const initialContent: Content = {
json: this.ngControl?.control?.value ?? {},
};
this.textModeDisabledForSession =
this.shouldDisableTextModeForContent(initialContent);
this.createEditor(
initialContent,
this.textModeDisabledForSession ? Mode.tree : Mode.text,
);
}
/**
* Creates a new JSON editor instance for the given content and mode.
*/
private createEditor(content: Content, mode: Mode): void {
this.editor = createJSONEditor({
target: this.json.nativeElement,
props: {
content,
mode,
readOnly: !this.enabled(),
onChange: (updatedContent: Content) =>
this.handleEditorChange(updatedContent),
onChangeMode: (mode: Mode) => this.handleEditorModeChange(mode),
},
});
}
/**
* Handles changes from the JSON editor.
* @param updatedContent The updated content from the editor.
*/
private handleEditorChange(updatedContent: Content): void {
if ("json" in updatedContent) {
this.handleJSONChange(updatedContent.json as object);
return;
}
if ("text" in updatedContent) {
this.handleTextChange(updatedContent.text);
}
}
/**
* Prevents switching to text mode when the session was marked as high-risk
* for long-string edits.
*/
private handleEditorModeChange(mode: Mode): void {
if (this.textModeDisabledForSession && mode === Mode.text) {
this.editor?.updateProps({ mode: Mode.tree });
}
}
/**
* Determines whether text mode should be disabled for the current content.
*/
private shouldDisableTextModeForContent(content: Content): boolean {
if (!("json" in content)) {
return false;
}
return this.hasLongString(content.json);
}
/**
* Recursively checks whether the JSON value contains at least one long string.
*/
private hasLongString(value: unknown): boolean {
if (typeof value === "string") {
return value.length > JsonEditorComponent.RISKY_TEXT_MODE_STRING_LENGTH;
}
if (Array.isArray(value)) {
return value.some((item) => this.hasLongString(item));
}
if (value && typeof value === "object") {
return Object.values(value).some((item) => this.hasLongString(item));
}
return false;
}
/**
* Handle the change event from the JSON editor.
* @param updatedJSON The updated JSON data.
*/
handleJSONChange(updatedJSON: object): void {
this.value = updatedJSON;
}
/**
* Handle the change event from the text editor.
* @param updatedText The updated text data.
*/
handleTextChange(updatedText: string): void {
try {
this.value = updatedText ? JSON.parse(updatedText) : {};
} catch (e) {
const control = this.ngControl?.control;
if (!control) {
Logging.debug("No FormControl in JsonEditorComponent");
return;
}
this.ngControl.control.setErrors({ invalidJson: true });
}
}
}
<div #json class="json-editor-container"></div>
./json-editor.component.scss
:host {
display: block;
height: 100%;
}
.json-editor-container {
height: 100%;
}