cloudfoundry/stratos

View on GitHub
src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.ts

Summary

Maintainability
C
1 day
Test Coverage
import { HttpClient } from '@angular/common/http';
import { AfterViewInit, Component, ElementRef, Input, OnDestroy, OnInit, Renderer2, ViewChild } from '@angular/core';
import { JsonSchemaFormComponent } from '@cfstratos/ajsf-core';
import * as yaml from 'js-yaml';
import { BehaviorSubject, combineLatest, fromEvent, Observable, of, Subscription } from 'rxjs';
import { catchError, debounceTime, filter, map, startWith, tap } from 'rxjs/operators';

import { ConfirmationDialogConfig } from '../../../../../core/src/shared/components/confirmation-dialog.config';
import { ConfirmationDialogService } from '../../../../../core/src/shared/components/confirmation-dialog.service';
import { ThemeService } from '../../../../../store/src/theme.service';
import { diffObjects } from './diffvalues';
import { generateJsonSchemaFromObject } from './json-schema-generator';
import { mergeObjects } from './merge';


export interface ChartValuesConfig {

  // URL of the JSON Schema for the chart values
  schemaUrl: string;

  // URL of the Chart Values
  valuesUrl: string;

  // Values for the current release (optional)
  releaseValues?: string;
}

// Height of the toolbar that sits above the editor conmponent
const TOOLBAR_HEIGHT = 40;

// Editor modes - can be either using the form or the code editor
enum EditorMode {
  CodeEditor = 'editor',
  JSonSchemaForm = 'form',
}

@Component({
  selector: 'app-chart-values-editor',
  templateUrl: './chart-values-editor.component.html',
  styleUrls: ['./chart-values-editor.component.scss']
})
export class ChartValuesEditorComponent implements OnInit, OnDestroy, AfterViewInit {

  @Input() set config(config: ChartValuesConfig) {
    if (!!config) {
      this.schemaUrl = config.schemaUrl;
      this.valuesUrl = config.valuesUrl;
      this.releaseValues = config.releaseValues;
      this.init();
    }
  }

  schemaUrl: string;
  valuesUrl: string;
  releaseValues: string;

  // Model for the editor - we set this once when the YAML support has been loaded
  public model;

  // Editor mode - either 'editor' for the Monaco Code Editor or 'form' for the JSON Schema Form editor
  public mode: EditorMode = EditorMode.CodeEditor;

  // Content shown in the code editor
  public code = '';

  // JSON Schema
  public schema: any;

  public hasSchema = false;

  // Data shown in the form on load
  public initialFormData = {};

  // Data updated in the form as the user changes it
  public formData = {};

  // Is the YAML in the code editor invalid?
  public yamlError = false;

  // Monaco Code Editor settings
  public minimap = true;
  public lineNumbers = true;

  // Chart Values - as both raw text (keeping comments) and parsed JSON
  public chartValuesYaml: string;
  public chartValues: any;

  // Default Monaco options
  public editorOptions = {
    automaticLayout: false, // We will resize the editor to fit the available space
    contextmenu: false, // Turn off the right-click context menu
    tabSize: 2,
  };

  // Monaco editor
  public editor: any;

  // Observable - are we still loading resources?
  public loading$: Observable<boolean>;

  public initing = true;

  // Observable for tracking if the Monaco editor has loaded
  private monacoLoaded$ = new BehaviorSubject<boolean>(false);

  private resizeSub: Subscription;
  private themeSub: Subscription;

  // Track whether the user changes the code in the text editor
  private codeOnEnter: string;

  // Reference to the editor, so we can adjust its size to fit
  @ViewChild('monacoEditor', { read: ElementRef }) monacoEditor: ElementRef;

  @ViewChild('schemaForm') schemaForm: JsonSchemaFormComponent;

  // Confirmation dialog - copy values
  overwriteValuesConfirmation = new ConfirmationDialogConfig(
    'Overwrite Values?',
    'Are you sure you want to replace your values with those from values.yaml?',
    'Overwrite'
  );

  // Confirmation dialog - copy release values
  overwriteReleaseValuesConfirmation = new ConfirmationDialogConfig(
    'Overwrite Values?',
    'Are you sure you want to replace your values with those from the release?',
    'Overwrite'
  );

  // Confirmation dialog - diff values
  overwriteDiffValuesConfirmation = new ConfirmationDialogConfig(
    'Overwrite Values?',
    'Are you sure you want to replace your values with the diff with values.yaml?',
    'Overwrite'
  );

  // Confirmation dialog - clear values
  clearValuesConfirmation = new ConfirmationDialogConfig(
    'Clear Values?',
    'Are you sure you want to clear the form values?',
    'Overwrite'
  );

