Home Reference Source

src/view/view.js

'use strict'

import Core from '../core/core.js'
import { eachEntry } from '../core/util.js'
import { Item } from '../model/item.js'
import { ItemBinder } from '../model/binder.js'

const HOOK_EVTS = ['click', 'submit', 'change'];

/**
* View
*/
class View extends Core {

  /**
   * Create a View.
   *
   * @param {object} [props] - Properties
   * @param {string|Element} [props.rootEl] - root element ID or root node
   * @param {Class<View>} [props.parent] - parent view this belongs to
   * @param {string|Element} [props.contentEl] - parent element of child views (specified by data-id or id value).
   */
  constructor(props = {}) {
    super();

    /**
     * Root element
     * @member {Node}
     */
    this.el = null;

    /**
     * Subview children of the view
     * @member {object}
     */
    this.views = {};

    const { rootEl, ...props_ } = props;
    this._build(rootEl, props_);
  }

  /** @override */
  _privates() {
    return Object.assign(super._privates(), {
      _F_onevts: new Set(),
      _F_binders: [],
      _F_elcache: new Map(),
      _F_emap: { _: {} },
    })
  }

  /**
   * Prepare data
   * 
   * @param {object} defaults - default data.
   * @return {object|Item} modified data or item.
   */
  prepareData(defaults) {
    return defaults
  }

  /**
   * For implement creating subviews and setting listener of events.
   * 
   * @param {object} views - added subview target (ex. views.list = new ListView(..))
   */
  load(views) {
  }

  /**
   * Ssetting listener of events.
   * 
   * @param {object} evts - added listener target (ex. evts.subview_event)
   */
  handle(evts) {
  }

  /**
   * For implement after loading completed
   */
  completed() {
  }

  /**
   * For implement unloading subviews
   */
  unload() {
  }

  /**
   * Cast a message to all children of view
   *
   * @param {string} method - method name
   * @param {Array} args - arguments
   */
  broadcast(method, args = []) {
    this._callR(args, 'views', method)
  }

  /**
   * _loaded resolve after all done
   */
  _build(root, props) {
    console.log('View#_build', root, props);

    const defaults = this._setupProps(props);
    this._data = this.prepareData(defaults);
    this._setRootNode(root, props.parent || null, this._data);
    if (this.el) this._assemble();
  }

  _setupProps(props) {
    let defaults;
    if (props) {
      // once detach data
      defaults = props.data;
      if ('data' in props) delete props.data;
      Object.assign(this, props);
    }
    return defaults;
  }

  /**
   * Set root node element to this.el
   */
  _setRootNode(root, parent, data) {
    if (this._isStr(root)) {
      root = parent ? parent.findEl(root) : document.getElementById(root);
      if (!root) {
        throw new Error(`Failed to create View because element not found ID: ${root}`);
      }
    }

    if (this.html) {
      this.el = this._buildFromHtml(data);
      if (parent) {
        // If this view doesn't belong to parent views
        parent.appendEl(this.el);
      } else if (root) {
        root.parentNode.replaceChild(this.el, root);
      }
    } else if (root) {
      this.el = root;
    }
  }

  _assemble() {
    console.log('View#_assemble', this);

    const ctn = this.contentEl;
    this.contentEl = this._isStr(ctn) ? this.findEl(ctn) : this.el;

    this._loadViewsEvts();
    this._setDataToUI();
    this._bindData();

    this.completed();
  }

  _loadViewsEvts() {
    this.load(this.views);

    eachEntry(this.views, ([name, view]) => {
      if (!view.el) {
        view.el = this.findEl(name);
        view._assemble();
      } else if (view.el.parentNode instanceof DocumentFragment) {
        const makerEl = this.findEl(name);
        if (makerEl) {
          // Embed view at marker if the node exists
          makerEl.parentNode.replaceChild(view.el, makerEl);
          view.el.dataset.id = name;
        } else {
          this.appendEl(view.el);
        }
      }
    });

    const evts = {}, emap = this._F_emap;
    this.handle(evts);
    this._parseEvts(evts, emap);

    // attach events to current views
    eachEntry(emap, ([target, hmap]) => {
      if (target === '_') {
        this._setEvts(this.el, hmap);
        return;
      }

      const el = this.findEl(target);
      if (!el) return;

      this._setEvts(el, hmap)
    })
  }

  _bindData() {
    const data = this._data;
    if (data instanceof Item) {
      this._F_binders.push(new ItemBinder(data, this));
    }
  }

  _unbindData() {
    const binder = this._F_binders.pop();
    if (binder) binder.destroy();
  }

  /**
   * data to elements text or value, innerHTML of elements
   *
   * @property {object} data
   * @example
   * view.data = { name: 'Mike', inputAge: { value: 24 }, message: { html: "<p>Hello!</p>" } };
   */
  get data() {
    return this._data
  }

  set data(value) {
    console.log('View#data=', this);
    this._data = this.prepareData(value);
    this._unbindData();
    this._setDataToUI();
    this._bindData();
  }

  /**
   * Find an element that has specified data-id else call getElementById
   *
   * @param id data-id value
   * @return {Element}
   * @example
   * view.findEl('elementDataId');
   */
  findEl(id) {
    const cached = this._F_elcache.get(id);
    if (cached && cached.parentNode) {
      return cached;
    }
    const result = this.el.querySelector(`[data-id="${id}"]`) || document.getElementById(id);
    this._F_elcache.set(id, result);
    return result;
  }

