component.js

import cuid from 'cuid';
import WebComponent from 'webcomponent';

import {EMPTY_DIV, DOMPatcher, h} from './dom-patcher';
import Router from './router';
import * as hookHelpers from './component-utils/hook-helpers';

const DOCUMENT_FRAGMENT_NODE = 11;
const ATTR_TYPE_DEFAULTS = {
  string: ``,
  boolean: false,
  number: 0,
  json: null,
};

/**
 * Definition of a Panel component/app, implemented as an HTML custom element.
 * App logic and configuration is defined by extending this class. Instantiating
 * a component is typically not done by calling the constructor directly, but
 * either by including the tag in HTML markup, or by using the DOM API method
 * [document.createElement]{@link https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement}.
 *
 * @example <caption>Defining a Panel component</caption>
 * class MyWidget extends Component {
 *   get config() {
 *     return {
 *       // options go here
 *     };
 *   }
 *
 *   myMethod() {
 *     // etc
 *   }
 * }
 *
 * @example <caption>Registering the custom element definition for the DOM</caption>
 * customElements.define('my-widget', MyWidget);
 *
 * @example <caption>Adding an instance of the element to the DOM</caption>
 * <my-widget some-attr></my-widget>
 *
 * @extends WebComponent
 */
class Component extends WebComponent {
  /**
   * Defines standard component configuration.
   * @type {object}
   * @property {function} template - function transforming state object to virtual dom tree
   * @property {object} [helpers={}] - properties and functions injected automatically into template state object
   * @property {object} [routes={}] - object mapping string route expressions to handler functions
   * @property {object} [appState={}] - (app root component only) state object to share with nested descendant components;
   * if not set, root component shares entire state object with all descendants
   * @property {object} [defaultState={}] - default entries for component state
   * @property {object} [hooks={}] - extra rendering/lifecycle callbacks
   * @property {function} [hooks.preUpdate] - called before an update is applied
   * @property {function} [hooks.postUpdate] - called after an update is applied
   * @property {boolean} [updateSync=false] - whether to apply updates to DOM
   * immediately, instead of batching to one update per frame
   * @property {boolean} [useShadowDom=false] - whether to use Shadow DOM
   * @property {string} [css=''] - component-specific Shadow DOM stylesheet
   * @example
   * get config() {
   *   return {
   *     template: state => h('.name', `My name is ${name}`),
   *     routes: {
   *       'wombat/:wombatId': (stateUpdate={}, wombatId) => {
   *         // route handler implementation
   *       },
   *     },
   *   };
   * }
   */
  get config() {
    return {};
  }

  /**
   * Template helper functions defined in config object, and exposed to template code
   * as $helpers. This getter uses the component's internal config cache.
   * @type {object}
   * @example
   * {
   *   myHelper: () => 'some return value',
   * }
   */
  get helpers() {
    return this.getConfig(`helpers`);
  }

  /**
   * For use inside view templates, to create a child Panel component nested under this
   * component, which will share its state object and update cycle.
   * @param {string} tagName - the HTML element tag name of the custom element
   * to be created
   * @param {object} [config={}] - snabbdom node config (second argument of h())
   * @returns {object} snabbdom vnode
   * @example
   * {template: state => h('.header', this.child('my-child-widget'))}
   */
  child(tagName, config = {}) {
    config.props = Object.assign({}, config.props, {
      $panelParentID: this.panelID,
    });
    return h(tagName, config);
  }

  /**
   * Searches the component's Panel ancestors for the first component of the
   * given type (HTML tag name).
   * @param {string} tagName - tag name of the parent to search for
   * @returns {object} Panel component
   * @throws Throws an error if no parent component with the given tag name is found.
   * @example
   * myWidget.findPanelParentByTagName('my-app');
   */
  findPanelParentByTagName(tagName) {
    tagName = tagName.toLowerCase();
    for (let node = this.$panelParent; node; node = node.$panelParent) {
      if (node.tagName.toLowerCase() === tagName) {
        return node;
      }
    }
    throw Error(`${tagName} not found`);
  }

  /**
   * Fetches a value from the component's configuration map (a combination of
   * values supplied in the config() getter and defaults applied automatically).
   * @param {string} key - key of config item to fetch
   * @returns value associated with key
   * @example
   * myWidget.getConfig('css');
   */
  getConfig(key) {
    return this._config[key];
  }

  /**
   * Executes the route handler matching the given URL fragment, and updates
   * the URL, as though the user had navigated explicitly to that address.
   * @param {string} fragment - URL fragment to navigate to
   * @param {object} [stateUpdate={}] - update to apply to state object when
   * routing
   * @example
   * myApp.navigate('wombat/54', {color: 'blue'});
   */
  navigate() {
    this.$panelRoot.router.navigate(...arguments);
  }

