Source: form/formElement.js

/**
 * Definition and construction of form elements determined by JSON Schemas.
 *
 * @module formElement
 */
import { ArrayHandler, ObjectHandler } from './json-schema/childApplicators.js';
import {
  BooleanInput,
  InputTitle,
  InstanceViewPanel,
  LabelTitle,
  NumericInput,
  PillsInstancePanel,
  RemoveButton,
  RequiredIcon,
  RootIcon,
  SelectInstancePanel,
  TextInput,
  Toggler,
} from './components.js';
import { ElementType, State, TitleType } from './formElementDefinitions.js';
import process from './json-schema/inPlaceApplicators.js';
import JsonSchemaKeywords from './json-schema/JsonSchemaKeywords.js';
import JsonSchemaType from './json-schema/JsonSchemaType.js';
import Layout from './layouts.js';
import createSelectors from './selectors.js';
import formElementByDiv from '../utils/formElementByDiv.js';
import pointerToId from '../utils/pointerToId.js';

/** The form element structure based on a JSON Schema. */
class FormElement {
  /**
   * Class constructor.
   *
   * @param {object} schema Object defining the JSON Schema to be reflected by
   * the form element.
   * @param {object} [parameters] The form element construction parameters.
   * @param {module:formElementDefinitions~ElementType} [parameters.elementType=module:formElementDefinitions~ElementType.ROOT] The
   * type of the form element regarding the way it is created.
   * @param {module:formElementDefinitions~TitleType} [parameters.titleType=module:formElementDefinitions~TitleType.STATIC] The
   * type of the form element title.
   * @param {module:formElementDefinitions~State} [parameters.state=new module:formElementDefinitions~State()] The
   * state of the form element at initialization.
   * @param {string} [parameters.pointer=''] A JSON Pointer-like string that
   * provides unique identification of the JSON Schema being represented by the
   * form element.
   * @param {string} [parameters.propertyKey] The name of the property defined
   * by the provided JSON Schema, only regarded when the child applicator
   * generating the form element is the `properties` applicator.
   * @param {object} [parameters.formOptions={}] Object storing optional
   * parameters for the form element construction.
   * @param {Function} [parameters.removeCallback] The function to be called if
   * the form element is removed, only regarded when the form element is of type
   * `ElementType.REMOVABLE`.
   */
  constructor(
    schema,
    {
      elementType = ElementType.ROOT,
      titleType = TitleType.STATIC,
      state = new State(),
      pointer = '',
      propertyKey = undefined,
      formOptions = {},
      removeCallback = undefined,
    } = {}
  ) {
    /**
     * The type of the form element regarding the way it is created.
     *
     * @type {module:formElementDefinitions~ElementType}
     */
    this.elementType = elementType;

    /**
     * The type of the form element title.
     *
     * @type {module:formElementDefinitions~TitleType}
     */
    this.titleType = titleType;

    /**
     * The state of the form element at initialization.
     *
     * @type {module:formElementDefinitions~State}
     */
    this.state = state;

    /**
     * A JSON Pointer-like string that provides unique identification of the
     * JSON Schema being represented by the form element.
     *
     * @type {string}
     */
    this.pointer = pointer;

    /**
     * The name of the property defined by the provided JSON Schema, only
     * regarded when the child applicator generating the form element is the
     * `properties` applicator.
     *
     * @member {string} propertyKey
     *
     * @memberof module:formElement~FormElement
     *
     * @instance
     */
    if (propertyKey) this.propertyKey = propertyKey;

    /**
     * Object storing optional parameters for the form element construction.
     *
     * @type {object}
     */
    this.formOptions = formOptions;

    /**
     * The function to be called if the form element is removed, only regarded
     * when the form element is of type `ElementType.REMOVABLE`.
     *
     * @member {Function} removeCallback
     *
     * @memberof module:formElement~FormElement
     *
     * @instance
     */

    if (removeCallback) this.removeCallback = removeCallback;

    /**
     * The set of JSON Schema keywords that are currently represented by the
     * form element.
     *
     * @member {object} keywords
     *
     * @memberof module:formElement~FormElement
     *
     * @instance
     */

    /**
     * The root node of a tree representation of the possible forms that the
     * JSON Schema may possibly take considering its in-place applicators.
     *
     * @member {module:inPlaceApplicators~IPASchema} inPlaceApplicationTree
     *
     * @memberof module:formElement~FormElement
     *
     * @instance
     */

    const inPlaceApplicationTree = process(schema);

    if (inPlaceApplicationTree.applicatorByPointer.size) {
      this.inPlaceApplicationTree = inPlaceApplicationTree;
      this.keywords = this.inPlaceApplicationTree.getSelectedSchema();
    } else this.keywords = inPlaceApplicationTree.common;

    /**
     * The title icon component of the form element.
     *
     * @type {module:components~Component}
     */
    this.titleIcon = buildTitleIcon(this);

    /**
     * The title component of the form element.
     *
     * @type {module:components~Component}
     */
    this.title = buildTitle(this);

    /**
     * The function to be called if the form element is removed, only regarded
     * when the form element is of type `ElementType.REMOVABLE`.
     *
     * @member {module:selectors~SelectorsContainer} selectors
     *
     * @memberof module:formElement~FormElement
     *
     * @instance
     */

    if (this.inPlaceApplicationTree) this.selectors = buildSelectors(this);

    /**
     * The content of the form element, complying the JSON Schema
     * specifications.
     *
     * @type {module:components~Component}
     */
    this.content = buildContent(this);

    if (
      formOptions.initTogglersOff &&
      elementType === ElementType.OPTIONAL &&
      (!state.active || state.disabled)
    )
      this.content.domElement.classList.add('disabled');

    if (titleType !== TitleType.FIELD) setLabelReference(this);

    /**
     * The layout object generating and keeping the DOM references to the form
     * element representation.
     *
     * @type {module:layouts~Layout}
     */
    this.layout = new Layout(this);
    formElementByDiv.set(this.layout.root, this);
  }

