Source: form/selectors.js

/**
 * Dropdown buttons organized in toolbars, acting as selectors for the
 * alternative materializations of a JSON Schema (thus following the tree
 * representation of schemas, {@link module:inPlaceApplicators~IPASchema}).
 *
 * ❗️ This module relies on [Bootstrap](https://getbootstrap.com/); if not
 * available, the HTML result will not be properly displayed.
 *
 * @module selectors
 *
 * @see module:inPlaceApplicators~IPASchema
 */
import pointerToId from '../utils/pointerToId.js';

/**
 * Class representing a container for {@link module:selectors~SelectorsToolbar}
 * components.
 *
 * @implements {module:components~Component}
 */
class SelectorsContainer {
  /**
   * Class constructor.
   *
   * @param {Map.<string, module:selectors~SelectorsToolbar>} toolbarByPointer
   * A map containing the selectors toolbars, indexed by the JSON Pointer string
   * that points to the in-place applicator represented by the first selector of
   * the mapped toolbar.
   */
  constructor(toolbarByPointer) {
    this.domElement = document.createElement('div');
    this.domElement.classList.add('btn-toolbar');
    this.domElement.setAttribute('role', 'toolbar');

    /**
     * The map containing the selectors toolbars indexed by the JSON Pointer
     * string that points to the in-place applicator represented by the first
     * selector of the mapped toolbar.
     *
     * @type {Map.<string, module:selectors~SelectorsToolbar>}
     */
    this.toolbarByPointer = toolbarByPointer;

    for (const [, toolbar] of this.toolbarByPointer)
      this.domElement.appendChild(toolbar.domElement);
  }

  disable() {
    for (const [, toolbar] of this.toolbarByPointer) toolbar.disable();
  }

  enable() {
    for (const [, toolbar] of this.toolbarByPointer) toolbar.enable();
  }

  /**
   * Updates selectors toolbars present in the container and the underlying
   * {@link module:inPlaceApplicators~IPASchema} tree.
   *
   * @param {string} formElementId The `id` of the form element.
   * @param {string} pointer The JSON Pointer to the in-place applicator
   * represented by the first selector of the toolbar which triggered the
   * update.
   * @param {number} selectorIndex The index in the toolbar of the selector
   * triggering the update.
   * @param {number} dropdownItemIndex The index in the dropdown option list
   * which triggered the update.
   * @param {Function} callback The callback function to be called on `click`
   * events from the newly appended selectors.
   */
  update(formElementId, pointer, selectorIndex, dropdownItemIndex, callback) {
    // Updates selection of in-place applicator.
    this.toolbarByPointer
      .get(pointer)
      .selectors[selectorIndex].updateSelection(dropdownItemIndex);

    // Generates the new selectors to attach.
    const ipaSchema = this.toolbarByPointer
      .get(pointer)
      .selectors[selectorIndex].applicator.getSelectedIpaSchema();

    const [selectorsToAppend, newSelectorsMatrix] = generateUpdatedSelectors(
      formElementId,
      ipaSchema,
      pointer,
      selectorIndex,
      callback
    );

    // Updates the changed toolbar.
    this.toolbarByPointer
      .get(pointer)
      .update(selectorIndex, dropdownItemIndex, selectorsToAppend);

    // Removes the obsolete toolbars.
    for (const [p] of this.toolbarByPointer) {
      if (p.startsWith(pointer) && p !== pointer) {
        this.toolbarByPointer.get(p).remove();
        this.toolbarByPointer.delete(p);
      }
    }

    // Adds the new toolbars.
    for (const [p, selectors] of newSelectorsMatrix) {
      this.toolbarByPointer.set(p, new SelectorsToolbar(selectors));
      this.domElement.appendChild(this.toolbarByPointer.get(p).domElement);
    }
  }
}

/**
 * Class representing a toolbar of {@link module:selectors~Selector} components.
 *
 * @implements {module:components~Component}
 */
class SelectorsToolbar {
  /**
   * Class constructor.
   *
   * @param {Array.<module:selectors~Selector>} selectors An array of selector
   * components contained in the toolbar.
   */
  constructor(selectors) {
    this.domElement = document.createElement('div');
    this.domElement.classList.add('btn-group', 'mr-2');
    this.domElement.setAttribute('role', 'group');
    this.domElement.setAttribute('aria-label', 'Selectors toolbar');

    /**
     * The array of selector components contained in the toolbar.
     *
     * @type {Array.<module:selectors~Selector>}
     */
    this.selectors = selectors;

    for (const selector of this.selectors)
      this.domElement.appendChild(selector.domElement);
  }