  /**
   * Helper function which will queue a function to be run once the component has been
   * initialized and added to the DOM. If the component has already had its connectedCallback
   * run, the function will run immediately.
   *
   * It can optionally return a function to be enqueued to be run just before the component is
   * removed from the DOM. This occurs during the disconnectedCallback lifecycle.
   * @param {function} fn - callback to be run after the component has been added to the DOM. If this
   * callback returns another function, the returned function will be run when the component disconnects from the DOM.
   * @example
   * myApp.onConnected(() => {
   *   const handleResize = () => calculateSize();
   *   document.body.addEventListener(`resize`, handleResize);
   *   return () => document.body.removeEventListener(`resize`, handleResize);
   * });
   */
  onConnected(fn) {
    if (this.initialized) {
      this._maybeEnqueueResult(fn.call(this));
    }
    this._connectedQueue.push(fn);
  }

  _maybeEnqueueResult(result) {
    if (result && typeof result === `function`) {
      result.removeAfterExec = true;
      this._disconnectedQueue.push(result);
    }
  }

  /**
   * Helper function which will queue a function to be run just before the component is
   * removed from the DOM. This occurs during the disconnectedCallback lifecycle.
   *
   * @param {function} fn - callback to be run just before the component is removed from the DOM
   * @example
   * connectedCallback() {
   *   const shiftKeyListener = () => {
   *     if (ev.keyCode === SHIFT_KEY_CODE) {
   *       const doingRangeSelect = ev.type === `keydown` && this.isMouseOver && this.lastSelectedRowIdx !== null;
   *       if (this.state.doingRangeSelect !== doingRangeSelect) {
   *         this.update({doingRangeSelect});
   *       }
   *     }
   *   }
   *   document.body.addEventListener(`keydown`, shiftKeyListener);
   *   this.onDisconnected(() => {
   *     document.body.removeEventListener(`keydown`, shiftKeyListener);
   *   });
   * }
   */
  onDisconnected(fn) {
    this._disconnectedQueue.push(fn);
  }

  /**
   * Sets a value in the component's configuration map after element
   * initialization.
   * @param {string} key - key of config item to set
   * @param val - value to associate with key
   * @example
   * myWidget.setConfig('template', () => h('.new-template', 'Hi'));
   */
  setConfig(key, val) {
    this._config[key] = val;
  }

  /**
   * To be overridden by subclasses, defining conditional logic for whether
   * a component should rerender its template given the state to be applied.
   * In most cases this method can be left untouched, but can provide improved
   * performance when dealing with very many DOM elements.
   * @param {object} state - state object to be used when rendering
   * @returns {boolean} whether or not to render/update this component
   * @example
   * shouldUpdate(state) {
   *   // don't need to rerender if result set ID hasn't changed
   *   return state.largeResultSetID !== this._cachedResultID;
   * }
   */
  // eslint-disable-next-line no-unused-vars
  shouldUpdate(state) {
    return true;
  }

  /**
   * Applies a state update, triggering a re-render check of the component as
   * well as any other components sharing the same state. This is the primary
   * means of updating the DOM in a Panel application.
   * @param {object|function} [stateUpdate={}] - keys and values of entries to update in
   * the component's state object
   * @example
   * myWidget.update({name: 'Bob'});
   */
  update(stateUpdate = {}) {
    const stateUpdateResult = typeof stateUpdate === `function` ? stateUpdate(this.state) : stateUpdate;
    return this._updateStore(stateUpdateResult, {
      store: `state`,
      cascade: this.isStateShared,
    });
  }

  /**
   * Applies a state update specifically to app state shared across components.
   * In apps which don't specify `appState` in the root component config, all
   * state is shared across all parent and child components and the standard
   * update() method should be used instead.
   * @param {object} [stateUpdate={}] - keys and values of entries to update in
   * the app's appState object
   * @example
   * myWidget.updateApp({name: 'Bob'});
   */
  updateApp(stateUpdate = {}) {
    return this._updateStore(stateUpdate, {store: `appState`, cascade: true});
  }

