APIs

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

import Helper from '@ember/component/helper';
import { assert } from '@ember/debug';
import { typeOf, isBlank, isNone } from '@ember/utils';
import { A, isArray } from '@ember/array';
import { get, set } from '@ember/object';
import { addObserver, removeObserver } from '@ember/object/observers';
import DynamicActionObject from '../objects/dynamic-action';
import { render } from '../utils/string';
import { getRecord } from '../utils/extended-get';

// Validates helper's properties.
// Not a helper member, so yuidoc-comments are unnecessary.
let validateHelperProperties = function(args, hash) {
  assert(
    'Exactly two unnamed arguments must be passed to `get-with-dynamic-actions` helper.',
    args.length === 2);

  let propertyOwner = args[0];
  assert(
    `Wrong type of \`get-with-dynamic-actions\` helper\`s first argument: ` +
    `actual type is \`${typeOf(propertyOwner)}\`, but \`object\` or \`instance\` is expected.`,
    typeOf(propertyOwner) === 'object' || typeOf(propertyOwner) === 'instance');

  let propertyName = args[1];
  assert(
    `Wrong type of \`get-with-dynamic-actions\` helper\`s second argument: ` +
    `actual type is \`${typeOf(propertyName)}\`, but \`string\` is expected.`,
    typeOf(propertyName) === 'string');

  let hierarchyPropertyName = get(hash, 'hierarchyPropertyName');
  assert(
    `Wrong type of \`get-with-dynamic-actions\` helper\`s \`hierarchyPropertyName\` property: ` +
    `actual type is \`${typeOf(hierarchyPropertyName)}\`, but \`string\` is expected.`,
    isNone(hierarchyPropertyName) || typeOf(hierarchyPropertyName) === 'string');

  let dynamicActions = get(hash, 'dynamicActions');
  assert(
    `Wrong type of \`get-with-dynamic-actions\` helper\`s \`dynamicActions\` property: ` +
    `actual type is \`${typeOf(dynamicActions)}\`, but \`array\` is expected.`,
    isNone(dynamicActions) || isArray(dynamicActions));
};

// Returns new action arguments array where template-like substrings in string arguments
// are replaced with related context's properties.
// Not a helper member, so yuidoc-comments are unnecessary.
let getRenderedDynamicActionArguments = function(actionArguments, renderingContext) {
  if (!isArray(actionArguments)) {
    return A();
  }

  // Some action arguments could be a strings with following substrings (templates) inside them: {% path %}.
  // We should replace these templates with context's related properties (current object's path instead of {% path %}, etc.),
  // before arguments will be binded to dynamic actions.
  let renderedActionArguments = [];
  for (let i = 0, len = actionArguments.length; i < len; i++) {
    let actionArgument = actionArguments[i];
    if (typeOf(actionArgument) === 'string') {
      renderedActionArguments[i] = render(actionArgument, {
        context: renderingContext,
        delimiters: ['{%', '%}']
      });
    } else {
      renderedActionArguments[i] = actionArgument;
    }
  }

  return renderedActionArguments;
};