  constructor(
    private elRef: ElementRef,
    private renderer: Renderer2,
    private httpClient: HttpClient,
    private themeService: ThemeService,
    private confirmDialog: ConfirmationDialogService,
  ) { }

  ngOnInit(): void {
    // Listen for window resize and resize the editor when this happens
    this.resizeSub = fromEvent(window, 'resize').pipe(debounceTime(150)).subscribe(event => this.resize());
  }

  private init() {
    // Observabled for loading schema and values for the Chart
    const schema$ = this.httpClient.get(this.schemaUrl).pipe(catchError(e => of(null)));
    const values$: Observable<string> = this.httpClient.get(this.valuesUrl, { responseType: 'text' }).pipe(
      catchError(e => of(null))
    );

    // We need the schame, value sand the monaco editor to be all loaded before we're ready
    this.loading$ = combineLatest(schema$, values$, this.monacoLoaded$).pipe(
      filter(([schema, values, loaded]) => schema !== undefined && values !== undefined && loaded),
      tap(([schema, values, loaded]) => {
        this.schema = schema;
        if (values !== null) {
          this.chartValuesYaml = values;
          this.chartValues = yaml.safeLoad(values, { json: true });
          // Set the form to the chart values initially, so if the user does nothing, they get the defaults
          this.initialFormData = this.chartValues;
        }
        // Default to form if there is a schema
        if (schema !== null) {
          this.hasSchema = true;
          this.mode = EditorMode.JSonSchemaForm;
          // Register schema with the Monaco editor
          this.registerSchema(this.schema);
        } else {
          // No Schema, so register an auto-generated schema from the Chart's values
          this.registerSchema(generateJsonSchemaFromObject('Generated Schema', this.chartValues));

          // Inherit the previous values if available (upgrade)
          if (this.releaseValues) {
            this.code = yaml.safeDump(this.releaseValues);
          }
        }
        this.updateModel();
      }),
      map(([schema, values, loaded]) => !loaded),
      startWith(true)
    );

    this.initing = false;
  }

  ngAfterViewInit(): void {
    this.resizeEditor();
  }

  ngOnDestroy(): void {
    if (this.resizeSub) {
      this.resizeSub.unsubscribe();
    }
    if (this.themeSub) {
      this.themeSub.unsubscribe();
    }
  }

  // Toggle editor minimap on/off
  toggleMinimap() {
    this.minimap = !this.minimap;
    this.editor.updateOptions({ minimap: { enabled: this.minimap } });
  }

  // Toggle editor line numbers on/off
  toggleLineNumbers() {
    this.lineNumbers = !this.lineNumbers;
    this.editor.updateOptions({ lineNumbers: this.lineNumbers ? 'on' : 'off' });
  }

  // Store the update form data when the form changes
  // AJSF two-way binding seems to cause issues
  formChanged(data: any) {
    this.formData = data;
  }

  // The edit mode has changed (form or editor)
  editModeChanged(mode) {
    this.mode = mode.value;

    if (this.mode === EditorMode.CodeEditor) {
      // Form -> Editor
      // Only copy if there is not an error - otherwise keep the invalid yaml from the editor that needs fixing
      if (!this.yamlError) {
        const codeYaml = yaml.safeLoad(this.code || '{}', { json: true });
        const data = mergeObjects(codeYaml, this.formData);
        this.code = this.getDiff(data);
        this.codeOnEnter = this.code;
      }

      // Need to resize the editor, as it will be freshly shown
      this.resizeEditor();
    } else {
      // Editor -> Form
      // Try and parse the YAML - if we can't this is an error, so we can't edit this back in the form
      try {
        if (this.codeOnEnter === this.code) {
          // Code did not change
          return;
        }

        // Parse as json
        const json = yaml.safeLoad(this.code || '{}', { json: true });
        // Must be an object, otherwise it was not valid
        if (typeof (json) !== 'object') {
          throw new Error('Invalid YAML');
        }
        this.yamlError = false;
        const data = mergeObjects(this.formData, json);
        this.initialFormData = data;
        this.formData = data;
      } catch (e) {
        // The yaml in the code editor is invalid, so we can't marshal it back to json for the from editor
        this.yamlError = true;
      }
    }
  }

  // Called once the Monaco editor has loaded and then each time the model is update
  // Store a reference to the editor and ensure the editor theme is synchronized with the Stratos theme
  onMonacoInit(editor) {
    this.editor = editor;
    this.resize();

    // Only load the YAML support once - when we set the model, onMonacoInit will et
    if (this.model) {
      return;
    }

    // Load the YAML Language support - require is available as it will have been loaded by the Monaco vs loader
    const req = (window as any).require;
    req(['vs/language/yaml/monaco.contribution'], () => {
      // Set the model now that YAML support is loaded - this will update the editor correctly
      this.updateModel();
      this.monacoLoaded$.next(true);
    });

    // Watch for theme changes - set light/dark theme in the monaco editor as the Stratos theme changes
    this.themeSub = this.themeService.getTheme().subscribe(theme => {
      const monaco = (window as any).monaco;
      const monacoTheme = (theme.styleName === 'dark-theme') ? 'vs-dark' : 'vs';
      monaco.editor.setTheme(monacoTheme);
    });
  }

