APIs

Show:
/**
  @module ember-flexberry
*/

import $ from 'jquery';
import Mixin from '@ember/object/mixin';
import { get, set, computed } from '@ember/object';
import { A, isArray } from '@ember/array';
import { assert } from '@ember/debug';
import { typeOf, isNone } from '@ember/utils';
import { getOwner } from '@ember/application';

// Validates DOM-event options.
// Not a mixin member, so yuidoc-comments are unnecessary.
let validateDomEventOptions = function(options, eventIsAttaching) {
  options = options || {};
  let eventTarget = get(options, 'eventTarget');
  let eventName = get(options, 'eventName');
  let eventHandler = get(options, 'eventHandler');

  assert(
    `Wrong type of \`options.eventTarget\` property: ` +
    `actual type is \`${typeOf(eventTarget)}\`, but \`object\` or \`instance\` is expected.`,
    typeOf(eventTarget) === 'object' || typeOf(eventTarget) === 'instance');

  let methodName = eventIsAttaching ? 'on' : 'off';
  assert(
    `Wrong type of \`options.eventTarget.{$methodName}\` property: ` +
    `actual type is \`${typeOf(eventTarget[methodName])}\`, but \`function\` is expected.`,
    typeOf(eventTarget[methodName]) === 'function');

  assert(
    `Wrong type of \`options.eventName\` property: ` +
    `actual type is \`${typeOf(eventName)}\`, but \`string\` is expected.`,
    typeOf(eventName) === 'string');

  assert(
    `Wrong type of \`options.eventHandler\` property: ` +
    `actual type is \`${typeOf(eventHandler)}\`, but \`function\` is expected.`,
    typeOf(eventHandler) === 'function');
};