  /**
   * Rebuilds a form element after using the current in-place applicators
   * selection.
   */
  rebuild() {
    this.keywords = this.inPlaceApplicationTree.getSelectedSchema();
    this.content = buildContent(this);

    if (this.titleType !== TitleType.FIELD) setLabelReference(this);

    this.layout.updateContentPane(this);
  }

  /**
   * Checks if the form element is enabled.
   *
   * @returns {boolean} `true` if the form element is enabled (that is, fully
   * interactive), `false` otherwise.
   */
  isEnabled() {
    return this.state.active && !this.state.disabled;
  }

  /**
   * Activates the form element, allowing it to be either partially or fully
   * interacted with respect to its current state.
   */
  activate() {
    if (!this.state.active) {
      if (this.elementType !== ElementType.REMOVABLE) this.titleIcon.enable();

      this.state.active = true;

      if (!this.state.disabled) this.enable();
    }
  }

  /** Deactivates the form element, disallowing any interaction. */
  deactivate() {
    if (this.state.active) {
      if (this.elementType !== ElementType.REMOVABLE) this.titleIcon.disable();

      this.state.active = false;

      if (!this.state.disabled) this.disable();
    }
  }

  /** Enables the form element, allowing it to be fully interacted. */
  enable() {
    if (this.selectors) this.selectors.enable();

    if (this.titleType === TitleType.FIELD) this.title.enable();

    if (this.state.disabled) this.state.disabled = false;

    this.content.enable();
    this.content.domElement.classList.remove('disabled');
  }

  /**
   * Disables the form element, disallowing any interaction other than its
   * icon.
   */
  disable() {
    if (this.selectors) this.selectors.disable();

    if (this.titleType === TitleType.FIELD) this.title.disable();

    if (this.state.active) this.state.disabled = true;

    this.content.disable();
    this.content.domElement.classList.add('disabled');
  }

  /**
   * Retrieves the JSON instance expressed by the form element together with the
   * inputted values.
   *
   * @returns {any} The JSON instance associated to the form element.
   */
  getInstance() {
    if (
      this.keywords.type === JsonSchemaType.ARRAY ||
      this.keywords.type === JsonSchemaType.OBJECT
    )
      return this.content.getInstance();
    else return this.content.getValue();
  }
}