  constructor() {
    super();

    this.panelID = cuid();

    this._connectedQueue = [];
    this._disconnectedQueue = [];

    this._attrs = {};
    this._syncAttrs(); // constructor sync ensures default properties are present on this._attrs

    this._config = Object.assign(
      {},
      {
        css: ``,
        helpers: {},
        routes: {},
        template: () => {
          throw Error(`No template provided by Component subclass`);
        },
        updateSync: false,
        useShadowDom: false,
      },
      this.config,
    );

    // initialize shared state store, either in `appState` or default to `state`
    // appState and isStateShared of child components will be overwritten by parent/root
    // when the component is connected to the hierarchy
    this.state = Object.assign({}, this.getConfig(`defaultState`));
    this.appState = this.getConfig(`appState`);

    if (!this.appState) {
      this.appState = {};
      this.isStateShared = true;
    } else {
      this.isStateShared = false;
    }

    if (this.getConfig(`useShadowDom`)) {
      this.el = this.attachShadow({mode: `open`});
      this.styleTag = document.createElement(`style`);
      this.styleTag.innerHTML = this.getConfig(`css`);
      this.el.appendChild(this.styleTag);
    } else if (this.getConfig(`css`)) {
      throw Error(`"useShadowDom" config option must be set in order to use "css" config.`);
    } else {
      this.el = this;
    }
  }

  connectedCallback() {
    if (this.initialized) {
      return;
    }

    // Prevent re-entrant calls to connectedCallback.
    // This can happen in some (probably erroneous) cases with Firefox+polyfills.
    if (this.initializing) {
      return;
    }
    this.initializing = true;

    this.$panelChildren = new Set();

    if (typeof this.$panelParentID !== `undefined`) {
      this.isPanelChild = true;
      // find $panelParent
      for (let node = this.parentNode; node && !this.$panelParent; node = node.parentNode) {
        if (node.nodeType === DOCUMENT_FRAGMENT_NODE) {
          // handle shadow-root
          node = node.host;
        }
        if (node.panelID === this.$panelParentID) {
          this.$panelParent = node;
          this.$panelRoot = node.$panelRoot;
        }
      }
      if (!this.$panelParent) {
        throw Error(`panelParent ${this.$panelParentID} not found`);
      }
      this.$panelParent.$panelChildren.add(this);

      // share either appState or all of state
      // flush any queued appState changes
      this.appState = Object.assign(this.$panelRoot.appState, this.appState);

      // if child element state is shared, point
      // state to parent's state object and flush any
      // queued state changes to the parent state
      this.isStateShared = this.$panelRoot.isStateShared;
      if (this.isStateShared) {
        this.state = Object.assign(this.$panelRoot.state, this.state);
      }
    } else {
      this.isPanelRoot = true;
      this.$panelRoot = this;
      this.$panelParent = null;
    }
    this.app = this.$panelRoot;

    Object.assign(this.state, this.getJSONAttribute(`data-state`), this._stateFromAttributes());

    if (Object.keys(this.getConfig(`routes`)).length) {
      this.router = new Router(this, {historyMethod: this.historyMethod});
      this.navigate(window.location.hash);
    }

    this.domPatcher = new DOMPatcher(this.state, this._render.bind(this), {
      updateMode: this.getConfig(`updateSync`) ? `sync` : `async`,
    });
    this.el.appendChild(this.domPatcher.el);

    for (let i = 0; i < this._connectedQueue.length; i++) {
      const connectedCallbackFn = this._connectedQueue[i];
      try {
        this._maybeEnqueueResult(connectedCallbackFn.call(this));
      } catch (err) {
        console.warn(`error running onConnected function`, err);
      }
    }

    this.initialized = true;
    this.initializing = false;
  }

  disconnectedCallback() {
    if (!this.initialized) {
      return;
    }

    for (let i = 0; i < this._disconnectedQueue.length; i++) {
      const disconnectedCallbackFn = this._disconnectedQueue[i];
      try {
        disconnectedCallbackFn.call(this);
      } catch (err) {
        console.warn(`error running onDisconnected function`, err);
      }
    }

    this._disconnectedQueue = this._disconnectedQueue.filter((fn) => !fn.removeAfterExec);

    if (this.router) {
      this.router.unregisterListeners();
    }

    if (this.$panelParent) {
      this.$panelParent.$panelChildren.delete(this);
    }

    if (this.domPatcher) {
      this.el.removeChild(this.domPatcher.el);
      this.domPatcher.disconnect();
    }

    this.domPatcher = null;
    this._rendered = null;
    this.initialized = false;

    // if a child component is added via child() and has keys, snabbdom uses parentEl.insertBefore
    // which disconnects the element and immediately connects it at another position.
    // usually the child's disconnectedCallback is called before the parent's
    // but in that case the parents are removed from dom before the children
    // which causes a $panelParent not found exception for the grandchildren.
    // we clean up parent references in an async manner so we can handle that situation.
    Promise.resolve().then(() => {
      // only clear references if element hasn't been re-initialized
      if (!this.initialized) {
        this.$panelRoot = null;
        this.$panelParent = null;
        this.appState = null;
        this.app = null;
      }
    });
  }