  private updateModel() {
    this.model = {
      language: 'yaml',
      uri: this.getSchemaUri()
    };
  }

  // Delayed resize of editor to fit
  resizeEditor() {
    setTimeout(() => this.resize(), 1);
  }

  // Resize editor to fit
  resize() {
    // Return if resize before editor has been set
    if (!this.editor) {
      return;
    }

    // Get width and height of the host element
    const w = this.elRef.nativeElement.offsetWidth;
    let h = this.elRef.nativeElement.offsetHeight;

    // Check if host element not visible (does not have a size)
    if ((w === 0) && (h === 0)) {
      return;
    }

    // Remove height of toolbar (since this is incluced in the height of the host element)
    h = h - TOOLBAR_HEIGHT;

    // Set the Monaco editor to the same size as the container
    this.renderer.setStyle(this.monacoEditor.nativeElement, 'width', `${w}px`);
    this.renderer.setStyle(this.monacoEditor.nativeElement, 'height', `${h}px`);

    // Ask Monaco to layout again with its new size
    this.editor.layout();
  }

  // Get an absolute URI for the Schema - it is not fetched, just used as a reference
  // schemaUrl is a relative URL - e.g. /p1/v1/chartsvc....
  getSchemaUri(): string {
    return `https://stratos.app/schemas${this.schemaUrl}`;
  }

  // Register the schema with the Monaco editor
  // Reference: https://github.com/pengx17/monaco-yaml/blob/master/examples/umd/index.html#L69
  registerSchema(schema: any) {
    const monaco = (window as any).monaco;
    monaco.languages.yaml.yamlDefaults.setDiagnosticsOptions({
      enableSchemaRequest: true,
      hover: true,
      completion: true,
      validate: true,
      format: true,
      schemas: [
        {
          uri: this.getSchemaUri(),
          fileMatch: [this.getSchemaUri()],
          schema
        }
      ]
    });
  }

  public getValues(): object {
    // Always diff the form with the Chart Values to get only the changes that the user has made
    return (this.mode === EditorMode.JSonSchemaForm) ? diffObjects(this.formData, this.chartValues) : yaml.safeLoad(this.code);
  }

  public copyValues() {
    const confirm = this.mode === EditorMode.JSonSchemaForm || this.mode === EditorMode.CodeEditor && this.code.length > 0;
    if (confirm) {
      this.confirmDialog.open(this.overwriteValuesConfirmation, () => {
        this.doCopyValues();
      });
    } else {
      this.doCopyValues();
    }
  }

  // Copy the chart values into either the form or the code editor, depending on the current mode
  private doCopyValues() {
    if (this.mode === EditorMode.JSonSchemaForm) {
      this.initialFormData = this.chartValues;
    } else {
      // Use the raw Yaml, so we keep comments and formatting
      this.code = this.chartValuesYaml;
    }
  }

  public copyReleaseValues() {
    const confirm = this.mode === EditorMode.JSonSchemaForm || this.mode === EditorMode.CodeEditor && this.code.length > 0;
    if (confirm) {
      this.confirmDialog.open(this.overwriteReleaseValuesConfirmation, () => {
        this.doCopyReleaseValues();
      });
    } else {
      this.doCopyReleaseValues();
    }
  }

  // Copy the release values into either the form or the code editor, depending on the current mode
  private doCopyReleaseValues() {
    if (this.mode === EditorMode.JSonSchemaForm) {
      this.initialFormData = this.releaseValues;
    } else {
      this.code = yaml.safeDump(this.releaseValues);
    }
  }

  // Reset the form values and the code
  clearFormValues() {
    this.confirmDialog.open(this.clearValuesConfirmation, () => {
      this.initialFormData = {};
      this.code = '';
      this.codeOnEnter = '';
    });
  }

  // Update the code editor to only show the YAML that contains the differences with the values.yaml
  diff() {
    this.confirmDialog.open(this.overwriteDiffValuesConfirmation, () => {
      const userValues = yaml.safeLoad(this.code, { json: true });
      this.code = this.getDiff(userValues);
    });
  }

  getDiff(userValues: any): string {
    let code = yaml.safeDump(diffObjects(userValues, this.chartValues));
    if (code.trim() === '{}') {
      code = '';
    }
    return code;
  }
}