wb/mgr/MgrLearn.js

/**
 * @copyright 2016 Tridium, Inc. All Rights Reserved.
 */

/*jshint browser:true*/

/**
 * A mixin type, used to add learn functionality to a Manager instance.
 *
 * @module nmodule/webEditors/rc/wb/mgr/MgrLearn
 */
define([ 'baja!',
        'log!nmodule.webEditors.rc.wb.mgr.MgrLearn',
        'bajaux/commands/CommandGroup',
        'bajaux/mixin/mixinUtils',
        'Promise',
        'underscore',
        'nmodule/webEditors/rc/fe/baja/util/DepthSubscriber',
        'nmodule/webEditors/rc/wb/mgr/Manager',
        'nmodule/webEditors/rc/wb/mgr/MgrLearnTableSupport',
        'nmodule/webEditors/rc/wb/mgr/mgrUtils',
        'nmodule/webEditors/rc/wb/mgr/commands/AddCommand',
        'nmodule/webEditors/rc/wb/mgr/commands/CancelDiscoverCommand',
        'nmodule/webEditors/rc/wb/mgr/commands/DiscoverCommand',
        'nmodule/webEditors/rc/wb/mgr/commands/LearnModeCommand',
        'nmodule/webEditors/rc/wb/mgr/commands/MatchCommand',
        'nmodule/webEditors/rc/wb/mgr/commands/MgrCommand',
        'nmodule/webEditors/rc/wb/mgr/commands/QuickMatchCommand',
        'nmodule/webEditors/rc/wb/mgr/commands/SelectAllCommand',
        'nmodule/webEditors/rc/wb/mgr/commands/ShowExistingCommand',
        'nmodule/webEditors/rc/wb/mgr/commands/QuickAddCommand',
        'nmodule/webEditors/rc/wb/table/tree/TreeTable' ], function (
        baja,
        log,
        CommandGroup,
        mixinUtils,
        Promise,
        _,
        DepthSubscriber,
        Manager,
        mixinLearnTableSupport,
        mgrUtils,
        AddCommand,
        CancelDiscoverCommand,
        DiscoverCommand,
        LearnModeCommand,
        MatchCommand,
        MgrCommand,
        QuickMatchCommand,
        SelectAllCommand,
        ShowExistingCommand,
        QuickAddCommand,
        TreeTable) {

  'use strict';

  const { applyMixin } = mixinUtils;
  const { findCommand, findCommands, toMgrTypeInfos } = mgrUtils;
  const logError = log.severe.bind(log);
  const MIXIN_NAME = 'MGR_LEARN';

  const { ALL, LEARN_CONTEXT_MENU } = MgrCommand.flags;

  /**
   * API Status: **Development**
   *
   * A mixin to provide learn support to a bajaux manager view.
   *
   * To support discovery, in addition to applying this mixin, the target manager object must
   * provide several functions that this mixin will use to accomplish the discovery and the
   * creation of new components from the discovered items.
   *
   * The concrete manager must provide a `makeLearnModel()` method. This should return a
   * `Promise` that will resolve to a `TreeTableModel`. This will be used as the data model
   * for the discovery table. On completion of the discovery job, the manager should use the
   * result of the job to insert items into the discovery model.
   *
   * The concrete manager must also provide an implementation of a `doDiscover()` function
   * that will create a job (typically by invoking an action that will submit a job
   * and return the ord), and then set the job on the manager via the `setJob()` function.
   * This function will accept the job instance or the ord for a job, specified either as
   * a `baja.Ord` or a string.
   *
   * Once the job is complete, a 'jobcomplete' tinyevent will be emitted on the manager. The
   * concrete manager will also typically have a handler for that event, which will get the
   * discovered items from the job by some means, and then update the discovery table. This
   * will normally involve inserting nodes into the learn model. The manager may store arbitrary
   * data on those nodes, which it may retrieve later via the node's `value()` function.
   *
   * The manager must also implement a `getTypesForDiscoverySubject()` function. This will be called
   * when dragging an item from the discovery table to the database table or invoking the 'add'
   * command. The function may be called several times, each time its argument will be a
   * `TreeNode` representing the item to be added into to the database table. The implementation
   * of this function is expected to return a single `MgrTypeInfo` instance or any array of them.
   * These will be used to create a new component instance of the required type for the discovered
   * node.
   *
   * Also to support the addition of new components, the manager should implement a function
   * called `getProposedValuesFromDiscovery()`. This will be passed the tree node that was dragged
   * from the discovery table to the database table. The function should obtain any information
   * the manager had set on the node at discovery time and use it to create an object containing
   * the initial values for the new component. The names of properties on the object returned by
   * the function will be compared against the column names in the main database model. For the
   * columns that have matching names, the values of those properties will be used to set the
   * initial proposed values on the new row(s) when the dialog for editing the new instances is
   * displayed.
   *
   * @alias module:nmodule/webEditors/rc/wb/mgr/MgrLearn
   * @mixin
   * @extends module:nmodule/webEditors/rc/wb/mgr/Manager
   * @param {module:nmodule/webEditors/rc/wb/mgr/Manager} target
   * @param {Object} params parameter to be passed down to provide additional information
   *
   * @example
   * <caption>Add the MgrLearn mixin to a Manager subclass to add learn
   * functionality.</caption>
   * require([...'nmodule/webEditors/rc/wb/mgr/MgrLearn'], function (...MgrLearn) {
   *   function MyManager() {
   *     Manager.apply(this, arguments);
   *     MgrLearn(this);
   *   }
   *   MyManager.prototype = Object.create(Manager.prototype);
   *
   *   //implement abstract functions
   *   MyManager.prototype.doDiscover = function () { ...
   * });
   */
  const MgrLearn = function MgrLearn(target, params) {
    if (!(target instanceof Manager)) {
      throw new Error('target must be a Manager instance.');
    }

    if (!applyMixin(target, MIXIN_NAME, MgrLearn.prototype)) {
      return this;
    }

    params = _.defaults(params || {}, {
      tableCtor: TreeTable
    });

    const superToContextMenuCommandGroup = target.toContextMenuCommandGroup;
    const superDoDestroy = target.doDestroy;

    /**
     * Extension of the manager's doDestroy() function. This will clean up
     * the subscription to the discovery job, if it is present.
     *
     * @returns {*|Promise}
     */
    target.doDestroy = function () {
      if (target.$jobSubDepth) { delete target.$jobSubDepth; }

      return unsubscribeJob(target)
        .then(() => superDoDestroy.apply(target, arguments));
    };

    target.toContextMenuCommandGroup = function (e) {
      const isLearnTable = e.currentTarget.classList.contains('mgr-discovery-row');
      if (isLearnTable) {
        return Promise.resolve(target.getLearnTableCommands())
          .then((commands) => new CommandGroup({ commands }));
      }

      return superToContextMenuCommandGroup.apply(this, arguments);
    };

    // Automatically mix in support for the learn table, so the target
    // manager does not need to do that themselves (or know about that
    // module...)

    mixinLearnTableSupport(target, {
      tableCtor: params.tableCtor
    });
  };

  /**
   * Abstract method used to obtain the model for the learn tree table. This
   * should return a `TreeTableModel`, or a Promise that resolves to one.
   *
   * @abstract
   * @returns {Promise.<module:nmodule/webEditors/rc/wb/table/tree/TreeTableModel>} a
   * tree table model that will be used by the manager's discovery table.
   */
  MgrLearn.prototype.makeLearnModel = function () {
    throw new Error('makeLearnModel() function not implemented.');
  };

  /**
   * Abstract method used to initiate the discovery process. What this
   * implementation does is a matter for the concrete manager, but the typical
   * pattern will be to invoke an Action that will submit a job, and then set
   * that job or its Ord on the manager via the `#setJob()` function.
   *
   * @abstract
   * @returns {Promise|*} Optionally return a Promise
   */
  MgrLearn.prototype.doDiscover = function () {
    throw new Error('doDiscover() function not implemented.');
  };

  /**
   * Abstract method to get the Component type(s) that could be created for the
   * given discovery node when adding it to the station as a component. If
   * returning an array, the first element of the array should be the type that
   * represents the best mapping for the discovery item.
   *
   * @abstract
   * @param {*} discovery a discovery object
   * @returns {Promise.<string|baja.Type|Array>} a Promise that can resolve to a string,
   * a baja Type or an array which can be used to `make` MgrTypeInfos.
   */
  MgrLearn.prototype.getTypesForDiscoverySubject = function (discovery) {
    throw new Error('getTypesForDiscoverySubject() function not implemented.');
  };

  /**
   * Abstract method to get the initial values for a discovered node when it is
   * being added to the station as a new component. This method should return an
   * Object instance, with the values to be used by the new instances. The
   * returned object may have a property called 'name', which will be used to
   * set the slot name of the new component. It may also have a child object
   * named 'values'. Each property of this object with a name that matches the
   * name of a `Column` in the main table model will have that property's value
   * used as the initial value when the component editor is displayed.
   *
   * The second argument is the corresponding value in the station database table. If using
   * the Add command, this will be the brand-new instance about to added to the
   * database. If using the Match command, this will be the existing instance already
   * in the database.
   *
   * @example
   * <caption>Return the initial values for the component name, and the
   * 'version' and 'address' columns</caption>
   * MyDeviceMgr.prototype.getProposedValuesFromDiscovery = function (discovery, component) {
     *   return {
     *     name: discovery.deviceName,
     *     values: {
     *       address: discovery.address,
     *       version: discovery.firmwareVersionMajor + '.' + discovery.firmwareVersionMinor
     *     }
     *   };
     * };
   *
   * @abstract
   * @param {*} discovery an object obtained from a node in discovery table.
   * @param {*} subject - the subject of the `Row` whose values are to be
   * proposed.
   * @see module:nmodule/webEditors/rc/wb/table/tree/TreeNode
   * @returns {Object|Promise.<Object>} an object literal with the name and
   * initial values to be used for the new component.
   */
  MgrLearn.prototype.getProposedValuesFromDiscovery = function (discovery, subject) {
    throw new Error('getProposedValuesFromDiscovery() function not implemented.');
  };

  /**
   * @function module:nmodule/webEditors/rc/wb/mgr/MgrLearn#isExisting
   * @param {*} discovery the discovery item
   * @param {baja.Component} component component already existing in local
   * database
   * @returns {boolean|Promise.<boolean>} true if the local component already
   * represents the discovery item
   */

  /**
   * Get the learn model. The model will have been created via a call to `makeLearnModel()`; a
   * function that the concrete manager must provide. This will return the `TreeTableModel`
   * resolved from the Promise.
   *
   * @returns {module:nmodule/webEditors/rc/wb/table/tree/TreeTableModel}
   */
  MgrLearn.prototype.getLearnModel = function () {
    return this.$learnModel;
  };

  /**
   * @since Niagara 4.14
   * @returns {Boolean}
   */
  MgrLearn.prototype.isLearnModeEnabled = function () {
    return this.$isLearnModeEnabled;
  };

  /**
   * @since Niagara 4.14
   * @param {Boolean} learnModeEnabled
   */
  MgrLearn.prototype.setLearnModeEnabled = function (learnModeEnabled) {
    this.$toggleLearnPane(learnModeEnabled);
    this.$isLearnModeEnabled = learnModeEnabled;
  };

  /**
   * Begin the discovery process. This function is not intended to be called directly
   * or overridden by concrete manager types. Instead, the concrete manager should provide
   * a `doDiscover` function, which will be called from this function. If the function is
   * not provided, an Error will be thrown.
   *
   * @private
   */
  MgrLearn.prototype.discover = function () {
    const modeCmd = this.$getLearnModeCommand();
    let prom;
    // Set the learn mode command to selected, if it isn't already. This will cause
    // the discovery pane to become visible, and the table heights to be adjusted.

    if ((modeCmd) && (!modeCmd.isSelected())) {
      prom = Promise.resolve(modeCmd.invoke());
    } else {
      prom = Promise.resolve();
    }

    return prom.then(() => this.doDiscover());
  };

  /**
   * Attach a job to this manager, typically as part of a driver discovery process.
   * The act of attaching a job will subscribe to it, and cause a 'jobcomplete' event
   * to be emitted once the job is complete. A manager will typically update the learn
   * model at that point.
   *
   * @param {Object} params an Object literal containing the parameters for this function.
   *
   * @param {string|baja.Ord|baja.Component} [params.jobOrOrd] either a BJob instance, an
   * Ord referencing a job, or a String containing the ord for a job.
   *
   * @param {Number} [params.depth] optional parameter that will be used when subscribing to
   * the job once it has completed; this allows the job plus its final set of children to
   * be subscribed. A depth of 1 will subscribe to the job, 2 will subscribe the job and
   * its children, and so on. Subscription will default to a depth of 1 if this parameter
   * is not specified.
   *
   * @returns {Promise}
   */
  MgrLearn.prototype.setJob = function (params) {
    params = baja.objectify(params, 'jobOrOrd');
    const jobOrOrd = params.jobOrOrd;
    const depth = params.depth || 1;

    // First, clean up any existing job that might already have been set on the manager.
    // After that, obtain the new job: we might have been passed one directly, or we might
    // need to resolve an ord.

    return unsubscribeJob(this)
      .then(() => baja.station.sync())
      .then(() => {
        if (this.$learnJob) { delete this.$learnJob; }

        const isJob = baja.hasType(jobOrOrd, 'baja:Job');

        return isJob ? jobOrOrd : baja.Ord.make(String(jobOrOrd)).get();
      })
      .then((job) => {
        const discoveryCmd = this.$getDiscoveryCommand();
        const cancelCmd = this.$getCancelDiscoveryCommand();

        // Now we have a reference to the job, keep track of it with some private
        // properties, then load it into the job bar.

        this.$learnJob = job;
        this.$jobSubDepth = depth;

        if (discoveryCmd) { discoveryCmd.setEnabled(false); }
        if (cancelCmd) { cancelCmd.setEnabled(true); }

        return this.$getJobBar().load(job).then(() => job);
      })
      .then((job) => {

        // Test if the job has already completed, if so, call the
        // job complete callback directly. Otherwise, subscribe and
        // wait for an event to notify us that the job has finished.

        if (isJobComplete(job)) {

          // The job has already completed, so subscribe to the job
          // and its children to the requested depth. We don't need
          // to pass in a change callback, as once it's complete we
          // don't care about changes.

          return subscribeJob(this, job, depth)
            .then(() => {
              jobComplete(this, job);
            });
        } else {
          // The job is not yet complete, so subscribe to it with a
          // depth of 1. Once the job is complete, we'll resubscribe
          // to it and its children to the requested depth. We pass
          // a callback here, so we can get the change events.

          return subscribeJob(this, job, 1, (p) => { jobStateChanged(this, p); });
        }
      });
  };

  /**
   * Get the discovery job currently set against the manager.
   *
   * @returns {baja.Component}
   */
  MgrLearn.prototype.getJob = function () {
    return this.$learnJob;
  };

  /**
   * Creates a new component instance from the types the manager specified
   * for a particular node in the discovery table. If the manager returned
   * more than one type, this default implementation will return a new
   * instance based on the first type info.
   *
   * @param {*} discovery an instance of a discovery object (e.g. an
   * `ndriver:NDiscoveryLeaf`), dragged from the discovery table and dropped
   * onto the database table or selected when the 'Add' command was invoked.
   *
   * @param {Array.<module:nmodule/webEditors/rc/wb/mgr/MgrTypeInfo>} typeInfos - an
   * array of MgrTypeInfos, created from the type or types returned
   * by the manager's `getTypesForDiscoverySubject()` implementation.
   *
   * @returns {Promise} a Promise of new component instance for the discovered item
   * based on the provided type information.
   */
  MgrLearn.prototype.newInstanceFromDiscoverySubject = function (discovery, typeInfos) {
    return this.getModel().newInstance(_.head(typeInfos));
  };

  /**
   * Creates new component instances for the discovered items. The default implementation
   * simply iterates over the discovery objects, gets their type info and delegates to
   * #newInstanceFromDiscoverySubject method.
   *
   * @since Niagara 4.14
   * @param {*} discoveries an array of discovery objects
   *
   * @returns {Promise<Array<baja.Component|null>>} a Promise of new component instances
   * from the discovered items
   */
  MgrLearn.prototype.newInstancesFromDiscoverySubjects = function (discoveries) {
    let that = this;
    return Promise.all(discoveries.map((discovery) => {
      return that.$getMgrTypesForDiscovery(discovery)
        .then((typeInfos) => typeInfos ? that.newInstanceFromDiscoverySubject(discovery, typeInfos) : null);
    }));
  };

  /**
   * Search for the existing component that matches the given node from the
   * discovery table. To match a component, the concrete manager subclass
   * must contain a function named `isExisting()` which will be passed the
   * discovery object and a component. The function will be used as a predicate and
   * should return true if the given component represents the same item as
   * the discovery table item, false otherwise. If the manager does not provide
   * such a function, all discovery nodes will be considered as not matching any
   * existing components.
   *
   * @param {*} discovery a discovered object
   *
   * @returns {Promise.<baja.Component|undefined>} the existing component that was found to match
   * the given discovery node, or undefined if no such match was found.
   */
  MgrLearn.prototype.getExisting = function (discovery) {
    const comps = _.invoke(this.getModel().getRows(), 'getSubject');

    if (typeof this.isExisting === 'function') {
      return Promise.resolve(_.find(comps, (c) => {
        return !!this.isExisting(discovery, c);
      }));
    }

    return Promise.resolve(undefined);
  };

////////////////////////////////////////////////////////////////
// Discovery Commands
////////////////////////////////////////////////////////////////

  /**
   * Creates and returns an array of discovery related commands.
   * These are the LearnModeCommand (show/hide the learn pane),
   * DiscoverCommand, CancelDiscoverCommand, AddCommands, and MatchCommands.
   *
   * @returns {Array.<module:bajaux/commands/Command>} a new array containing
   * the discovery related commands.
   */
  MgrLearn.prototype.makeDiscoveryCommands = function () {
    return [
      new LearnModeCommand(this),
      new DiscoverCommand(this),
      new CancelDiscoverCommand(this),
      new AddCommand(this),
      new QuickAddCommand(this),
      new MatchCommand(this),
      new QuickMatchCommand(this)
    ];
  };

  /**
   * When the learn table is right-clicked, only learn table-specific commands will be shown. This
   * function defines what those commands are.
   *
   * By default, these will be any commands with the `LEARN_CONTEXT_MENU` flag, plus Show Existing
   * and Select All commands, but _not_ including any commands with flags that have not been
   * configured (i.e. those commands with flags still set to the default value of `ALL`). You must
   * explicitly set the command flags to a value that includes `LEARN_CONTEXT_MENU` to make it
   * appear here.
   *
   * @returns {Promise.<Array.<module:bajaux/commands/Command>>}
   * @since Niagara 4.14
   * @see module:nmodule/webEditors/rc/wb/mgr/commands/MgrCommand~flags
   */
  MgrLearn.prototype.getLearnTableCommands = function () {
    const learnCmds = this.getCommandGroup().filter({
      include: (cmd) => {
        const flags = cmd.getFlags();
        // omit flags that default to ALL, to make this less of a breaking change.
        // most commands set to ALL are ok to show in the database table context menu, but not in
        // the learn table.
        return flags !== ALL && (flags & LEARN_CONTEXT_MENU);
      }
    }).getChildren();

    const selectAll = new SelectAllCommand(this);

    const hasOneDiscoverySelected = this.getLearnTable().getSelectedRows().length === 1;

    if (hasOneDiscoverySelected) {
      return ShowExistingCommand.make(this).then((showExisting) => [ ...learnCmds, showExisting, selectAll ]);
    } else {
      return Promise.resolve([ ...learnCmds, selectAll ]);
    }
  };

  /**
   * Get the 'LearnModeCommand' instance from the command group. This will be used by the
   * discovery command to enable the learn pane if a discovery is started.
   *
   * @private
   * @returns {module:nmodule/webEditors/rc/wb/mgr/commands/LearnModeCommand}
   */
  MgrLearn.prototype.$getLearnModeCommand = function () {
    return findCommand(this, LearnModeCommand);
  };

  /**
   * Get the 'DiscoverCommand' instance from the command group. Invoking this will show
   * the learn pane and call the `discover()` function on the manager.
   *
   * @private
   * @returns {module:nmodule/webEditors/rc/wb/mgr/commands/DiscoverCommand}
   */
  MgrLearn.prototype.$getDiscoveryCommand = function () {
    return findCommand(this, DiscoverCommand);
  };

  /**
   * Return the 'CancelDiscoveryCommand' instance from the command group. This is used
   * to enable or disable the command as the discovery process starts or stops.
   *
   * @private
   * @returns {module:nmodule/webEditors/rc/wb/mgr/commands/CancelDiscoverCommand}
   */
  MgrLearn.prototype.$getCancelDiscoveryCommand = function () {
    return findCommand(this, CancelDiscoverCommand);
  };

  /**
   * Return the first 'AddCommand' instance from the command group. This is used
   * to add selected items from the discovery table into the database table.
   * It's also invoked from the drag and drop operation.
   *
   * @private
   * @returns {module:nmodule/webEditors/rc/wb/mgr/commands/AddCommand}
   */
  MgrLearn.prototype.$getAddCommand = function () {
    return findCommand(this, AddCommand);
  };

  /**
   * Return any 'AddCommand' instance from the command group. These commands are used
   * to add selected items from the discovery table into the database table.
   *
   * @private
   * @since Niagara 4.14
   * @returns {Array.<module:nmodule/webEditors/rc/wb/mgr/commands/AddCommand>}
   */
  MgrLearn.prototype.$getAddCommands = function () {
    return findCommands(this, AddCommand);
  };

  /**
   * Return the 'MatchCommand' instance from the command group. This will
   * can be used to update an existing database item from a discovered item.
   *
   * @private
   * @returns {module:nmodule/webEditors/rc/wb/mgr/commands/MatchCommand}
   */
  MgrLearn.prototype.$getMatchCommand = function () {
    return findCommand(this, MatchCommand);
  };

  /**
   * Return any 'MatchCommand' instance from the command group. These commands are used
   * to match selected items from the discovery table with the database table.
   *
   * @private
   * @since Niagara 4.14
   * @returns {Array.<module:nmodule/webEditors/rc/wb/mgr/commands/MatchCommand>}
   */
  MgrLearn.prototype.$getMatchCommands = function () {
    return findCommands(this, MatchCommand);
  };

  /**
   * Return the 'QuickAddCommand' instance from the command group. This is used
   * to add selected items from the discovery table into the database table without a dialog.
   *
   * @private
   * @since Niagara 4.14
   * @returns {module:nmodule/webEditors/rc/wb/mgr/commands/QuickAddCommand}
   */
  MgrLearn.prototype.$getQuickAddCommand = function () {
    return findCommand(this, QuickAddCommand);
  };


  /**
   * Return the 'QuickMatchCommand' instance from the command group. This will
   * can be used to update an existing database item from a discovered item without
   * prompting to update the proposed data.
   *
   * @private
   * @since Niagara 4.14
   * @returns {module:nmodule/webEditors/rc/wb/mgr/commands/quickMatchCommand}
   */
  MgrLearn.prototype.$getQuickMatchCommand = function () {
    return findCommand(this, QuickMatchCommand);
  };

  /**
   * @private
   * @param {*} discovery
   * @returns {Promise<Array.<module:nmodule/webEditors/rc/wb/mgr/MgrTypeInfo>|boolean>}
   */
  MgrLearn.prototype.$getMgrTypesForDiscovery = function (discovery) {
    const that = this;
    return Promise.resolve(that.getTypesForDiscoverySubject(discovery))
      .then((types) => {
        return types && !_.isEmpty(types) && toMgrTypeInfos(types);
      });
  };

  /**
   * This function is used to determine if the `MatchCommand` should be enabled or not.  It does
   * this by checking to see if the selection in the main table (mainSelection) is of a type that
   * the type from the selection in the learn table supports (learnSelection).  What types are
   * supported is determined by the `getTypesForDiscoverySubject()` that the manager has
   * implemented.  If this default behaviour is not enough or a different behaviour is required
   * then this function can be overridden in the manager, with the new function either returning a
   * `boolean` or a `Promise.<boolean>`.
   * Resolves to true if the supplied mainSelection item can be matched to the supplied
   * learnSelection item.
   * @since Niagara 4.14
   * @param {*} learnSelection the subject selected in the learn table
   * @param {baja.Component} mainSelection the subject from the main table that the learnSelection
   * subject is being matched to
   * @returns {boolean|Promise.<boolean>}
   */
  MgrLearn.prototype.isMatchable = function (learnSelection, mainSelection) {
    const that = this;
    return that.$getMgrTypesForDiscovery(learnSelection)
      .then((types) => {
        return Array.isArray(types) && !!types.find((type) => type.isMatchable(mainSelection));
      })
      .catch((e) => {
        logError(e);
        return false;
      });
  };

  /**
   * Test for discovery job completion.
   * @private
   */
  function isJobComplete(job) {
    return !baja.AbsTime.DEFAULT.equals(job.get('endTime'));
  }

  /**
   * Callback function used for receiving notifications of changes on the discovery
   * job. This is used to listen for notification of the job completion, at which
   * point we will emit the 'jobcomplete' event.
   *
   * @private
   * @param mgr the manager instance
   * @param prop the property that has changed on the subscribed job.
   */
  function jobStateChanged(mgr, prop) {
    const job = mgr.$learnJob;

    // If the end time has changed, we'll look to see whether the job is
    // now finished. If so, we'll emit the event on the manager. We'll
    // also re-subscribe to the requested depth provided in the setJob()
    // call. We'll previously have been subscribed at depth 1, but we can
    // now subscribe to the job's children.

    if (prop.getName() === 'endTime' && isJobComplete(job)) {
      unsubscribeJob(mgr)
        .then(() => subscribeJob(mgr, job, mgr.$jobSubDepth))
        .then(() => {
          jobComplete(mgr, job);
        })
        .catch(logError);
    }
  }

  /**
   * Subscribe to the discovery job that has been attached to the manager.
   * If the job has not yet completed, the depth parameter is expected to
   * be 1, to listen for changes to the job's properties. If the job is complete,
   * the depth will be the value specified during the call to setJob(), as at
   * that point we are in a position to be able to subscribe to the job's children,
   * if needed. There is an optional change callback parameter - this is used to get
   * our notification that the job has finished.
   *
   * @private
   * @param mgr
   * @param {baja.Component} job
   * @param {Number} depth
   * @param {Function} [changeCallback]
   * @returns {Promise}
   */
  function subscribeJob(mgr, job, depth, changeCallback) {
    const subscriber = new DepthSubscriber(depth);
    if (changeCallback) { subscriber.attach('changed', changeCallback); }
    mgr.$jobSubscriber = subscriber;
    return subscriber.subscribe(job);
  }

  /**
   * Unsubscribe from the job and remove the property on the manager.
   *
   * @private
   * @param mgr
   * @returns {Promise}
   */
  function unsubscribeJob(mgr) {
    const subscriber = mgr.$jobSubscriber;

    if (subscriber) {
      subscriber.detach();
      delete mgr.$jobSubscriber;
      return subscriber.unsubscribeAll();
    }

    return Promise.resolve();
  }

  /**
   * Function called when a job attached via `setJob()` has completed. This
   * will emit a 'jobcomplete' event to any registered handlers.
   *
   * @private
   * @param {module:nmodule/webEditors/rc/wb/mgr/MgrLearn} mgr
   * @param {baja.Component} job the completed job.
   */
  function jobComplete(mgr, job) {
    const discoveryCmd = mgr.$getDiscoveryCommand();
    const cancelCmd = mgr.$getCancelDiscoveryCommand();

    if (discoveryCmd) { discoveryCmd.setEnabled(true); }
    if (cancelCmd) { cancelCmd.setEnabled(false); }

    mgr.emit('jobcomplete', job);
  }

  return MgrLearn;
});