  disable() {
    for (const selector of this.selectors) selector.disable();
  }

  enable() {
    for (const selector of this.selectors) selector.enable();
  }

  /**
   * Updates the toolbar by removing the obsolete selectors and appending the
   * ones provided.
   *
   * @param {number} selectorIndex The index in the toolbar of the selector
   * triggering the update.
   * @param {number} dropdownItemIndex The index in the dropdown option list
   * which triggered the update.
   * @param {Array.<module:selectors~Selector>} selectorsToAppend The array of
   * selectors that should be appended to the toolbar.
   */
  update(selectorIndex, dropdownItemIndex, selectorsToAppend) {
    // Updates selector content.
    this.selectors[selectorIndex].update(dropdownItemIndex);

    // Removes the obsolete selectors and appends the new ones.
    const deleteCount = this.selectors.length - 1 - selectorIndex;

    Array.from(
      {
        length: deleteCount,
      },
      (_, index) => this.selectors[selectorIndex + 1 + index].remove()
    );

    this.selectors.splice(selectorIndex + 1, deleteCount, ...selectorsToAppend);

    for (const selector of selectorsToAppend)
      this.domElement.appendChild(selector.domElement);
  }

  /** Removes the DOM elements associated to the toolbar. */
  remove() {
    this.domElement.remove();
  }
}

/**
 * Class representing a selector component, which represents an
 * {@link module:inPlaceApplicators~InPlaceApplicator} in the
 * {@link module:inPlaceApplicators~IPASchema} tree.
 *
 * The selector consists of a dropdown button with a list of choices each
 * representing a subschema in the associated in-place applicator.
 *
 * @implements {module:components~Component}
 */
class Selector {
  /**
   * Class constructor.
   *
   * @param {module:inPlaceApplicators~InPlaceApplicator} applicator The
   * in-place applicator to be represented.
   * @param {string} formElementId The `id` of the form element.
   * @param {string} pointer The JSON Pointer to the in-place applicator
   * represented by the first selector of the toolbar containing the newly
   * created selector.
   * @param {number} selectorIndex The index in the toolbar of the newly created
   * selector.
   * @param {Function} callback The callback function for the `click` DOM event
   * generated by any of the choices listed in the dropdown.
   * @param {boolean} [disabled=false] Flag indicating whether the button should
   * be disabled at initialization.
   */
  constructor(
    applicator,
    formElementId,
    pointer,
    selectorIndex,
    callback,
    disabled = false
  ) {
    /**
     * The represented in-place applicator.
     *
     * @type {module:inPlaceApplicators~InPlaceApplicator}
     */
    this.applicator = applicator;
    this.domElement = document.createElement('div');
    this.domElement.classList.add('btn-group');
    this.domElement.setAttribute('role', 'group');

    /**
     * The array of selector components contained in the toolbar.
     *
     * @type {Array.<module:selectors~Selector>}
     */
    this.button = this.domElement.appendChild(document.createElement('button'));
    this.button.id = `${formElementId}__selectors__${pointerToId(
      pointer + '/' + selectorIndex
    )}`;
    this.button.type = 'button';
    this.button.classList.add(
      'btn',
      'btn-primary',
      'btn-sm',
      'dropdown-toggle'
    );
    this.button.dataset.toggle = 'dropdown';
    this.button.setAttribute('aria-haspopup', true);
    this.button.setAttribute('aria-expanded', false);

    this.button.disabled = !!disabled;

    const dropdownDiv = this.domElement.appendChild(
      document.createElement('div')
    );
    dropdownDiv.classList.add('dropdown-menu');
    dropdownDiv.setAttribute('aria-labelledby', this.button.id);

    for (const [
      dropdownItemIndex,
      ipaSchema,
    ] of this.applicator.subschemas.entries()) {
      const a = dropdownDiv.appendChild(document.createElement('a'));
      a.classList.add('dropdown-item');
      a.href = '#';
      a.innerText = ipaSchema.getAnnotations().title;
      a.addEventListener(
        'click',
        () =>
          void callback(
            formElementId,
            pointer,
            selectorIndex,
            dropdownItemIndex
          )
      );

      if (window.$)
        window
          .$(a)
          .click((e) =>
            e.preventDefault ? e.preventDefault() : (e.returnValue = false)
          );
    }

    this.update();
  }

