Source: src/js/views/JSONEditorView.js

"use strict";

define(["backbone", `text!${MetacatUI.root}/css/json-editor.css`], (
  Backbone,
  JSONEditorCSS,
) => {
  const BASE_CLASS = "json-editor";
  const CLASS_NAMES = {
    EDITOR: BASE_CLASS,
    LABEL: `${BASE_CLASS}__label`,
    INPUT: `${BASE_CLASS}__input`,
    ERROR: `${BASE_CLASS}__error`,
  };
  const ID_SUFFIXES = { INPUT: "-json", ERROR: "-error" };
  const CSS_KEY = "jsonEditor";

  /**
   * @class JSONEditorView
   * @classdesc Edits JSON values and keeps incomplete drafts.
   * @classcategory Views
   * @augments Backbone.View
   * @since 2.41.0
   * @screenshot views/JSONEditorView.png
   */
  const JSONEditorView = Backbone.View.extend(
    /** @lends JSONEditorView.prototype */ {
      /** @inheritdoc */
      className: CLASS_NAMES.EDITOR,

      /** @inheritdoc */
      events: {
        "input textarea": "updateDraft",
      },

      /**
       * Store the initial JSON as text so caller edits can't change it.
       * @param {object} options View options
       * @param {*} options.value A JSON value
       * @param {string} options.label The visible field label
       */
      initialize(options) {
        this.label = options.label;
        this.draft = JSON.stringify(options.value, null, 2);
        this.baseline = this.draft;
        this.errorMessage = "";
        MetacatUI.appModel.addCSS(JSONEditorCSS, CSS_KEY);
      },

      /**
       * The HTML template for the view.
       * @returns {string} The template string
       */
      template() {
        return `
          <label class="${CLASS_NAMES.LABEL}" for="${this.cid}${ID_SUFFIXES.INPUT}"></label>
          <textarea class="${CLASS_NAMES.INPUT}" id="${this.cid}${ID_SUFFIXES.INPUT}"
            rows="14" wrap="off" spellcheck="false" autocapitalize="off"
            aria-describedby="${this.cid}${ID_SUFFIXES.ERROR}"></textarea>
          <p class="${CLASS_NAMES.ERROR}" id="${this.cid}${ID_SUFFIXES.ERROR}" role="alert"></p>
        `;
      },

      /** @inheritdoc */
      render() {
        this.el.innerHTML = this.template();
        this.el.querySelector("label").textContent = this.label;
        this.textarea = this.el.querySelector("textarea");
        this.errorEl = this.el.querySelector(`.${CLASS_NAMES.ERROR}`);
        this.textarea.value = this.draft;
        this.showError(this.errorMessage);
        return this;
      },

      /**
       * Replace the draft and baseline with an accepted or discarded value.
       * @param {*} value A JSON value
       */
      setValue(value) {
        const wasPending = this.hasPendingChanges();
        this.draft = JSON.stringify(value, null, 2);
        this.baseline = this.draft;
        this.textarea.value = this.draft;
        this.textarea.setSelectionRange(0, 0);
        this.showError("");
        if (wasPending) this.trigger("change:draft", false);
      },

      /**
       * Parse the draft without accepting it. Show syntax errors in the editor.
       * @returns {{value: *, error: string|null}} Parsed value or syntax error
       */
      parse() {
        let value;
        try {
          value = JSON.parse(this.textarea.value);
        } catch (error) {
          this.showError(error.message);
          this.focus();
          return { value: undefined, error: error.message };
        }
        this.showError("");
        return { value, error: null };
      },

      /**
       * Check whether the draft differs from its last accepted value.
       * @returns {boolean} Whether changes are pending
       */
      hasPendingChanges() {
        return this.draft !== this.baseline;
      },

      /**
       * Show syntax errors or caller feedback as text.
       * @param {string} message The error message, or an empty string to clear it
       */
      showError(message) {
        this.errorMessage = message;
        this.errorEl.textContent = message;
        if (message) this.textarea.setAttribute("aria-invalid", "true");
        else this.textarea.removeAttribute("aria-invalid");
      },

      /** Focus the editor without changing its text selection */
      focus() {
        this.textarea.focus();
      },

      /**
       * Update the draft and clear old feedback. Fire change:draft when edits
       * become pending or return to the accepted text.
       * @param {Event} event The input event
       */
      updateDraft(event) {
        const wasPending = this.hasPendingChanges();
        this.draft = event.target.value;
        this.showError("");
        const pending = this.hasPendingChanges();
        if (pending !== wasPending) this.trigger("change:draft", pending);
      },
    },
  );

  return JSONEditorView;
});