/**
 * Builds the selector toolbars that allow to choose among the possible JSON
 * Schema materializations regarding its in-place applicators.
 *
 * @param {module:formElement~FormElement} fe The form element which the
 * selector toolbar will be built for.
 *
 * @returns {module:selectors~SelectorsContainer} The container handling the
 * selector toolbars.
 *
 * @modifies fe
 */
function buildSelectors(fe) {
  const selectorCallback = (id, pointer, selectorIndex, dropdownItemIndex) => {
    fe.selectors.update(
      id,
      pointer,
      selectorIndex,
      dropdownItemIndex,
      selectorCallback
    );

    fe.rebuild();
  };

  return createSelectors(
    pointerToId(fe.pointer),
    fe.inPlaceApplicationTree,
    selectorCallback,
    fe.elementType === ElementType.OPTIONAL
      ? fe.formOptions.initTogglersOff
      : false
  );
}

/**
 * Builds a title icon component.
 *
 * @param {module:formElement~FormElement} fe The form element which the title
 * icon will be built for.
 *
 * @returns {module:components~Component} The title icon component.
 */
function buildTitleIcon(fe) {
  const titleIconCase = {
    [ElementType.ROOT]: {
      c: RootIcon,
      args: [
        {
          fontAwesome: fe.formOptions.fontAwesome,
        },
      ],
    },
    [ElementType.REMOVABLE]: {
      c: RemoveButton,
      args: [
        () => void fe.removeCallback(),
        {
          disabled: !fe.state.active,
        },
        {
          bootstrap: fe.formOptions.bootstrap,
          fontAwesome: fe.formOptions.fontAwesome,
        },
      ],
    },
    [ElementType.REQUIRED]: {
      c: RequiredIcon,
      args: [
        {
          fontAwesome: fe.formOptions.fontAwesome,
        },
      ],
    },
    [ElementType.OPTIONAL]: {
      c: Toggler,
      args: [
        pointerToId(fe.pointer),
        () => {
          if (fe.state.disabled) fe.enable();
          else fe.disable();
        },
        {
          disabled: !fe.state.active,
          initOff: fe.state.disabled || fe.formOptions.initTogglersOff,
        },
        {
          bootstrap: fe.formOptions.bootstrap,
        },
      ],
    },
  }[fe.elementType];

  return titleIconCase ? new titleIconCase.c(...titleIconCase.args) : null;
}

/**
 * Builds a title component.
 *
 * @param {module:formElement~FormElement} fe The form element which the title
 * will be built for.
 *
 * @returns {module:components~Component} The title component.
 */
function buildTitle(fe) {
  const titleCase = {
    [TitleType.FIELD]: {
      c: InputTitle,
      args: [
        pointerToId(fe.pointer),
        {
          disabled: !fe.state.active || fe.state.disabled,
          placeholder: fe.keywords.title || fe.propertyKey,
        },
        {
          bootstrap: fe.formOptions.bootstrap,
        },
      ],
    },
    [TitleType.STATIC]: {
      c: LabelTitle,
      args: [
        fe.keywords.title ||
          (fe.elementType === ElementType.REMOVABLE
            ? fe.formOptions.arrayItemTitle
            : fe.pointer.split('-').pop()),
        fe.keywords.description,
        {
          htmlFor:
            fe.content && fe.content.input ? fe.content.input.id : undefined,
        },
        {
          bootstrap: fe.formOptions.bootstrap,
          fontAwesome: fe.formOptions.fontAwesome,
        },
      ],
    },
    [TitleType.ADDED_ITEM]: {
      c: LabelTitle,
      args: [
        fe.formOptions.arrayItemTitle,
        fe.keywords.description,
        {
          htmlFor:
            fe.content && fe.content.input ? fe.content.input.id : undefined,
        },
        {
          bootstrap: fe.formOptions.bootstrap,
          fontAwesome: fe.formOptions.fontAwesome,
        },
      ],
    },
  }[fe.titleType];

  return titleCase ? new titleCase.c(...titleCase.args) : null;
}