  /**
   * Attributes schema that defines the component's html attributes and their types
   * Panel auto parses attribute changes into attrs() object and $attr template helper
   *
   * @typedef {object} AttrSchema
   * @prop {'string' | 'number' | 'boolean' | 'json'} type - type of the attribute
   *       if not set, the attr parser will interpret it as 'string'
   * @prop {string} default - value if the attr is not defined
   * @prop {number} description - description of the attribute, what it does e.t.c
   *
   * @type {Object.<string, AttrSchema>}
   */
  static get attrsSchema() {
    return {};
  }

  static get observedAttributes() {
    return [`style-override`].concat(Object.keys(this.attrsSchema));
  }

  attributeChangedCallback(attr, oldVal, newVal) {
    this._updateAttr(attr);

    if (attr === `style-override`) {
      this._applyStyles(newVal);
    }

    if (this.initialized) {
      this.update();
    }
  }

  _applyStyles(styleOverride) {
    if (this.getConfig(`useShadowDom`)) {
      this.styleTag.innerHTML = this.getConfig(`css`) + (styleOverride || ``);
    }
  }

  _logError() {
    console.error(...arguments);
  }

  toString() {
    try {
      return `${(this.tagName || ``).toLowerCase()}#${this.panelID}`;
    } catch (e) {
      return `UNKNOWN COMPONENT`;
    }
  }

  _render(state) {
    if (this.shouldUpdate(state)) {
      try {
        this._rendered = this.getConfig(`template`).call(
          this,
          Object.assign({}, state, {
            $app: this.appState,
            $component: this,
            $helpers: this.helpers,
            $attr: this.attr.bind(this),
            $hooks: hookHelpers,
          }),
        );
      } catch (error) {
        this._logError(`Error while rendering`, this, `\n`, error);
        this.dispatchEvent(
          new CustomEvent(`renderError`, {
            detail: {error, component: this},
            bubbles: true,
            composed: true,
          }),
        );
      }
    }
    return this._rendered || EMPTY_DIV;
  }

  // run a user-defined hook with the given params, if configured
  // cascade down tree hierarchy if option is set
  runHook(hookName, options, ...params) {
    if (!this.initialized) {
      return;
    }

    const hook = (this.getConfig(`hooks`) || {})[hookName];
    if (hook) {
      hook(...params);
    }
    if (options.cascade) {
      for (const child of this.$panelChildren) {
        if (options.exclude !== child) {
          child.runHook(hookName, options, ...params);
        }
      }
    }
  }

  _stateFromAttributes() {
    const state = {};

    // this.attributes is a NamedNodeMap, without normal iterators
    for (let ai = 0; ai < this.attributes.length; ai++) {
      const attr = this.attributes[ai];
      const attrMatch = attr.name.match(/^state-(.+)/);
      if (attrMatch) {
        const num = Number(attr.value);
        state[attrMatch[1]] = isNaN(num) ? attr.value : num;
      }
    }

    return state;
  }

  /**
   * Validates attrsSchema and syncs element attributes defined in attrsSchema
   */
  _syncAttrs() {
    // maintain local validated map where all schema keys are defined
    this._attrsSchema = {};
    const attrsSchema = this.constructor.attrsSchema;

    for (const attr of Object.keys(attrsSchema)) {
      // convert type shorthand to object
      let attrSchema = attrsSchema[attr];
      if (typeof attrSchema === `string`) {
        attrSchema = {type: attrSchema};
      }

      // Ensure attr type is valid
      const attrType = attrSchema.type;
      if (!ATTR_TYPE_DEFAULTS.hasOwnProperty(attrType)) {
        throw new Error(
          `Invalid type: ${attrType} for attr: ${attr} in attrsSchema. ` +
            `Only (${Object.keys(ATTR_TYPE_DEFAULTS)
              .map((v) => `'${v}'`)
              .join(` | `)}) is valid.`,
        );
      }

      const attrSchemaObj = {
        type: attrType,
        default: attrSchema.hasOwnProperty(`default`) ? attrSchema.default : ATTR_TYPE_DEFAULTS[attrType],
      };

      // convert enum to a set for perf
      if (attrSchema.hasOwnProperty(`enum`)) {
        const attrEnum = attrSchema.enum;
        if (!Array.isArray(attrEnum)) {
          throw new Error(`Enum not an array for attr: ${attr}`);
        }

        const enumSet = new Set(attrEnum);
        enumSet.add(attrSchema.default);
        attrSchemaObj.enumSet = enumSet;
      }

      this._attrsSchema[attr] = attrSchemaObj;
      this._updateAttr(attr);
      // updated at end so we don't console.warn on initial sync
      attrSchemaObj.deprecatedMsg = attrSchema.deprecatedMsg;
    }

    return this._attrs;
  }