/**
  Mixin containing logic making available to handle DOM-events related to component
  as inner & outer component's actions.
  Also contains methods to attach custom handlers for DOM-events with delayed clean up logic
  which will be executed before component will be destroyed.

  @class DomActionsMixin
  @extends <a href="https://www.emberjs.com/api/ember/release/classes/Mixin">Mixin</a>
*/
export default Mixin.create({
  /**
    Dictionary containing names of DOM-events available for component's wrapping DOM-element
    and component's actions names related to them.

    @property _availableDomEvents
    @type Object
    @readOnly
    @private
  */
  _availableDomEvents: computed(function() {
    // Code from Component 'init' hook to get DOM-events available for component.
    let eventDispatcher = getOwner(this).lookup('event_dispatcher:main');

    return eventDispatcher && eventDispatcher._finalEvents || {};
  }),

  /**
    Array with objects containing selected event targets & handlers attached to them.
    Each object in array has following structure: {
      eventTarget: ...,
      attachedEventHandlers: {
        click: [function() {}, function() {}, ...],
        dblclick: [function() {}, function() {}, ...],
        ...
      }
    }.

    @property _eventsMetadata
    @type Object[]
    @default null
    @private
   */
  _eventsMetadata: null,

  /**
    Flag: indicates whether component is tagless or not
    (has empty [tagName](https://emberjs.com/api/ember/release/classes/Component#property_tagName) or not).

    @property isTagless
    @type Boolean
    @readOnly
   */
  isTagless: computed('tagName', function() {
    let tagName = this.get('tagName');
    if (typeOf(tagName) === 'string') {
      tagName = $.trim(tagName);
    }

    return tagName === '';
  }),

  /**
    Attaches event handler for a given event-target.
    Also remembers attached handlers and related event-targets for clean up logic
    which will be executed before component will be destroyed, so use this method instead of
    explicit calls to [jQuery.on method](http://api.jquery.com/on/) to avoid possible memory leaks.

    @method attachEventHandler
    @param {Object} options Method options.
    @param {Object} options.eventTarget DOM-element ([jQuery-object](http://api.jquery.com/Types/#jQuery))
    for which event-handler must be attached.
    @param {String} options.eventName Event name.
    @param {Function} options.eventHandler Event handler.
  */
  attachEventHandler(options) {
    validateDomEventOptions(options, true);
    let eventTarget = get(options, 'eventTarget');
    let eventName = get(options, 'eventName');
    let eventHandler = get(options, 'eventHandler');

    // Find event-metadata for a given DOM-element.
    let eventsMetadata = this.get('_eventsMetadata');
    let eventMetadata = eventsMetadata.filter((metadata) => {
      return metadata.eventTarget === eventTarget;
    })[0];

    // Create new metadata if necessary.
    if (isNone(eventMetadata)) {
      eventMetadata = {
        eventTarget: eventTarget
      };

      eventsMetadata.pushObject(eventMetadata);
    }

    let attachedEventHandlers = get(eventMetadata, 'attachedEventHandlers');
    if (isNone(attachedEventHandlers)) {
      attachedEventHandlers = {};
      set(eventMetadata, 'attachedEventHandlers', attachedEventHandlers);
    }

    let specifiedEventAttachedEventHandlers = get(attachedEventHandlers, eventName);
    if (!isArray(specifiedEventAttachedEventHandlers)) {
      specifiedEventAttachedEventHandlers = A();
      set(attachedEventHandlers, eventName, specifiedEventAttachedEventHandlers);
    }

    if (!specifiedEventAttachedEventHandlers.includes(eventHandler)) {
      // Store given event-handler.
      specifiedEventAttachedEventHandlers.pushObject(eventHandler);

      // Finally attach event-handler for given event-target.
      eventTarget.on(eventName, eventHandler);
    }
  },

  /**
    Detaches event-handler from a given event-target.
    Also cleans up metadata remembered when event-handler was attached through
    {{#crossLink "DomActionsMixin/attachEventHandler:method"}}'attachEventHandler' method {{/crossLink}}.

    @method detachEventHandler
    @param {Object} options Method options.
    @param {Object} options.eventTarget DOM-element ([jQuery-object](http://api.jquery.com/Types/#jQuery))
    from which event-handler must be detached.
    @param {String} options.eventName Event name.
    @param {Function} options.eventHandler Event handler.
  */
  detachEventHandler(options) {
    validateDomEventOptions(options, false);
    let eventTarget = get(options, 'eventTarget');
    let eventName = get(options, 'eventName');
    let eventHandler = get(options, 'eventHandler');

    // Detach event-handler from given DOM-element.
    eventTarget.off(eventName, eventHandler);

    // Clean up metadata.
    let eventsMetadata = this.get('_eventsMetadata');
    let eventMetadata = eventsMetadata.filter((metadata) => {
      return metadata.eventTarget === eventTarget;
    })[0];

    if (isNone(eventMetadata)) {
      return;
    }

    // Get object (keys - events names, values - arrays with attached handlers).
    let attachedEventHandlers = get(eventMetadata, 'attachedEventHandlers');
    if (!isNone(attachedEventHandlers)) {
      // Get handlers array for specified event-name.
      let specifiedEventAttachedEventHandlers = get(attachedEventHandlers, eventName);

      if (isArray(specifiedEventAttachedEventHandlers) && specifiedEventAttachedEventHandlers.includes(eventHandler)) {
        // Remove handler from metadata.
        specifiedEventAttachedEventHandlers.removeObject(eventHandler);

        // Remove whole array if there is no handlers any more.
        if (specifiedEventAttachedEventHandlers.length === 0) {
          delete attachedEventHandlers[eventName];
        }
      }
    }

    // Remove whole metadata for specified DOM-element if there is no handlers any more.
    if (isNone(attachedEventHandlers) || Object.keys(attachedEventHandlers).length === 0) {
      eventsMetadata.removeObject(eventMetadata);
    }
  },

  /**
    Detaches all event-handlers remembered in metadata when event-handlers were attached through
    {{#crossLink "DomActionsMixin/attachEventHandler:method"}}'attachEventHandler' method {{/crossLink}}.

    @method detachAllEventHandlers
  */
  detachAllEventHandlers() {
    let eventsMetadata = this.get('_eventsMetadata');
    var metadataCount = get(eventsMetadata, 'length');
    while (--metadataCount >= 0) {
      let eventMetadata = eventsMetadata[metadataCount];

      let eventTarget = get(eventMetadata, 'eventTarget');
      let attachedEventHandlers = get(eventMetadata, 'attachedEventHandlers');
      if (isNone(attachedEventHandlers)) {
        eventsMetadata.removeObject(eventMetadata);
        break;
      }

      let eventNames = Object.keys(attachedEventHandlers);
      for (let i = 0, len = eventNames.length; i < len; i++) {
        let eventName = eventNames[i];
        let specifiedEventAttachedEventHandlers = get(attachedEventHandlers, eventName);

        if (!isArray(specifiedEventAttachedEventHandlers)) {
          delete attachedEventHandlers[eventName];
          break;
        }

        var handlersCount = get(specifiedEventAttachedEventHandlers, 'length');
        while (--handlersCount >= 0) {
          this.detachEventHandler({
            eventTarget: eventTarget,
            eventName: eventName,
            eventHandler: specifiedEventAttachedEventHandlers[handlersCount]
          });
        }
      }
    }
  },

  /**
    Overridden ['send' method](https://github.com/emberjs/ember.js/blob/v2.7.0/packages/ember-runtime/lib/mixins/action_handler.js#L145).
    Method logic still the same, but method now returns value returned by the action handler.

    ```
    @method send
    @param {String} actionName Name of the action that must be triggered.
    @param {*} args Arguments that must be passed to action.
    @return {any} Returns value returned by action handler.
  */
  send(actionName, ...args) {
    // Modified code of 'send'-method from original Ember action-handler mixin.
    let actionShouldBubble;
    let actionHandlerResult;
    let actionHandler = this.get(`actions.${actionName}`);

    if (typeOf(actionHandler) === 'function') {
      actionHandlerResult = actionHandler.apply(this, args);

      // If inner action founded, bubble only if it returned 'true'.
      actionShouldBubble = actionHandlerResult === true;
    } else {
      // If inner action wasn't found, then bubble is necessary.
      actionShouldBubble = true;
    }

    if (actionShouldBubble) {
      let target = this.get('target');
      if (!isNone(target)) {
        assert(
          `The \`target\` (${target}) for ${this} doesn\`t have a \`send\` method`,
          typeOf(this.get('target.send')) === 'function');

        target.send(...arguments);
      }
    }

    // It is the only one 'send'-logic modification:
    // 'send' method now returns value returned by founded inner action.
    // It is necessary when DOM-events are handling like inner & outer actions,
    // to break outer action sending, if inner action handler returned 'false'.
    return actionHandlerResult;
  },

  /**
    Initializes some mixin properties.
  */
  init() {
    this._super(...arguments);

    // Initialize array-property for this component's instance.
    this.set('_eventsMetadata', A());
  },

  /**
    Initializes component's DOM-events related logic.
  */
  didInsertElement() {
    this._super(...arguments);

    // DOM-events are not available for tagless components.
    if (this.get('isTagless')) {
      return;
    }

    // Events names example: domEventName = 'dblclick', but componentActionName = 'doubleClick'.
    // See https://guides.emberjs.com/v2.4.0/components/handling-events/.
    let availableDomEvents = this.get('_availableDomEvents');
    let $component = this.$();
    let attachEventsActions = (domEventName, componentActionName) => {
      this.attachEventHandler({
        eventTarget: $component,
        eventName: domEventName,
        eventHandler: (...args) => {
          let canSendAction = true;

          // Trigger component's inner action handler.
          // (Check if action is defined in component to avoid assertion failed exception).
          if (typeOf(this.get(`actions.${componentActionName}`)) === 'function') {
            canSendAction = this.send(componentActionName, ...args) !== false;
          }

          // Trigger component's outer action if inner action handler doesn't return 'false'.
          if (canSendAction) {
            if (!isNone(this.get(componentActionName))) {
              this.get(componentActionName)(...args);
            }

          }
        }
      });
    };

    for (let domEventName in availableDomEvents) {
      if (!availableDomEvents.hasOwnProperty(domEventName)) {
        break;
      }

      let componentActionName = availableDomEvents[domEventName];
      if (typeOf(componentActionName) !== 'string') {
        break;
      }

      // Attach actions logic for every available DOM-event.
      attachEventsActions(domEventName, componentActionName);
    }
  },

  /**
    Executes some logic before every render.
  */
  willRender() {
    this._super(...arguments);

    // DOM-events are not available for tagless components.
    if (this.get('isTagless')) {
      return;
    }

    // Events names example: domEventName = 'dblclick', but componentActionName = 'doubleClick'.
    // See https://guides.emberjs.com/v2.4.0/components/handling-events/.
    let availableDomEvents = this.get('_availableDomEvents');

    // Remove component's standard DOM-events handlers, because they are useless,
    // they are replaced with component's inner actions in 'didInsertElement' hook.
    // Ember adds them in every component's render/rerender time, so we need to remove them each time,
    // otherwise DOM-actions will be triggered twice when they happen.
    for (let domEventName in availableDomEvents) {
      if (!availableDomEvents.hasOwnProperty(domEventName)) {
        break;
      }

      let componentActionName = availableDomEvents[domEventName];
      if (typeOf(componentActionName) !== 'string') {
        break;
      }

      this.set(componentActionName, undefined);
      delete this[componentActionName];
    }
  },

  /**
    Cleans up component's DOM-events related logic.
  */
  willDestroyElement() {
    this._super(...arguments);

    // DOM-events are not available for tagless components.
    if (this.get('isTagless')) {
      return;
    }

    this.detachAllEventHandlers();
  }
});