/**
 * Builds the form element content based on its associated JSON Schema.
 *
 * @param {module:formElement~FormElement} fe The form element which the content
 * component will be built for.
 *
 * @returns {module:components~Component} The content component.
 */
function buildContent(fe) {
  if (
    Object.keys(fe.keywords).includes(
      JsonSchemaKeywords.GenericValidation.CONST
    ) ||
    Object.keys(fe.keywords).includes(JsonSchemaKeywords.GenericValidation.ENUM)
  )
    return buildEnum(fe);
  else {
    if (!fe.keywords.type)
      console.warn(`No "type" keyword assigned to ${fe.pointer}. Setting \
fallback type to "string".`);
    return {
      [JsonSchemaType.ARRAY]: buildArray,
      [JsonSchemaType.BOOLEAN]: buildBoolean,
      [JsonSchemaType.INTEGER]: buildNumber,
      [JsonSchemaType.NULL]: buildNull,
      [JsonSchemaType.NUMBER]: buildNumber,
      [JsonSchemaType.OBJECT]: buildObject,
      [JsonSchemaType.STRING]: buildString,
    }[fe.keywords.type || JsonSchemaType.STRING](fe);
  }
}

/**
 * Builds the form element content of an `array`-typed JSON Schema.
 *
 * @param {module:formElement~FormElement} fe The form element which the content
 * component will be built for.
 *
 * @returns {module:childApplicators~ArrayHandler} The component handling the
 * `array`-typed JSON Schema.
 */
function buildArray(fe) {
  return new ArrayHandler(fe);
}

/**
 * Builds the form element content of a `boolean`-typed JSON Schema.
 *
 * @param {module:formElement~FormElement} fe The form element which the content
 * component will be built for.
 *
 * @returns {module:components~InputComponent} The component presenting an input
 * control for the `boolean`-typed JSON Schema.
 */
function buildBoolean(fe) {
  return new BooleanInput(
    pointerToId(fe.pointer),
    {
      disabled: !fe.isEnabled(),
      form: fe.formOptions.formId,
    },
    {
      bootstrap: fe.formOptions.bootstrap,
    }
  );
}

/**
 * Builds the form element content of an `enum` JSON Schema.
 *
 * @param {module:formElement~FormElement} fe The form element which the content
 * component will be built for.
 *
 * @returns {module:components~InputComponent} The component presenting an input
 * control for the `enum` JSON Schema.
 */
function buildEnum(fe) {
  if (fe.keywords.const)
    return new InstanceViewPanel(
      fe.keywords.const,
      fe.formOptions.booleanTranslateFunction,
      {
        bootstrap: fe.formOptions.bootstrap,
      }
    );
  else if (fe.keywords.enum.length === 1)
    return new InstanceViewPanel(
      fe.keywords.enum[0],
      fe.formOptions.booleanTranslateFunction,
      {
        bootstrap: fe.formOptions.bootstrap,
      }
    );
  else if (
    fe.formOptions.bootstrap &&
    fe.keywords.enum.length <= fe.formOptions.maxEnumTabs &&
    fe.keywords.enum.some((i) => i instanceof Object)
  )
    return new PillsInstancePanel(
      pointerToId(fe.pointer),
      fe.keywords.enum,
      fe.formOptions.objectSubstitute,
      fe.formOptions.arraySubstitute,
      fe.formOptions.booleanTranslateFunction,
      {
        bootstrap: fe.formOptions.bootstrap,
        fontAwesome: fe.formOptions.fontAwesome,
      }
    );
  else
    return new SelectInstancePanel(
      pointerToId(fe.pointer),
      fe.keywords.enum,
      fe.formOptions.objectSubstitute,
      fe.formOptions.arraySubstitute,
      fe.formOptions.booleanTranslateFunction,
      0,
      {
        disabled: !fe.isEnabled(),
        multiple: true,
        size: fe.formOptions.maxSelectSize,
      },
      {
        bootstrap: fe.formOptions.bootstrap,
        fontAwesome: fe.formOptions.fontAwesome,
      }
    );
}

/**
 * Builds the form element content of a `null`-typed JSON Schema.
 *
 * @returns {module:components~Component} The component presenting the `null`
 * -typed JSON Schema.
 */