/**
  Get with dynamic actions helper.
  Retrieves property with the specified name from the specified object
  (exactly as [standard get helper](https://www.emberjs.com/api/ember/release/classes/Ember.Templates.helpers/methods/get?anchor=get) does),
  and additionally binds dynamic actions for retrieved property's nested object/objects
  (even if retrieved property has hierarchical structure).

  Helper must be used with those component's,
  which consumes their inner structure as [JSON-object](http://www.json.org/)
  or [EmberObject](https://emberjs.com/api/ember/release/classes/EmberObject)
  and there is no way to attach action handlers for their nested component's explicitly in hbs-markup.

  @class GetWithDynamicActionsHelper
  @extends <a href="https://emberjs.com/api/ember/release/classes/Helper">Helper</a>

  Usage:
  templates/my-form.hbs
  ```handlebars
  {{!-- Without custom "path" keyword (default keyword "path" will be used) --}}
  {{flexberry-tree
    nodes=(get-with-dynamic-actions this "treeNodes"
      hierarchyPropertyName="nodes"
      dynamicActions=(array
        (hash
          on="headerClick"
          actionName="onTreenodeHeaderClick"
          actionArguments=(array "{% path %}")
        )
        (hash
          on="checkboxChange"
          actionName="onTreenodeCheckboxChange"
          actionArguments=(array "{% path %}.checkboxValue")
        )
      )
    )
  }}

  {{!-- With custom "path" keyword --}}
  {{flexberry-tree
    nodes=(get-with-dynamic-actions this "treeNodes"
      hierarchyPropertyName="nodes"
      pathKeyword="nodePath"
      dynamicActions=(array
        (hash
          on="headerClick"
          actionName="onTreenodeHeaderClick"
          actionArguments=(array "{% nodePath %}")
        )
        (hash
          on="checkboxChange"
          actionName="onTreenodeCheckboxChange"
          actionArguments=(array "{% nodePath %}.checkboxValue")
        )
      )
    )
  }}
  ```

  controllers/my-form.js
  ```javascript
  import Controller from '@ember/controller';
  import TreeNodeObject from 'ember-flexberry/objects/flexberry-treenode';

  export default Controller.extend({
    treeNodes: A([
      FlexberryTreenodeObject.create({
        caption: 'Node 1 (with child nodes)',
        hasCheckbox: true,
        checkboxValue: false,
        iconClass: 'map icon',
        nodes: A([
          caption: 'Node 1.1 (leaf node)',
          hasCheckbox: true,
          checkboxValue: false,
          iconClass: 'apple icon',
          nodes: null
        ])
      }),
      FlexberryTreenodeObject.create({
        caption: 'Node 2 (leaf node)',
        hasCheckbox: true,
        checkboxValue: false,
        iconClass: 'compass icon',
        nodes: null
      })
    ]),

    actions: {
      onTreenodeHeaderClick(...args) {
        let treeNodePath = args[0];
        let treeNodeHeaderClickActionEventObject = args[args.length - 1];

        console.log('Clicked tree node\'s properties:', this.get(treeNodePath));
        console.log('Clicked tree node\'s \'headerClick\' action event object:', treeNodeHeaderClickActionEventObject);
      },

      onTreenodeCheckboxChange(...args) {
        let treeNodeCheckboxValuePath = args[0];
        let treeNodeCheckboxChangeActionEventObject = args[args.length - 1];

        // Change tree node's 'checkboxValue' property.
        this.set(treeNodeCheckboxValuePath, treeNodeCheckboxChangeActionEventObject.newValue);
      }
    }
  });
  ```
*/
export default Helper.extend({
  /**
    Owner of hierarchy root property.

    @property _rootPropertyOwner
    @type Object
    @default null
    @private
  */
  _rootPropertyOwner: null,

  /**
    Name of those property which must be retrieved from owner & returned from helper
    (after dynamic actions binding).

    @property _rootPropertyName
    @type String
    @private
  */
  _rootPropertyName: null,

  /**
    Name of nested property (inside retrieved one), through which
    child object/objects (objects hierarchy) is/are available.

    @property _hierarchyPropertyName
    @type String
    @private
  */
  _hierarchyPropertyName: null,

  /**
    Dynamic actions, which will be binded to object/objects retrieved from the
    {{#crossLink "GetWithDynamicActionsHelper/_rootPropertyOwner:property"}}owner{{/crossLink}}
    (and to all nested child objects).

    @property _dynamicActions
    @type DynamicActionObject[]
    @private
  */
  _dynamicActions: null,

  /**
    Array with objects containing names of hierarchy properties and observer handlers related to them.
    Each object in array has following structure: { propertyPath: '...', observerHandler: function() { ... } }.

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

  /**
    Adds observer for given hierarchy property, which will force helper to recompute
    on any changes in the specified hierarchy property.

    @method _addHierarchyPropertyObserver
    @param {String} propertyPath Path to observable property.
    @private
  */
  _addHierarchyPropertyObserver(propertyPath) {
    let hierarchyPropertiesObservers = this.get('_hierarchyPropertiesObservers');
    if (isNone(hierarchyPropertiesObservers)) {
      hierarchyPropertiesObservers = A();
      this.set('_hierarchyPropertiesObservers', hierarchyPropertiesObservers);
    }

    let observer = hierarchyPropertiesObservers.find((observer) => {
      return get(observer, 'propertyPath') === propertyPath;
    });
    if (isNone(observer)) {
      observer = {
        propertyPath: propertyPath,
        referenceObserver: null,
        arrayObserver: null
      };
      hierarchyPropertiesObservers.pushObject(observer);
    }

    let arrayObserver = {
      /* eslint-disable no-unused-vars */
      arrayWillChange: (arr, start, removeCount, addCount) => {
        // Remove observers from removing properties (while properties still exists).
        if (removeCount !== 0) {
          for (let i = start; removeCount > 0 && i < start + removeCount; i++) {
            let removingPropertyPath = `${propertyPath}.${i}`;

            this._removeHierarchyPropertiesObservers({
              propertyPathStartsWith: removingPropertyPath,
              status: 'Delete'
            });
          }
        } else {
          for (let i = start; i < start + 2; i++) {
            let replacePropertyPath = `${propertyPath}.${i}`;

            this._removeHierarchyPropertiesObservers({
              propertyPathStartsWith: replacePropertyPath,
              status: 'Replace'
            });
          }
        }

      },
      /* eslint-enable no-unused-vars */

      /* eslint-disable no-unused-vars */
      arrayDidChange: (arr, start, removeCount, addCount) => {
        // Bind dynamic actions for added property, and rebind for following ones (till the end of array), or
        // rebind dynamic actions for properties following after removed ones (till the end of array).
        for (let i = start, len = arr.length; i < len; i++) {
          let addedOrMovedPropertyPath = `${propertyPath}.${i}`;
          let addedOrMovedPropertyValue = getRecord(this, `_rootPropertyOwner.${addedOrMovedPropertyPath}`);

          this._bindDynamicActions({
            propertyPath: addedOrMovedPropertyPath,
            propertyValue: addedOrMovedPropertyValue,

            // Don't observe properties containing inside arrays,
            // it's unnecessary, because whole array has special observer.
            propertyIsObservable: false
          });
        }
      }
      /* eslint-enable no-unused-vars */
    };

    let referenceObserver = () => {
      let rootPropertyOwner = this.get('_rootPropertyOwner');
      let propertyValue = getRecord(rootPropertyOwner, propertyPath);

      // Property-object reference changed.
      // Remove old observers.
      this._removeHierarchyPropertiesObservers({
        propertyPathStartsWith: propertyPath
      });

      // Bind dynamic actions for new property-object.
      // It will add new observers.
      this._bindDynamicActions({
        propertyValue: propertyValue,
        propertyPath: propertyPath
      });
    };

    let rootPropertyOwner = this.get('_rootPropertyOwner');
    let propertyValue = getRecord(rootPropertyOwner, propertyPath);

    // Observe property reference.
    if (isNone(get(observer, 'referenceObserver'))) {
      set(observer, 'referenceObserver', referenceObserver);
      addObserver(rootPropertyOwner, propertyPath, referenceObserver);
    }

    // Observe property content.
    if (isArray(propertyValue) && isNone(get(observer, 'arrayObserver'))) {
      set(observer, 'arrayObserver', arrayObserver);
      propertyValue.addArrayObserver(arrayObserver);
    }
  },

  /**
    Removes all previously attached to hierarchy properties observers.

    @method _removeHierarchyPropertiesObservers
    @param {Object} options Method options.
    @param {String} options.propertyPath Path to property from which observers must be removed.
    @param {String} options.propertyPathStartsWith Prefix of path to properties from which observers must be removed.
    @private
  */
  _removeHierarchyPropertiesObservers(options) {
    options = options || {};
    let propertyPath =  get(options, 'propertyPath');
    let propertyPathStartsWith = get(options, 'propertyPathStartsWith');
    let observerCanBeRemoved = function(observer) {
      let observerPropertyPath = get(observer, 'propertyPath');

      return isNone(propertyPath) && isNone(propertyPathStartsWith) ||
        typeOf(propertyPath) === 'string' && observerPropertyPath === propertyPath ||
        typeOf(propertyPathStartsWith) === 'string' && observerPropertyPath.indexOf(propertyPathStartsWith) === 0;
    };

    let rootPropertyOwner = this.get('_rootPropertyOwner');
    let hierarchyPropertiesObservers = this.get('_hierarchyPropertiesObservers');
    let len = hierarchyPropertiesObservers.length;
    hierarchyPropertiesObservers = this._sort(hierarchyPropertiesObservers);
    while (--len >= 0) {
      let observer = hierarchyPropertiesObservers[len];
      if (!observerCanBeRemoved(observer)) {
        continue;
      }

      let observerPropertyPath = get(observer, 'propertyPath');
      let observerPropertyValue = getRecord(rootPropertyOwner, observerPropertyPath);

      let status = get(options, 'status');
      if (status === 'Delete') {
        this._renamePropertyPath(observerPropertyPath, len);
      }

      let referenceObserver = get(observer, 'referenceObserver');
      removeObserver(rootPropertyOwner, observerPropertyPath, referenceObserver);

      let arrayObserver = get(observer, 'arrayObserver');
      if (isArray(observerPropertyValue)) {
        observerPropertyValue.removeArrayObserver(arrayObserver);
      }

      hierarchyPropertiesObservers.removeAt(len);
    }

    if (hierarchyPropertiesObservers.length === 0) {
      this.set('_hierarchyPropertiesObservers', null);
    }
  },

  /**
    Renames property path of observers.

    @method _renamePropertyPath
    @param {String} observerPropertyPath Path to property from which observers must be removed.
    @param {Number} len Index of observer in the array to be removed.
    @private
  */
  _renamePropertyPath(observerPropertyPath, len) {
    let hierarchyPropertiesObservers = this.get('_hierarchyPropertiesObservers');
    let rootPropertyOwner = this.get('_rootPropertyOwner');
    let observerPropertyPathArray = observerPropertyPath.split('.');

    if (observerPropertyPathArray.length >= 3) {

      let arrayForSubstring = observerPropertyPathArray.slice(0, observerPropertyPathArray.length - 2);
      let checkSubstring = arrayForSubstring.join('.');

      for (let i = len + 1; i < hierarchyPropertiesObservers.length; i++) {
        let observer = hierarchyPropertiesObservers[i];

        let updatableObserverPropertyPath = get(observer, 'propertyPath');
        let updatableObserverPropertyPathArray = updatableObserverPropertyPath.split('.');
        let newObserverPropertyPath = updatableObserverPropertyPathArray[0];

        for (let j = 1; j < updatableObserverPropertyPathArray.length; j++) {
          if ((j === (observerPropertyPathArray.length - 2)) && (checkSubstring === newObserverPropertyPath)) {
            newObserverPropertyPath += '.' + (updatableObserverPropertyPathArray[j] - 1).toString();

            let observerPropertyValue = getRecord(rootPropertyOwner, updatableObserverPropertyPath);
            let referenceObserver = get(observer, 'referenceObserver');
            removeObserver(rootPropertyOwner, updatableObserverPropertyPath, referenceObserver);

            let arrayObserver = get(observer, 'arrayObserver');
            if (isArray(observerPropertyValue)) {
              observerPropertyValue.removeArrayObserver(arrayObserver);
            }

          } else {
            newObserverPropertyPath += '.' + updatableObserverPropertyPathArray[j];
          }
        }

        set(observer, 'propertyPath', newObserverPropertyPath);
      }
    }
  },

  /**
    Sort array with hierarchy properties observers.

    @method _sort
    @param {Array} hierarchyPropertiesObservers Array with hierarchy properties observers.
    @private
  */
  _sort(hierarchyPropertiesObservers) {

    let sortArray = hierarchyPropertiesObservers.sort(function (a, b) {
      if (a.propertyPath > b.propertyPath) {
        return 1;
      }

      if (a.propertyPath < b.propertyPath) {
        return -1;
      }

      return 0;
    });
    return sortArray;
  },

  /**
    Binds given dynamic actions to every object in the specified hierarchy.

    @method _bindDynamicActions
    @param {Object|Object[]} propertyValue Value of some property in the specified hierarchy.
    @param {String} propertyPath Path to property in the specified hierarchy.
    @param {String} Name of property that leads to nested child properties in the specified hierarchy.
    @param {DynamicAction[]} Specified dynamic actions.
    @private
  */
  _bindDynamicActions(options) {
    options = options || {};
    let propertyValue = get(options, 'propertyValue');
    let propertyPath = get(options, 'propertyPath');
    let propertyIsObservable = get(options, 'propertyIsObservable');

    let hierarchyPropertyName = this.get('_hierarchyPropertyName');
    let dynamicActions = this.get('_dynamicActions');

    if (propertyIsObservable !== false) {
      // Add property observer to force helper recompute, if some new objects appear/remove in hierarchy.
      this._addHierarchyPropertyObserver(`${propertyPath}`);
    }

    if (isNone(propertyValue)) {
      return;
    }

    // If property is array, then attach dynamic actions for each object in array.
    if (isArray(propertyValue)) {
      for (let i = 0, len = propertyValue.length; i < len; i++) {
        this._bindDynamicActions({
          propertyValue: propertyValue.objectAt(i),
          propertyPath: `${propertyPath}.${i}`,

          // Don't observe properties containing inside arrays,
          // it's unnecessary, because whole array has special observer.
          propertyIsObservable: false
        });
      }

      return;
    }

    // Here 'propertyValue' is strict an object or an instance.
    assert(
      `Wrong type of \`${propertyPath}\` property retrieved through \`get-with-dynamic-actions\` helper: ` +
      `actual type is \`${typeOf(propertyValue)}\`, but \`object\` or \`instance\` is expected.`,
      typeOf(propertyValue) === 'object' || typeOf(propertyValue) === 'instance');

    // Prepare & add dynamic actions for current 'propertyValue' (which is always object or instance here).
    if (isArray(dynamicActions)) {
      let preparedDynamicActions = A();

      for (let i = 0, len = dynamicActions.length; i < len; i++) {
        let dynamicAction = dynamicActions[i];

        let argumentsRenderingContext = {};
        argumentsRenderingContext[this.get('_pathKeyword')] = propertyPath;

        // Helper shouldn't mutate given data, so we need to create new dynamic action with modified properties.
        let preparedDynamicAction = DynamicActionObject.create({
          on: get(dynamicAction, 'on'),
          actionHandler: get(dynamicAction, 'actionHandler'),
          actionName: get(dynamicAction, 'actionName'),

          // Use 'rootPropertyOwner' as action context (if context is not defined explicitly).
          actionContext: get(dynamicAction, 'actionContext') || this.get('_rootPropertyOwner'),

          // Perform template substitutions inside action arguments.
          actionArguments: getRenderedDynamicActionArguments(
            get(dynamicAction, 'actionArguments'),
            argumentsRenderingContext)
        });

        preparedDynamicActions.pushObject(preparedDynamicAction);
      }

      set(propertyValue, 'dynamicActions', preparedDynamicActions);
    }

    // Recursively bind dynamic actions to nested child properties.
    if (!isBlank(hierarchyPropertyName) && typeOf(hierarchyPropertyName) === 'string') {
      this._bindDynamicActions({
        propertyValue: get(propertyValue, hierarchyPropertyName),
        propertyPath: `${propertyPath}.${hierarchyPropertyName}`
      });
    }
  },

  /**
    Overridden [Helper compute method](https://emberjs.com/api/ember/release/classes/Helper#method_compute).
    Executes helper's logic, returns helper's value.

    @method compute
    @param {any[]} args [Helper arguments](https://guides.emberjs.com/v2.4.0/templates/writing-helpers/#toc_helper-arguments).
    @param {Object} args.0 Property owner, object containing property which must be retrieved & returned from helper
    (after dynamic action binding).
    Will be also used as action handlers context (if context wasn't specified explicitly in dynamic action bindings).
    @param {String} args.1 Name of those property which must be retrieved from owner & returned from helper
    (after dynamic actions binding).
    Owner's property (behind this name) must be an object or array of objects.
    @param {Object} [hash] Object containing
    [helper's named arguments](https://guides.emberjs.com/v2.4.0/templates/writing-helpers/#toc_named-arguments).
    @param {Object} [hash.hierarchyPropertyName] Name of nested property (inside retrieved one), through which
    child object/objects (objects hierarchy) will be available.
    @param {DynamicActionObject[]} [hash.dynamicActions] Dynamic actions, which will be binded
    to object/objects retrieved from the owner (and to all nested child objects).
    @return {Object|Object[]} Retrieved object/objects with binded dynamic actions (including nested child objects).
  */
  compute(args, hash) {
    validateHelperProperties(args, hash);

    let propertyOwner = args[0];
    let propertyName = args[1];
    let hierarchyPropertyName = get(hash, 'hierarchyPropertyName');
    let pathKeyword = get(hash, 'pathKeyword') || 'path';
    let dynamicActions = get(hash, 'dynamicActions');

    this.set('_rootPropertyOwner', propertyOwner);
    this.set('_rootPropertyName', propertyName);
    this.set('_hierarchyPropertyName', hierarchyPropertyName);
    this.set('_pathKeyword', pathKeyword);
    this.set('_dynamicActions', dynamicActions);

    let propertyValue = get(propertyOwner, propertyName);
    this._bindDynamicActions({
      propertyValue: propertyValue,
      propertyPath: propertyName,
    });

    return propertyValue;
  },

  /**
    Destroys helper.
  */
  willDestroy() {
    this._super(...arguments);

    this._removeHierarchyPropertiesObservers();
    this.set('_rootPropertyOwner', null);
    this.set('_dynamicActions', null);
  }
});