src/app/core/admin/json-editor/json-editor.component.ts

Description

Component for editing JSON data.

Extends

CustomFormControlDirective<object>

Implements

AfterViewInit OnDestroy

Example

Metadata

Relationships

Index

Properties
Methods
Inputs
Outputs

Inputs

aria-describedby
Type : string
disabled
Type : boolean
ngControl
Type : any
Default value : inject(NgControl, { optional: true, self: true })
placeholder
Type : string
required
Type : boolean
value
Type : T

Outputs

valueChange
Type : EventEmitter

Methods

handleJSONChange
handleJSONChange(updatedJSON: object)

Handle the change event from the JSON editor.

Parameters :
Name Type Optional Description
updatedJSON object No

The updated JSON data.

Returns : void
handleTextChange
handleTextChange(updatedText: string)

Handle the change event from the text editor.

Parameters :
Name Type Optional Description
updatedText string No

The updated text data.

Returns : void
blur
blur()
Returns : void
focus
focus()
Returns : void
onContainerClick
onContainerClick(event: MouseEvent)
Parameters :
Name Type Optional
event MouseEvent No
Returns : void
registerOnChange
registerOnChange(fn: any)
Parameters :
Name Type Optional
fn any No
Returns : void
registerOnTouched
registerOnTouched(fn: any)
Parameters :
Name Type Optional
fn any No
Returns : void
setDescribedByIds
setDescribedByIds(ids: string[])
Parameters :
Name Type Optional
ids string[] No
Returns : void
setDisabledState
setDisabledState(isDisabled: boolean)
Parameters :
Name Type Optional
isDisabled boolean No
Returns : void
writeValue
writeValue(value: T, notifyFormControl: unknown)

Implementation for Angular ControlValueAccessor interface that links the form control value to the component value

Parameters :
Name Type Optional Default value Description
value T No

The new value to set

notifyFormControl unknown No false

Whether to notify the FormControl of this change (for internal updates)

Returns : void

Properties

json
Type : ElementRef<HTMLDivElement>
Decorators :
@ViewChild('json', {static: true})
controlType
Type : string
Default value : "custom-control"
elementRef
Type : unknown
Default value : inject<ElementRef<HTMLElement>>(ElementRef)
Readonly enabled
Type : Signal<boolean>
Default value : computed(() => !this._disabled())

Whether the control is currently enabled, as a signal (tracks disabled).

errorStateMatcher
Type : unknown
Default value : inject(ErrorStateMatcher)
id
Type : unknown
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
Static nextId
Type : number
Default value : 0
onChange
Type : unknown
Default value : () => {...}
onTouched
Type : unknown
Default value : () => {...}
parentForm
Type : unknown
Default value : inject(NgForm, { optional: true })
parentFormGroup
Type : unknown
Default value : inject(FormGroupDirective, { optional: true })
stateChanges
Type : unknown
Default value : new Subject<void>()
Readonly valueSignal
Type : Signal<T>
Default value : computed(() => this._value())

The current value of the control as a signal. Authoritative in both modes: it reflects the bound FormControl (synced in ngDoCheck) as well as [(value)] / writeValue updates.

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%;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""