function buildNull() {
  const nullDiv = document.createElement('div');
  nullDiv.innerText = 'null';

  const content = {
    domElement: nullDiv,
    enable() {},
    disable() {},
    getValue() {
      return null;
    },
  };

  return content;
}

/**
 * Builds the form element content of a `number`-typed JSON Schema.
 *
 * @param {module:formElement~FormElement} fe The form element which the content
 * component will be built for.
 *
 * @returns {module:components~InputComponent} The component presenting an input
 * control for the `number`-typed JSON Schema.
 */
function buildNumber(fe) {
  let max;
  let min;
  let step;

  // Handles case of "integer" type by configuring the "step" attribute
  // accordingly.
  if (fe.keywords.type === JsonSchemaType.INTEGER) {
    if (fe.keywords.multipleOf) {
      if (Math.floor(fe.keywords.multipleOf) === fe.keywords.multipleOf)
        step = fe.keywords.multipleOf;
      else {
        const n = fe.keywords.multipleOf.toString().split('.')[1].length - 1;

        for (const i of [2 * 10 ** n, 5 * 10 ** n, 10 * 10 ** n]) {
          const q = fe.keywords.multipleOf * i;

          if (Math.floor(q) === q) {
            step = q;
            break;
          }
        }
      }
    } else step = 1;
  } else step = fe.keywords.multipleOf;

  // Prioritizes "exclusiveMaximum" over "maximum".
  max = fe.keywords.exclusiveMaximum || fe.keywords.maximum;

  if (max) {
    if (step) {
      max = Math.floor(max / step) * step;
      max =
        Math.floor(max) === max
          ? max
          : max.toFixed(step.toString().split('.')[1].length);
    }

    if (fe.keywords.exclusiveMaximum && fe.keywords.exclusiveMaximum === max)
      max = step ? max - step : max - 1;
  }

  // Prioritizes "exclusiveMinimum" over "minimum".
  min = fe.keywords.exclusiveMinimum || fe.keywords.minimum;

  if (min) {
    min = step ? Math.ceil(min / step) * step : min;

    if (fe.keywords.exclusiveMinimum && fe.keywords.exclusiveMinimum === min)
      min = step ? min + step : min + 1;
  }

  return new NumericInput(
    pointerToId(fe.pointer),
    {
      disabled: !fe.isEnabled(),
      form: fe.formOptions.formId,
      max,
      min,
      step,
    },
    {
      bootstrap: fe.formOptions.bootstrap,
    }
  );
}

/**
 * Builds the form element content of an `object`-typed JSON Schema.
 *
 * @param {module:formElement~FormElement} fe The form element which the content
 * component will be built for.
 *
 * @returns {module:childApplicators~ObjectHandler} The component handling the
 * `object`-typed JSON Schema.
 */
function buildObject(fe) {
  return new ObjectHandler(fe);
}

/**
 * Builds the form element content of a `string`-typed JSON Schema.
 *
 * @param {module:formElement~FormElement} fe The form element which the content
 * component will be built for.
 *
 * @returns {module:components~InputComponent} The component presenting an input
 * control for the `string`-typed JSON Schema.
 */
function buildString(fe) {
  return new TextInput(
    pointerToId(fe.pointer),
    {
      disabled: !fe.isEnabled(),
      form: fe.formOptions.formId,
      maxlength: fe.keywords.maxLength,
      minlength: fe.keywords.minLength,
      pattern: fe.keywords.pattern,
    },
    {
      bootstrap: fe.formOptions.bootstrap,
    }
  );
}

/**
 * Sets the title label of a form element to point to its referred input
 * control.
 *
 * @param {module:formElement~FormElement} fe The form element which the content
 * component will be built for.
 *
 * @modifies fe
 */
function setLabelReference(fe) {
  const id =
    fe.keywords.type !== JsonSchemaType.ARRAY &&
    fe.keywords.type !== JsonSchemaType.OBJECT &&
    fe.keywords.type !== JsonSchemaType.NULL
      ? fe.content.getId()
      : undefined;

  if (id) fe.title.setLabelFor(id);
}

export default FormElement;