  /**
   * Append child element
   *
   * @param {Element} el - child element
   */
  appendEl(el) {
    this.contentEl.appendChild(el)
  }

  /**
   * Set child view as name after load
   *
   * @param {string} name - view name
   * @param {View} view - child view. remove if null
   * @example
   * parent.set('name', view);
   */
  set(name, view) {
    const t = this, curvw = t.views[name];
    if (curvw) {
      curvw.destroy();
      curvw.el.remove();
      delete t.views[name];
    }
    if (view) {
      t.views[name] = view;
      t.appendEl(view.el);
      t._setEvts(view.el, t._F_emap[name] || {})
    }
  }

  /**
   * Fire event
   *
   * @param {string} name - event name
   * @param {object} ctx - event context
   * @example
   * view.fire('move', { newPosition: 1 });
   */
  fire(name, ctx) {
    const e = ctx ? new CustomEvent(name, { detail: ctx, bubbles: true })
                : new Event(name, { bubbles: true });
    this.el.dispatchEvent(e);
  }

  /**
   * Destroy all chidren, unload, and destroy binder, teardown events
   */
  destroy() {
    this.unload();

    Object.values(this.views).forEach(it => it.destroy());
    this.views = {};
    this._F_elcache.clear();
    this._unbindData();
    this._teardownEvts();
    this.parent = null;
  }

  /**
   * Called when the binding data is updated.
   * 
   * @param {string} name field name
   * @param {*} newValue new data value
   * @param {*} oldValue old data value
   * @return {boolean} true if setting field value succeeded
   */
  update(name, newValue, oldValue) {
    return this._setFieldValue(name, newValue)
  }

  /**
   * Set data to subviews or elements in the view.
   */
  _setDataToUI() {
    const data = this._data;
    if (data === undefined) return;
    console.log('View#_setDataToUI', this);

    if (data instanceof Object) {
      eachEntry(data, ([name, val]) => this._setFieldValue(name, val))
    } else {
      this._setVal(this.el, data)
    }
  }

  _setFieldValue(name, val) {
    const it = this.views[name];
    if (it) {
      it.data = val;
      return true;
    }

    const el = this.findEl(name);
    if (!el) return false;

    this._setVal(el, val)
    return true;
  }

  // private

  _setVal(el, val) {
    if (el.dataset && el.dataset.type === 'html') {
      el.innerHTML = val;
    } else if ('value' in el) {
      el.value = val;
    } else {
      el.textContent = val;
    }
  }

  _parseEvts(evts, emap) {
    eachEntry(evts, ([name, handler]) => {
      const pos = name.lastIndexOf('_');
      if (pos === -1) {
        emap._[name] = handler;
        return;
      }

      // ex) button_click
      const target = name.substr(0, pos),
            ename = name.substr(pos + 1),
            hmap = emap[target];
      if (hmap) {
        hmap[ename] = handler;
      } else {
        emap[target] = { [ename]: handler };
      }
    })
  }

  /**
   * Bind targets with event handler set.
   *
   * @param {Element} el - event raiser.
   * @param {object} hmap - handler map. Object<type, value>
   */
  _setEvts(el, hmap) {
    const ts = el instanceof NodeList ? Array.from(el) : [el];
    eachEntry(hmap, ([type, handler]) => {
      ts.forEach(it => this._trapEvt(this, it, type, handler))
    })
  }

  /**
   * Bind target event with handler.
   *
   * @param {View} root - caller
   * @param {Element} el - event raiser
   * @param {string} type - event type
   * @param {function} handler - callback function
   */
  _trapEvt(root, el, type, handler) {
    const hook = function(e) {
      let rb;
      if (handler.constructor.name === 'AsyncFunction') {
        handler.call(root, this, e).catch(err => {
          console.error(err)
        }).then(r => {
          if (!r) e.preventDefault()
        })
      } else {
        try {
          rb = handler.call(root, this, e)
        } catch(err) {
          console.error(err)
        }
      }
      return rb !== undefined ? rb : false
    };

    if (HOOK_EVTS.includes(type)) {
      el['on'+type] = hook;
    } else {
      el.addEventListener(type, hook);
    }
    this._F_onevts.add([el, type, hook]);
  }

  _teardownEvts() {
    const onevts = this._F_onevts;
    onevts.forEach(([el, type, hook]) => {
      if (HOOK_EVTS.includes(type)) {
        el['on'+type] = null;
      } else {
        el.removeEventListener(type, hook);
      }
    });
    onevts.clear();
  }

  _buildFromHtml(data) {
    const el = document.createElement('template');
    try {
      el.innerHTML = this.html(data || {})
    } catch(err) {
      console.error(err)
    }
    const df = document.adoptNode(el.content);
    return this._firstEl(df);
  }

  _firstEl(el) {
    const fec = el.firstElementChild;
    if (fec !== undefined) return fec;

    // for Safari, Edge
    const nodes = el.childNodes;
    for (let i = 0, len = nodes.length; i < len; i++) {
      const child = nodes[i];
      if (child.nodeType === Node.ELEMENT_NODE) return child;
    }
    return null;
  }
}

export default View;