  /**
   * Parses html attribute using type information from attrsSchema and updates this._attrs
   * @param {string} attr - attribute name
   */
  _updateAttr(attr) {
    const attrsSchema = this._attrsSchema;
    if (attrsSchema.hasOwnProperty(attr)) {
      const attrSchema = attrsSchema[attr];
      const attrType = attrSchema.type;
      let attrValue = null;

      if (!this.hasAttribute(attr)) {
        if (attrType === `boolean` && attrSchema.default) {
          console.warn(
            `${this}: attr '${attr}' is defaulted to true. Default-true boolean attributes are deprecated and will be removed in a future release`,
          );
        }
        attrValue = attrSchema.default;
      } else if (attrType === `string`) {
        attrValue = this.getAttribute(attr);
        const enumSet = attrSchema.enumSet;

        if (enumSet && !enumSet.has(attrValue)) {
          throw new Error(
            `Invalid value: '${attrValue}' for attr: ${attr}. ` +
              `Only (${Array.from(enumSet)
                .map((v) => `'${v}'`)
                .join(` | `)}) is valid.`,
          );
        }
      } else if (attrType === `boolean`) {
        attrValue = this.isAttributeEnabled(attr);
      } else if (attrType === `number`) {
        attrValue = this.getNumberAttribute(attr);
      } else if (attrType === `json`) {
        attrValue = this.getJSONAttribute(attr);
      }

      this._attrs[attr] = attrValue;

      if (attrSchema.deprecatedMsg) {
        console.warn(`${this}: attr '${attr}' is deprecated. ${attrSchema.deprecatedMsg}`);
      }
    }
  }

  /**
   * gets the parsed value of an attribute
   * @param {string} attr - attribute name
   */
  attr(attr) {
    if (attr in this._attrs) {
      return this._attrs[attr];
    } else {
      throw new TypeError(`${this}: attr '${attr}' is not defined in attrsSchema`);
    }
  }

  /**
   * Returns the parsed attrs as a key-value POJO
   * @returns {object} parsed attribute values from attrsSchema
   */
  attrs() {
    return this._attrs;
  }

  // update helpers

  // Update a given state store (this.state or this.appState), with option
  // to 'cascade' the update across other linked components
  _updateStore(stateUpdate, options = {}) {
    const {cascade, store} = options;
    if (!this.initialized) {
      // just update store without patching DOM etc
      Object.assign(this[store], stateUpdate);
    } else {
      // update DOM, router, descendants etc.
      const updateHash = `$fragment` in stateUpdate && stateUpdate.$fragment !== this[store].$fragment;
      const cascadeFromRoot = cascade && !this.isPanelRoot;
      const updateOptions = {cascade, store};
      const rootOptions = {exclude: this, cascade, store};

      this.runHook(`preUpdate`, updateOptions, stateUpdate);
      if (cascadeFromRoot) {
        this.$panelRoot.runHook(`preUpdate`, rootOptions, stateUpdate);
      }

      this.updateSelfAndChildren(stateUpdate, updateOptions);
      if (cascadeFromRoot) {
        this.$panelRoot.updateSelfAndChildren(stateUpdate, rootOptions);
      }
      if (updateHash) {
        this.router.replaceHash(this[store].$fragment);
      }

      this.runHook(`postUpdate`, updateOptions, stateUpdate);
      if (cascadeFromRoot) {
        this.$panelRoot.runHook(`postUpdate`, rootOptions, stateUpdate);
      }
    }
  }

  // Apply the given update down the component hierarchy from this node,
  // optionally excluding one node's subtree. This is useful for applying
  // a full state update to one component while sending only "shared" state
  // updates to the app root.
  updateSelfAndChildren(stateUpdate, options = {}) {
    if (!this.initialized) {
      return;
    }

    const {store, cascade} = options;
    Object.assign(this[store], stateUpdate);
    if (store !== `state` || this.shouldUpdate(this[store])) {
      this.domPatcher.update(this.state);

      if (cascade) {
        for (const child of this.$panelChildren) {
          if (options.exclude !== child) {
            child.updateSelfAndChildren(stateUpdate, options);
          }
        }
      }
    }
  }
}

export default Component;