  disable() {
    this.button.disabled = true;
  }

  enable() {
    this.button.disabled = false;
  }

  /** Removes the DOM elements associated to the selector. */
  remove() {
    this.domElement.remove();
  }

  /**
   * Updates the underlying {@link module:inPlaceApplicators~InPlaceApplicator}
   * and the selector display.
   *
   * @param {number} selected Index of the selected in-place applicator
   * subschema.
   */
  updateSelection(selected) {
    this.applicator.selected = selected;
    this.update();
  }

  /** Updates the selector display. */
  update() {
    this.button.innerText = this.applicator
      .getSelectedIpaSchema()
      .getAnnotations().title;
  }
}

/**
 * Creates a {@link module:selectors~SelectorsContainer} which handles the
 * selectors representing the given {@link module:inPlaceApplicators~IPASchema}
 * tree.
 *
 * @param {string} formElementId The `id` of the form element.
 * @param {module:inPlaceApplicators~IPASchema} ipaSchema The root element of
 * the {@link module:inPlaceApplicators~IPASchema} tree that the selectors
 * should represent.
 * @param {Function} callback The callback function to be called on `click`
 * events from the created selectors.
 * @param {boolean} [disabled=false] Flag indicating whether the button should
 * be disabled at initialization.
 *
 * @returns {module:selectors~SelectorsContainer} The created
 * {@link module:selectors~SelectorsContainer}.
 */
function createSelectors(formElementId, ipaSchema, callback, disabled = false) {
  const selectorsMatrix = generateSelectors(
    formElementId,
    ipaSchema,
    callback,
    disabled
  );

  return new SelectorsContainer(
    new Map(
      selectorsMatrix.map(([pointer, selectors]) => [
        pointer,
        new SelectorsToolbar(selectors),
      ])
    )
  );
}

/**
 * Generates a matrix containing the selectors representing the given
 * {@link module:inPlaceApplicators~IPASchema} tree.
 *
 * @param {string} formElementId The `id` of the form element.
 * @param {module:inPlaceApplicators~IPASchema} ipaSchema The root element of
 * the {@link module:inPlaceApplicators~IPASchema} tree that the selectors
 * should represent.
 * @param {Function} callback The callback function to be called on `click`
 * events from the created selectors.
 * @param {boolean} disabled Flag indicating whether the selectors should be
 * disabled at initialization.
 *
 * @returns {Array.<Array>} A matrix whose rows are 2-length arrays, each of
 * them representing a selectors toolbar:
 *
 * - The first element is a JSON Pointer string that points to the in-place
 * applicator represented by the first selector of the mapped toolbar.
 *
 * - The second element is an array of {@link module:selectors~Selector} to be
 * assembled as a {@link module:selectors~SelectorsToolbar}.
 */
function generateSelectors(formElementId, ipaSchema, callback, disabled) {
  return forkRecursiveSelectorGenerator(
    formElementId,
    ipaSchema,
    callback,
    disabled
  );
}

/**
 * Generates the update data which includes the new selectors to be added to a
 * {@link module:selector~SelectorContainer}, with respect to the given
 * {@link module:inPlaceApplicators~IPASchema} tree.
 *
 * @param {string} formElementId The `id` of the form element.
 * @param {module:inPlaceApplicators~IPASchema} ipaSchema The root element of
 * the {@link module:inPlaceApplicators~IPASchema} tree that the selectors
 * should represent.
 * @param {string} updatedToolbarPointer The JSON Pointer to the in-place
 * applicator represented by the first selector of the toolbar which triggered
 * the update.
 * @param {number} triggererSelectorIndex The index in the toolbar of the
 * selector which triggered the update.
 * @param {Function} callback The callback function to be called on `click`
 * events from the created selectors.
 * @param {boolean} [disabled=false] Flag indicating whether the button should
 * be disabled at initialization.
 *
 * @returns {Array.<Array>} A 2-length array with the following structure:
 *
 * - The first element is an array of {@link module:selectors~Selector}
 * components to append to the toolbar which triggered the update.
 *
 * - The second element is a matrix whose rows are 2-length arrays, each of them
 * representing a selectors toolbar to be appended as result of the update:
 *
 *   - The first element is a JSON Pointer string that points to the in-place
 * applicator represented by the first selector of the mapped toolbar.
 *
 *   - The second element is an array of {@link module:selectors~Selector} to be
 * assembled as a {@link module:selectors~SelectorsToolbar}.
 */
