archivesspace/archivesspace

View on GitHub
public/app/assets/javascripts/InfiniteTree.js

Summary

Maintainability
D
2 days
Test Coverage
(function (exports) {
  class InfiniteTree {
    /**
     * @constructor
     * @param {number} waypointSize - The number of nodes per waypoint
     * @param {string} appUrlPrefix - The proper app prefix
     * @param {string} resourceUri - The URI of the collection resource
     * @param {string} identifier_separator - The i18n identifier separator
     * @param {string} date_type_bulk - The i18n date type bulk
     * @returns {InfiniteTree} - InfiniteTree instance
     */
    constructor(
      waypointSize,
      appUrlPrefix,
      resourceUri,
      identifier_separator,
      date_type_bulk
    ) {
      this.WAYPOINT_SIZE = waypointSize;
      this.appUrlPrefix = appUrlPrefix;
      this.resourceUri = resourceUri;
      this.repoId = this.resourceUri.split('/')[2];
      this.resourceId = this.resourceUri.split('/')[4];
      this.baseUri = `${this.resourceUri}/tree`;
      this.rootUri = `${this.baseUri}/root`;
      this.nodeUri = `${this.baseUri}/node`;
      this.waypointUri = `${this.baseUri}/waypoint`;
      this.i18n = { sep: identifier_separator, bulk: date_type_bulk };

      this.container = document.querySelector('#infinite-tree-container');

      this.waypointObserver = new IntersectionObserver(
        // Wrap handler in arrow fn to preserve `this` context
        (entries, observer) => {
          this.waypointScrollHandler(entries, observer);
        },
        {
          root: this.container,
          rootMargin: '-30% 0px -30% 0px',
        }
      );

      this.initTree();
    }

    /**
     * Initialize the large tree navigation sidebar with the collection's
     * root node and the first waypoint of its immediate children
     */
    async initTree() {
      const rootNode = await this.fetchRootNode();
      const rootNodeDivId = `resource_${this.resourceId}`;
      const firstWPData = rootNode.precomputed_waypoints[''][0];

      const tableRootFrag = new DocumentFragment();

      const tableRoot = document.createElement('div');
      tableRoot.setAttribute('role', 'list');
      tableRoot.className = 'table root';

      tableRoot.appendChild(this.rootRowMarkup(this.nodeTitle(rootNode)));
      tableRoot.appendChild(
        this.rootNodeWaypointsScaffold(rootNodeDivId, 1, rootNode.waypoints)
      );

      this.populateWaypoint(
        tableRoot.querySelector('.table-row-group'),
        firstWPData,
        1,
        rootNode.waypoints > 1
      );

      tableRootFrag.appendChild(tableRoot);

      this.container.appendChild(tableRootFrag);

      if (rootNode.waypoints > 1) {
        // Now that the above work has been added to the live DOM, start observing
        // the middle node in order to populate the next empty waypoint
        const obsSelector = `[data-parent-id="${rootNodeDivId}"][data-waypoint-number="0"] > [data-observe-next-wp]`;
        const obsTarget = document.querySelector(obsSelector);

        this.waypointObserver.observe(obsTarget);
      }
    }

    /**
     * Build a parent node's waypoint scaffold and populate the first waypoint.
     * This is called when any parent is expanded for the first time, then
     * the waypointObserver takes over to populate any next empty waypoints.
     * @param {string} nodeDivId - Div id of the parent node,
     * ie: 'archival_object_18028'
     */
    async initNodeChildren(nodeDivId) {
      const nodeId = nodeDivId.split('_')[2];
      const node = await this.fetchNode(nodeId);
      const nodeLevel = parseInt(
        document
          .querySelector(`#${nodeDivId}`)
          .closest('.table-row-group')
          .getAttribute('data-waypoint-level'),
        10
      );
      const nodeWpCount = node.waypoints;
      const nodeUri = `/repositories/${this.repoId}/archival_objects/${nodeId}`;

      this.nodeWaypointsScaffold(nodeDivId, nodeLevel + 1, nodeWpCount);

      const firstWP = document.querySelector(
        `[data-parent-id="${nodeDivId}"][data-waypoint-number="0"]`
      );

      this.populateWaypoint(
        firstWP,
        node.precomputed_waypoints[nodeUri][0],
        nodeLevel + 1,
        nodeWpCount > 1
      );

      if (nodeWpCount > 1) {
        this.waypointObserver.observe(
          firstWP.querySelector('[data-observe-next-wp]')
        );
      }
    }

    /**
     * Fetch the root node of the tree
     * @returns {Object} - Root node object as returned from the server
     */
    async fetchRootNode() {
      try {
        const response = await fetch(this.appUrlPrefix + this.rootUri);

        return await response.json();
      } catch (err) {
        console.error(err);
      }
    }

    /**
     * Fetch the tree of the node with the given id
     * @param {number} nodeId - ID of the node, ie: 18028
     * @returns {Object} - Node object as returned from the server
     */
    async fetchNode(nodeId) {
      const query = new URLSearchParams();

      query.append(
        'node',
        `/repositories/${this.repoId}/archival_objects/${nodeId}`
      );

      try {
        const response = await fetch(`${this.nodeUri}?${query}`);

        return await response.json();
      } catch (err) {
        console.error(err);
      }
    }

    /**
     * Fetch the next waypoint of the given node
     * @param {Object} params - Object of params for the ajax call with the signature:
     * @param {string} params.node - Node URL param in the form of '' or
     * '/repositories/X/archival_objects/Y'
     * @param {number} params.offset - Offset URL param
     * @returns {array} - Array of waypoint objects as returned from the server
     */
    async fetchWaypoint(params) {
      const query = new URLSearchParams();

      for (const key in params) {
        query.append(key, params[key]);
      }

      try {
        const response = await fetch(`${this.waypointUri}?${query}`);
        const waypoint = await response.json();

        return waypoint;
      } catch (err) {
        console.error(err);
      }
    }

    /**
     * Append the root row to the tree container
     * @param {string} title - Title of the root node
     * @returns {DocumentFragment} - DocumentFragment containing the root row
     */
    rootRowMarkup(title) {
      const titleContent = new MixedContent(title);
      const rootRowFrag = new DocumentFragment();
      const rootRow = document
        .querySelector('#infinite-tree-root-row-template')
        .content.cloneNode(true);

      rootRow.querySelector('.table-row').id = `resource_${this.resourceId}`;
      rootRow
        .querySelector('.table-row')
        .setAttribute('data-uri', this.resourceUri);

      rootRow
        .querySelector('.title')
        .setAttribute(
          'title',
          titleContent.isMixed ? titleContent.derivedString : titleContent.input
        );

      rootRow.querySelector(
        '.record-title'
      ).href = `#tree::resource_${this.resourceId}`;

      if (titleContent.isMixed) {
        rootRow.querySelector('.record-title').innerHTML = titleContent.input;
      } else {
        rootRow.querySelector('.record-title').textContent = titleContent.input;
      }

      rootRowFrag.appendChild(rootRow);

      return rootRowFrag;
    }

    /**
     * Build the waypoints of a node and populate its first waypoint
     * @param {number} nodeId - Div id of the parent node whose waypoints
     * are being scaffolded
     * @param {number} level - Level of the waypoints
     * @param {number} numWPs - Number of waypoints to create
     * @returns {DocumentFragment} - DocumentFragment containing the waypoint
     */
    rootNodeWaypointsScaffold(nodeId, level, numWPs) {
      const nodeWaypointsFrag = new DocumentFragment();

      for (let i = 0; i < numWPs; i++) {
        const tableRowGroup = document.createElement('div');
        tableRowGroup.className = 'table-row-group';
        tableRowGroup.setAttribute('data-parent-id', nodeId);
        tableRowGroup.setAttribute('data-waypoint-number', i);
        tableRowGroup.setAttribute('data-waypoint-level', level);
        tableRowGroup.setAttribute('role', 'list');

        const tableRow = document.createElement('div');
        tableRow.className = `table-row waypoint indent-level-${level}`;

        tableRowGroup.appendChild(tableRow);

        nodeWaypointsFrag.appendChild(tableRowGroup);
      }

      return nodeWaypointsFrag;
    }

    /**
     * Build the markup for a node row
     * @param {Object} node - Node data
     * @param {number} level - Indent level of the node
     * @param {boolean} shouldObserve - Whether or not to observe the node
     * in order to populate the next empty waypoint
     * @param {number} [parentId=null] - Optional ID of the node's parent; if null
     * then parent is assumed to be the root resource
     * @param {number} [offset=null] - Optional offset of the next waypoint to
     * populate; required if `shouldObserve` is true
     * @returns {DocumentFragment} - DocumentFragment containing the node row
     */
    nodeRowMarkup(node, level, shouldObserve, parentId = null, offset = null) {
      const aoId = node.uri.split('/')[4];
      const divId = `archival_object_${aoId}`;
      const titleContent = new MixedContent(this.nodeTitle(node));
      const aHref = `#tree::${divId}`;

      const nodeRowFrag = new DocumentFragment();

      const nodeRow = document
        .querySelector('#infinite-tree-node-row-template')
        .content.cloneNode(true);

      nodeRow.querySelector('.table-row').id = divId;
      nodeRow
        .querySelector('.table-row')
        .classList.add(`indent-level-${level}`);
      nodeRow.querySelector('.table-row').setAttribute('data-uri', node.uri);

      if (shouldObserve) {
        const nodeParam = parentId.startsWith('resource')
          ? ''
          : `/repositories/${this.repoId}/archival_objects/${
              parentId.split('_')[2]
            }`;

        nodeRow
          .querySelector('.table-row')
          .setAttribute('data-observe-next-wp', true);
        nodeRow
          .querySelector('.table-row')
          .setAttribute('data-observe-node', nodeParam);
        nodeRow
          .querySelector('.table-row')
          .setAttribute('data-observe-offset', offset);
      }

      nodeRow
        .querySelector('.title')
        .setAttribute(
          'title',
          titleContent.isMixed ? titleContent.derivedString : titleContent.input
        );

      if (node.child_count == 0) {
        nodeRow.querySelector('.expandme').style.visibility = 'hidden';
        nodeRow.querySelector('.expandme').setAttribute('aria-hidden', 'true');
      } else if (node.child_count > 0) {
        nodeRow
          .querySelector('.table-row')
          .setAttribute('data-has-expanded', false);
        nodeRow
          .querySelector('.table-row')
          .setAttribute('data-is-expanded', false);
        nodeRow
          .querySelector('.expandme')
          .setAttribute('aria-expanded', 'false');
      }

      nodeRow.querySelector('.sr-only').textContent = titleContent.isMixed
        ? titleContent.derivedString
        : titleContent.input;

      if (node.has_digital_instance) {
        const iconHtml = `<i class="has_digital_instance fa fa-file-image-o" aria-hidden="true"></i>`;
        nodeRow
          .querySelector('.record-title')
          .insertAdjacentHTML('beforebegin', iconHtml);
      }

      nodeRow.querySelector('.record-title').setAttribute('href', aHref);

      if (titleContent.isMixed) {
        nodeRow.querySelector('.record-title').innerHTML = titleContent.input;
      } else {
        nodeRow.querySelector('.record-title').textContent = titleContent.input;
      }

      nodeRowFrag.appendChild(nodeRow);

      return nodeRowFrag;
    }

    /**
     * Append the empty set of waypoint containers belonging to a node after
     * the node element; append elements manually because `insertAdjacentElement()`
     * @param {number} nodeId - Div id of the parent node whose waypoints
     * are being scaffolded
     * @param {number} level - Level of the waypoints
     * @param {number} numWPs - Number of waypoints to create
     * @todo - refactor to use DocumentFragment and <template>
     */
    nodeWaypointsScaffold(nodeId, level, numWPs) {
      for (let i = 0; i < numWPs; i++) {
        const prevSibling =
          i === 0
            ? document.querySelector(`#${nodeId}`)
            : document.querySelector(
                `[data-parent-id="${nodeId}"][data-waypoint-number="${i - 1}"]`
              );

        const tableRowGroup = document.createElement('div');
        tableRowGroup.className = 'table-row-group';
        tableRowGroup.setAttribute('data-parent-id', nodeId);
        tableRowGroup.setAttribute('data-waypoint-number', i);
        tableRowGroup.setAttribute('data-waypoint-level', level);
        tableRowGroup.setAttribute('role', 'list');

        const tableRow = document.createElement('div');
        tableRow.className = `table-row waypoint indent-level-${level}`;

        prevSibling.insertAdjacentElement('afterend', tableRowGroup);

        const liveTableRowGroup = document.querySelector(
          `[data-parent-id="${nodeId}"][data-waypoint-number="${i}"]`
        );

        liveTableRowGroup.appendChild(tableRow);
      }
    }

    /**
     * Populate an empty waypoint with nodes
     * @param {HTMLElement} waypoint - The empty waypoint to populate
     * @param {array} nodes - Array of node objects to populate the waypoint with
     * @param {number} level - Level of the waypoint
     * @param {boolean} hasNextEmptyWP - Whether or not there is a next empty waypoint
     */
    populateWaypoint(waypoint, nodes, level, hasNextEmptyWP) {
      const waypointMarker = waypoint.querySelector('.waypoint');
      const nodeRowsFrag = new DocumentFragment();

      waypointMarker.classList.add('populated');

      nodes.forEach((node, i) => {
        // observe the middle node if there is a next empty waypoint
        const observeThisNode =
          i == Math.floor(this.WAYPOINT_SIZE / 2) - 1 && hasNextEmptyWP;
        const markupArgs = [node, level, observeThisNode];

        if (observeThisNode) {
          const nodeParentId = waypoint.getAttribute('data-parent-id');
          const nodeWpNum = parseInt(
            waypoint.getAttribute('data-waypoint-number'),
            10
          );

          markupArgs.push(nodeParentId, nodeWpNum + 1);
        }

        nodeRowsFrag.appendChild(this.nodeRowMarkup(...markupArgs));
      });

      waypoint.appendChild(nodeRowsFrag);
    }

    /**
     * IntersectionObserver callback for populating the next empty waypoint
     * @param {IntersectionObserverEntry[]} entries - Array of entries
     * @param {IntersectionObserver} observer - The observer instance
     */
    waypointScrollHandler(entries, observer) {
      entries.forEach(async entry => {
        if (entry.isIntersecting) {
          const thisWaypoint = entry.target.closest('.table-row-group');

          if (!this.nextSiblingIsNextWaypoint(thisWaypoint)) {
            return;
          }

          const node = entry.target.getAttribute('data-observe-node');
          const offset = entry.target.getAttribute('data-observe-offset');
          const nodes = await this.fetchWaypoint({ node, offset });
          const level = thisWaypoint.getAttribute('data-waypoint-level');
          const nextWaypoint = thisWaypoint.nextElementSibling;
          const nextWPHasNextWP = this.nextSiblingIsNextWaypoint(nextWaypoint);

          this.populateWaypoint(nextWaypoint, nodes, level, nextWPHasNextWP);

          if (nextWPHasNextWP) {
            const nextWPTarget = nextWaypoint.querySelector(
              '[data-observe-next-wp="true"]'
            );

            observer.observe(nextWPTarget);
          }

          entry.target.removeAttribute('data-observe-next-wp');
          entry.target.removeAttribute('data-observe-node');
          entry.target.removeAttribute('data-observe-offset');

          observer.unobserve(entry.target);
        }
      });
    }

    /**
     * Determine if the next sibling of the given waypoint is the next
     * empty waypoint of the same parent
     * @param {HTMLElement} waypoint - The waypoint with a possible next sibling
     * @returns {boolean} - True if the next sibling is the next empty waypoint
     * of the same parent
     */
    nextSiblingIsNextWaypoint(waypoint) {
      const nextSibling = waypoint.nextElementSibling;

      if (!nextSibling) {
        return false;
      }

      const thisWpParent = waypoint.getAttribute('data-parent-id');
      const thisWpNum = parseInt(
        waypoint.getAttribute('data-waypoint-number'),
        10
      );

      const nextSiblingParent = nextSibling.getAttribute('data-parent-id');
      const nextSiblingWpNum = parseInt(
        nextSibling.getAttribute('data-waypoint-number'),
        10
      );
      const nextSiblingFirstChild = nextSibling.firstElementChild;

      return (
        nextSiblingParent == thisWpParent &&
        nextSiblingWpNum == thisWpNum + 1 &&
        nextSiblingFirstChild.classList.contains('waypoint') &&
        !nextSiblingFirstChild.classList.contains('populated')
      );
    }

    /**
     * Handle click events on expandme buttons or their child icons
     * @param {Event} e - Click event
     */
    expandHandler(e) {
      const node = e.target.closest('.largetree-node');
      const button =
        e.target.className === 'expandme'
          ? e.target
          : e.target.closest('.expandme');
      const icon = e.target.classList.contains('expandme-icon')
        ? e.target
        : e.target.querySelector('.expandme-icon');

      button.setAttribute(
        'aria-expanded',
        button.getAttribute('aria-expanded') === 'true' ? 'false' : 'true'
      );

      icon.classList.toggle('expanded');

      node.setAttribute(
        'data-is-expanded',
        button.getAttribute('aria-expanded')
      );

      if (node.getAttribute('data-has-expanded') === 'false') {
        this.initNodeChildren(node.id);

        node.setAttribute('data-has-expanded', true);
      }
    }

    /**
     * Build the title of a node
     * @param {Object} node - Node data
     * @returns {string} - Title of the node
     */
    nodeTitle(node) {
      const title = [];

      if (SHOW_IDENTIFIERS_IN_TREE && node.identifier && node.parsed_title) {
        title.push(`${node.identifier}${this.i18n.sep} ${node.parsed_title}`);
      } else if (node.parsed_title) {
        title.push(node.parsed_title);
      }

      if (node.label) {
        title.push(node.label);
      }

      if (node.dates && node.dates.length > 0) {
        node.dates.forEach(date => {
          if (date.expression) {
            if (date.type === 'bulk') {
              title.push(`${this.i18n.bulk}: ${date.expression}`);
            } else {
              title.push(date.expression);
            }
          } else if (date.begin && date.end) {
            if (date.type === 'bulk') {
              title.push(`${this.i18n.bulk}: ${date.begin}-${date.end}`);
            } else {
              title.push(`${date.begin}-${date.end}`);
            }
          } else if (date.begin) {
            if (date.type === 'bulk') {
              title.push(`${this.i18n.bulk}: ${date.begin}`);
            } else {
              title.push(date.begin);
            }
          }
        });
      }

      return title.join(', ');
    }
  }

  exports.InfiniteTree = InfiniteTree;
})(window);