function generateUpdatedSelectors(
  formElementId,
  ipaSchema,
  updatedToolbarPointer,
  triggererSelectorIndex,
  callback,
  disabled = false
) {
  if (ipaSchema.applicatorByPointer.size === 1) {
    const nextApplicator = ipaSchema.applicatorByPointer.values().next().value;

    const result = recursiveSelectorGenerator(
      formElementId,
      nextApplicator,
      updatedToolbarPointer,
      callback,
      disabled,
      triggererSelectorIndex + 1
    );

    const selectorsToAppend = result.shift();
    return [selectorsToAppend[1], result];
  } else
    return [
      [],
      forkRecursiveSelectorGenerator(
        formElementId,
        ipaSchema,
        callback,
        disabled
      ),
    ];
}

/**
 * Forks the execution of {@link module:selectors~recursiveSelectorGenerator} to
 * produce multiple selectors toolbars.
 *
 * @param {string} formElementId The `id` of the form element.
 * @param {module:inPlaceApplicators~IPASchema} ipaSchema The node of the
 * {@link module:inPlaceApplicators~IPASchema} tree to be represented.
 * @param {Function} callback The callback function to be called on `click`
 * events from the created selectors.
 * @param {boolean} disabled Flag indicating whether the selectors should be
 * disabled at initialization.
 *
 * @returns {Array.<Array>} A matrix whose rows are 2-length arrays, each of
 * them representing a selectors toolbar to be appended as result of the update:
 *
 * - The first element is a JSON Pointer string that points to the in-place
 * applicator represented by the first selector of the mapped toolbar.
 *
 * - The second element is an array of {@link module:selectors~Selector} to be
 * assembled as a {@link module:selectors~SelectorsToolbar}.
 */
function forkRecursiveSelectorGenerator(
  formElementId,
  ipaSchema,
  callback,
  disabled
) {
  return Array.from(ipaSchema.applicatorByPointer).flatMap(([p, a]) =>
    recursiveSelectorGenerator(formElementId, a, p, callback, disabled)
  );
}

/**
 * Recursive execution of the selector generation.
 *
 * @param {string} formElementId The `id` of the form element.
 * @param {module:inPlaceApplicators~InPlaceApplicator} applicator  The in-place
 * applicator to be represented by the selector being currently built.
 * @param {string} matrixKeyPointer A JSON Pointer string that points to the
 * in-place applicator represented by the first selector of the toolbar being
 * built.
 * @param {Function} callback The callback function to be called on `click`
 * events from the created selectors.
 * @param {boolean} disabled Flag indicating whether the selectors should be
 * disabled at initialization.
 * @param {number} [currentSelectorIndex=0] The index in the toolbar of the
 * selector being currently built.
 *
 * @returns {Array.<Array>} A matrix whose rows are 2-length arrays, each of
 * them representing a selectors toolbar to be appended as result of the update:
 *
 * - The first element is a JSON Pointer string that points to the in-place
 * applicator represented by the first selector of the mapped toolbar.
 *
 * - The second element is an array of {@link module:selectors~Selector} to be
 * assembled as a {@link module:selectors~SelectorsToolbar}.
 */
function recursiveSelectorGenerator(
  formElementId,
  applicator,
  matrixKeyPointer,
  callback,
  disabled,
  currentSelectorIndex = 0
) {
  const selector = new Selector(
    applicator,
    formElementId,
    matrixKeyPointer,
    currentSelectorIndex,
    callback,
    disabled
  );

  const selectedIpaSchema = applicator.getSelectedIpaSchema();

  if (selectedIpaSchema.applicatorByPointer.size == 0)
    return [[matrixKeyPointer, [selector]]];
  else if (selectedIpaSchema.applicatorByPointer.size == 1) {
    const nextApplicator = selectedIpaSchema.applicatorByPointer.values().next()
      .value;
    const result = recursiveSelectorGenerator(
      formElementId,
      nextApplicator,
      matrixKeyPointer,
      callback,
      disabled,
      currentSelectorIndex + 1
    );

    result[0][1] = [selector, ...result[0][1]];
    return result;
  } else {
    // (selectedIpaSchema.applicatorByPointer.size > 1)
    const results = forkRecursiveSelectorGenerator(
      formElementId,
      selectedIpaSchema,
      callback,
      disabled
    );

    return [[matrixKeyPointer, [selector]], ...results];
  }
}

export default createSelectors;