Manual Reference Source Test

src/helpers/flow/Flow.js

import AnalyticsService from '../../services/AnalyticsService';
import Client from '../../Client';
import FlowStep from './FlowStep';
import FlowModel from '../../models/flow/Flow';
import FlowService from '../../services/FlowService';
import StatusService from '../../services/StatusService';
import Commentable from '../../utilities/mixins/Commentable';
import Helper from '../Helper';
import { aggregate } from '../../utilities/ClassUtility';
import { validate, hasToken, isRequired, isValid } from '../../utilities/ValidationUtility';
import { union } from '../../utilities/ArrayUtility';


/**
 * A flow is a collection of steps representing a screen or card created by the Clinical6
 * platform.  Each flow indicates what the first step is to start the flow and each step has a list
 * of actions the user or system can perform to move to the next step.  Flows additionally have
 * a status that starts at `initial` and can be either transitioned directly through the
 * `transition` method or by saving the flow or step.  Saving a flow will send everything that has
 * been set in the `_fields` property to the server.  Saving a step will only save items in the
 * `toSave` property and will provide a transition based on completeness, updating the flow status.
 *
 * @extends {FlowModel}
 * @extends {Helper}
 * @extends {Commentable}
 *
 * @property {Object} _fields                           - The current value of fields in the flow for each flow step
 * @property {Object} _options                          - Used for API calls.  This can be set using the options setter and
 *                                                        getter.  If the term `existingId` or `ownerType` is used, it
 *                                                        convert it to use the underscore `_` naming convention.
 * @property {String} _options.captured_value_group_id  - The captured value group id determines which captured value group
 *                                                        that the data belongs to.  This can be set using the `flowDataGroupId`
 *                                                        getter and setter.
 * @property {String} _options.existing_id              - The existing id to be used for updating items in a flow instead of
 *                                                        inserting items.  This can be set using the `existingId` getter
 *                                                        and setter.
 * @property {String} _options.owner                    - The owner of the object.  This can be set using the `owner` getter
 *                                                        and setter.
 * @property {String} _options.owner_type               - The owner_type of the object.  This can be set using the
 *                                                        `ownerType` getter and setter.
 * @property {String} _status                           - The status of the flow.  Typically `initial`, `in_progress`, and
 *                                                        `completed`.  This can be set using the `status` getter and
 *                                                        setter.
 * @property {String[]} _toSave                         - An array of keys to save when the `collect` method is called.
 * @property {FlowStep} first                           - The first step if the first_step id exists
 * @property {Number} first_step                        - The id corresponding to the first step in the flow (inherited)
 * @property {Number} id                                - The flow id (inherited)
 * @property {String} name                              - The name of the flow  (inherited)
 * @property {FlowStep[]} steps                         - The array of steps belonging to the flow (inherited)
 * @property {Number} total                             - The total number of steps in the flow (inherited)
 *
 * @example <caption>Simple flow</caption>
 * let flow = new Flow({ id: 1, steps: [] });
 *
 * @example <caption>Simple flow with settings</caption>
 * let flow = new Flow({ id: 1, steps: [] }, { transition: 'manual' });
 *
 * @example <caption>Set Owner and Owner Type</caption>
 * flow.owner = 23;
 * flow.ownerType = 'site_start/sites';
 *
 * @example <caption>Complex Flow</caption>
 * let flow = new Flow({
 *       id: 1,
 *       name: 'Dummy Flow',
 *       first_step: 11,
 *       steps: [
 *         {
 *           id: 11,
 *           title: 'dummy_5',
 *           description: 'dummy_6',
 *           content_type: 'info_screen_with_help',
 *           help: 'ALL text',
 *           image: { },
 *           inputs: [
 *             {
 *               title: 'Age',
 *               question_type: 'text',
 *               style: 'text',
 *               choice_list: [],
 *               id: 'first_name',
 *             },
 *             {
 *               id: 271,
 *               title: 'Is your facility affiliated with an SMO?',
 *               question_type: 'single_choice',
 *               style: 'radio_buttons',
 *               required: false,
 *               validation_expression: null,
 *               instructions: 'Is your facility affiliated with an SMO?',
 *               choice_list: [
 *                 {
 *                   id: 371,
 *                   body: 'Yes',
 *                 },
 *                 {
 *                   id: 372,
 *                   body: 'No',
 *                 },
 *               ],
 *               locked: null,
 *             },
 *           ],
 *           paths: [
 *             {
 *               capture: true,
 *               button_name: 'Next',
 *               steps: [
 *                 {
 *                   step: 12,
 *                   conditions: [
 *                     {
 *                       criteria: '271',
 *                       operator: '=',
 *                       value: '371',
 *                     },
 *                   ],
 *                 },
 *               ],
 *             },
 *           ],
 *         },
 *         {
 *           id: 12,
 *           title: 'dummy_5',
 *           description: 'dummy_6',
 *           content_type: 'info_screen_with_help',
 *           help: 'ALL text',
 *           image: { },
 *           inputs: [
 *             {
 *               title: 'First Name',
 *               question_type: 'text',
 *               style: '',
 *               choice_list: [],
 *               id: 'first_name',
 *             },
 *           ],
 *           paths: [
 *             {
 *               capture: true,
 *               button_name: 'Next',
 *               steps: [
 *                 {
 *                   step: 13,
 *                   conditions: [],
 *                 },
 *               ],
 *             },
 *           ],
 *         },
 *         {
 *           id: 13,
 *           title: 'dummy_5',
 *           description: 'dummy_6',
 *           content_type: 'info_screen_with_help',
 *           help: 'ALL text',
 *           image: { },
 *           inputs: [
 *             {
 *               title: 'First Name',
 *               question_type: 'text',
 *               style: '',
 *               choice_list: [],
 *               id: 'first_name',
 *             },
 *           ],
 *           paths: [],
 *         },
 *       ],
 *     });
 *
 */
class Flow extends aggregate(FlowModel, Helper, Commentable) {
  /**
   * Constructor for Flow.  This expects that steps are in JSON format and is able to be mapped
   * to the `FlowStep` data type.  After each step has been mapped, the constructor will then
   * connect the graph according to each steps available actions.
   *
   * @param {!Object} response                      - JSON formatted response of a flow process.
   *                                                  Requires all fields that are necessary for
   *                                                  FlowModel
   * @param {Number}  response.id                   - The id of the content
   * @param {String}  response.name                 - The id of the content
   * @param {Array}   response.steps                - The array of steps in the flow process
   * @param {Number}  response.first_step           - The id of the first step
   * @param {Number}  response.total                - The total number of steps
   * @param {?Object} settings                      - Flow process settings
   * @param {?String} [settings.transition='auto']  - Either 'auto' or 'manual', defaults to 'auto'
   */
  constructor(response, settings = {}) {
    super(response);
    /** @type {Object} */
    this._fields = {};
    /** @type {Object} */
    this._options = { owner_type: this.owner_type || 'mobile_user' };
    /** @type {Object} */
    this._settings = {
      transition: 'auto'
    };
    /** @type {String} */
    this._status = 'initial';
    /** @type {String[]} */
    this._toSave = [];

    // Setup user defined settings
    this.settings = settings;

    // Map steps to FlowStep() objects
    /** @type {FlowStep[]} */
    this.steps = this.steps.map(obj => new FlowStep(obj, this));

    // Connect each step's next options to an actual FlowStep() object
    this.connectGraph();

    // this.deserializeRelationshipStubs(response);
    // this.syncRelationships(response);

  }

  /** @type {String}  - The type */
  /*
  static get type() {
    return 'consent__strategies';
  }
  */

  /** @type {Cohort} */
  /*
  get cohort() {
    return this._relationships.cohort;
  }*/

  /**
   * Get `_options.entry`
   * @type {Entry}
   */
  get entry() { return this._options.entry; }

  /**
   * Set `_options.entry`
   * @type {Entry}
   */
  set entry(entry) { this._options.entry = entry; }

  /**
   * Get `_options.existing_id`
   * @type {String}
   */
  get existingId() { return this._options.existing_id; }

  /**
   * Set `_options.existing_id`
   * @type {String}
   */
  set existingId(id) { this._options.existing_id = id; }

  /**
   * Get Fields flattens the fields to an array to be used for the API endpoint
   * @readonly
   * @type {Object[]}
   */
  get fields() { return Object.keys(this._fields).map(k => this._fields[k]); }

  /**
   * Get `_options.captured_value_group_id`
   * @type {String}
   */
  get flowDataGroupId() { return this._options.captured_value_group_id; }

  /**
   * Set `_options.captured_value_group_id`
   * @type {String}
   */
  set flowDataGroupId(id) { this._options.captured_value_group_id = id; }

  /**
   * Get objectType
   * @readonly
   * @type {String}
   */
  get objectType() { return 'data_collection/flow_process'; }

  /**
   * Get options needed for API flow process collect endpoint
   * @type {Object}
   */
  get options() { return this._options; }

  /**
   * Set options needed for API flow process collect endpoint
   * @type {Object}
   */
  set options(options) {
    Object.assign(this._options, options);
    if (options.ownerType) {
      this._options.owner_type = options.ownerType;
      delete options.ownerType;
    }
    if (options.existing_id || options.existingId) {
      this.existingId = options.existing_id || options.existingId;
    }
  }

  /**
   * Get `_options.owner`
   * @type {String}
   */
  get owner() { return this._options.owner; }

  /**
   * Set `_options.owner`
   * @type {String}
   */
  set owner(owner) { this._options.owner = owner; }

  /**
   * Get `_options.owner_type`
   * @type {String}
   */
  get ownerType() { return this._options.owner_type; }

  /**
   * Set `_options.owner_type`
   * @type {String}
   */
  set ownerType(ownerType) { this._options.owner_type = ownerType; }

  /**
   * Get options needed for API flow process collect endpoint
   * @type {Object}
   */
  get settings() { return this._settings; }

  /**
   * Set settings needed for API flow process collect endpoint
   * @type {Object}
   */
  set settings(settings) {
    Object.assign(this._settings, settings);
  }

  /**
   * Get `_status`
   * @type {String}
   */
  get status() { return this._status; }

  /**
   * Set `_status`
   * @type {String}
   */
  set status(status) { this._status = status; }

  /** @type {String}  - The type */
  get type() {
    return 'data_collection__flow_processes';
  }

  /**
   * Prepare the list of keys to be sent later
   *
   * @param {String[]} array - A list of keys representing fields that `save()` will send
   *
   * @example
   * flow.addFieldsToSave([ 23, 42, 189 ]);
   */
  addFieldsToSave(array) {
    this._toSave = union(this._toSave, array, (a, b) => (a.input_id === b.input_id));
  }

  /**
   * Clones the current object and returns a copy of `this` instance
   *
   * @return {Flow} - Returns a copy of this object
   *
   * @example
   * let newFlow = flow.clone();
   */
  clone() {
    const origProto = Object.getPrototypeOf(this);
    return Object.assign(Object.create(origProto), this);
  }

  /**
   * Completes the flow using transition collect
   *
   * @return {Promise} - Returns a promise via ajax call.
   *
   * @example
   * flow.complete();
   */
  complete() {
    this.postInsights('Complete').catch(() => {});
    return this.transition('collect');
  }

  /**
   * Loop through each step and provide a direct link to the next_step's step.  This in essence
   * creates / completes the flow's graph structure.
   *
   * @example
   * flow.connectGraph();
   */
  connectGraph() {
    // Link "first" directly with the first step per "first_step" id
    /** @type {FlowStep} */
    this.first = this.steps.filter(obj => (obj.id === this.first_step))[0];

    const self = this;
    this.steps.forEach(step => step.paths.forEach(next => next.steps.forEach((_step) => {
      // Link "step" directly with the object containing the id
      _step.step = self.steps.filter(obj => (obj.id === _step.step))[0];

      // provide some way of getting the parent flow information
      if (_step.step && !_step.step.flow) {
        _step.step.flow = self;
      }
    })));
  }

  /**
   * The final action after the last step.  This function is intended for the user to override.
   *
   * @param  {!FlowStep} step - The last step information
   *
   * @example
   * flow.end = (step) => {
   *  console.log(step);
   *  return step;
   * };
   */
  end(step) {
    return step;
  }

  /**
   * Find inputs where certain attributes are equal
   *
   * @param  {!function} whereFn  - A function returning a boolean value
   * @return {FlowInput[]}        - Array of FlowInputs corresponding to the filter
   *
   * @example
   * const inputs = flow.findInputs(input => (
   *   ['single_choice', 'multiple_choice'].indexOf(input.question_type) >= 0
   *   && input.id === key));
   */
  findInputs(whereFn) {
    return this.steps.reduce((a, b) =>
      union(a, b.inputs.filter(whereFn), (i, j) => (i.id === j.id)), []);
  }

  /**
   * Get value from key
   * @param {String} key   Key must be 'input_id'
   * @return {Any} value   This is the user response given the key
   */
  get(key) {
    if (this._fields[key]) {
      return this._fields[key].value;
    }
    return null;
  }

  /**
   * BFS Distance, creates a BFS object based on distance from a.
   *
   * @see http://opendatastructures.org/versions/edition-0.1e/ods-java/12_3_Graph_Traversal.html#SECTION001531000000000000000
   *
   * @param  {!FlowStep} a  - The starting point for the BFS object
   * @return {Object}       - BFS object based on distance from a in
   *                          { <flowstep.id>: <distance>} format.
   *
   * @example
   * flow.getBFS(flow.first);
   */
  getBFS(a) {
    if (!a || !a.id) {
      return {};
    }
    const dist = {};
    const Q = [a];
    dist[a.id] = 0;
    while (Q.length > 0) {
      const c = Q.shift(); // dequeue
      c.paths.forEach(next => next.steps.forEach((_step) => {
        if (!dist[_step.step.id]) {
          Q.push(_step.step);
          dist[_step.step.id] = dist[c.id] + 1;
        }
      }));
    }
    return dist;
  }

  /**
   * Gets user input data belonging to the flow
   *
   * @return {Promise} - A promise with the resulting data
   * @example
   * flow.getData().then((data) => {
   *   console.log('response', data);
   *   console.log('flow.data', flow.data);
   * });
   */
  getData() {
    return (new FlowService()).getInputDataByFlow(this);
  }

  /**
   * Get the longest path from a flowstep
   * @param  {!FlowStep} a  - The starting step
   * @return {Number}       - 0 if nothing is found or the longest path distance
   *
   * @example
   * flow.getLongestDistance(flow.first);
   */
  getLongestDistance(a) {
    const bfsArray = Object.values(this.getBFS(a));
    return bfsArray.length > 0 ? Math.max.apply(null, bfsArray) : 0;
  }

  /**
   * Johnson Matrix using the key/value as id/distance of the graph
   *
   * @see http://dl.acm.org/citation.cfm?doid=321992.321993
   *
   * @return {Object} - {id: {id: distance}} to get diatance of a step id to another step id
   *
   * @example
   * var matrix = flow.getJohnsonMatrix();
   * console.log(matrix);
   */
  getJohnsonMatrix() {
    const matrix = {};
    this.steps.forEach(step => (matrix[step.id] = this.getBFS(step)));
    return matrix;
  }

  /**
   * Get Status and set it to this._status
   *
   * @return {Promise} - A Promise containing the status (String)
   *
   * @example
   * flow.getStatus().then((data) => {
   *   console.log('response', data);
   *   console.log('flow.status', flow.status);
   * });
   */
  getStatus() {
    validate('Content getStatus()',
      hasToken(),
      isRequired({
        object_type: this.objectType,
        object: this.permanentLink,
        ownerType: this._options.owner_type,
        owner: this._options.owner,
      })
    );

    const objectType = this.objectType;
    const object = this.permanentLink;
    const ownerType = this._options.owner_type;
    const owner = this._options.owner;

    return (new StatusService()).getStatus(
      objectType,
      object,
      ownerType,
      owner
    ).then(data => (this.status = data.status.value));
  }

  /**
   * Loading the steps from server
   *
   * This is only necessary if you are using v3 eDiary
   * @todo modify this function later when v3 is out for flows
   *
   * @return {Promise<FlowStep[]>} Returns this flow's new flowsteps
   *
   * @example
   * import { flowService } from 'clinical6';
   * flowService.getFlow('my_flow').then(flow => flow.loadSteps());
   */
  loadSteps() {
    return (new FlowService()).getFlow(this.permanentLink).then((flow) => {
      this.steps = flow.steps;
      this.first = flow.first;
      /** @type {Number} - Total number of steps in a flow */
      this.total = flow.steps.length();
      return this.steps;
    });
  }

  /**
   * Is run after a successful flow save.
   *
   * @param  {!Object}  results - results of the save event
   * @return {Object}           - returns the results
   *
   * @example
   * flow.onSave = (response) => {
   *   // do something with the save response
   *   return response;
   * }
   */
  onSave(results) {
    return results;
  }

  /**
   * Post Insight on this flow.
   *
   * @param  {!String} action - The action that is being tracked
   * @return {Promise<any>}   - returns the results
   *
   * @example
   * flow.postInsights('collect') = (response) => {
   *   // do something with the save response
   *   return response;
   * }
   */
  postInsights(action) {
    return new Promise((resolve, reject) => {
      if (Client.instance.config.track.flows) {
        const flowName = this.name || '';
        const flowId = this.permanentLink || '';
        (new AnalyticsService()).postInsights(`Flow: ${action}`,
          `Flow: ${flowName} (id: ${flowId})`,
          flowId,
          flowName,
          (new Date()).toISOString()).then(
          response => resolve(response),
          reason => reject(reason)
        ).catch(() => {});
      } else {
        resolve();
      }
    });
  }

  /**
   * Resets the current fields from the flow and its steps.
   *
   * @example
   * flow.reset();
   */
  reset() {
    this._toSave = [];
    this._fields = {};
    this.steps.forEach(step => step.initFields());
  }

  /**
   * Saves the collected data of the flow.  This will automatically process a transition.
   *
   * @return {Promise} - Returns a promise via ajax call.
   *
   * @example
   * flow.save();
   */
  save() {
    let arr = Object.keys(this.fields).map(k => this.fields[k]);
    arr = arr.filter(obj => (obj.value !== undefined && obj.value !== null));
    return new Promise((resolve, reject) => {
      (new FlowService()).collectFields(this, arr).then(
        (response) => {
          this.onSave(response);
          resolve(response);
        },
        reason => reject(reason));
    });
  }

  /**
   * Saves flow attributes / flow data. (insert if id doesn't exist, update if it does)
   * This method does NOT submit flow answers, just flow specific data.
   * @return {Promise<Flow>} - Returns a promise via ajax call.
   *
   * @example
   * import { Flow, clinical6 } from 'clinical6';
   *
   * // Updates existing flow(has existing id)
   * const flow = new Flow({...});
   * flow.saveFlowAttributes();
   */
  async saveFlowAttributes() {
    return (new FlowService()).update(this);
  }

  /**
   * Set user collected data with a key/value pair for server consumption.
   *
   * @param {!String} key         - A key from inputs
   * @param {!String} value       - The value from the user
   * @param {Number} [existingId] - The existing id to update an object to
   *
   * @example
   * flow.set('first_name', 'Joe');
   * flow.set('last_name', 'Montana');
   */
  set(key, value, existingId = undefined) {
    let _value = value;

    // See if it's the id of a single or multiple choice question
    const inputs = this.findInputs(input => (
      ['single_choice', 'multiple_choice'].indexOf(input.question_type) >= 0
      && input.id === key));
    if (inputs.length > 0 && inputs[0].findChoiceByCode(_value)) {
      _value = inputs[0].findChoiceByCode(_value).id; // choice_list[...].id
    }

    // Set the value
    this._fields[key] = { input_id: key, value: _value };

    if (existingId || this.existingId) {
      this._fields[key].existing_id = existingId || this.existingId;
    } else if ('existing_id' in this._fields[key]) {
      delete this._fields[key].existing_id;
    }
  }

  /**
   * Starts the flow using transition collect
   *
   * @return {Promise} - Returns a promise via ajax call.
   *
   * @example
   * flow.start();
   */
  start() {
    return new Promise((resolve, reject) => {
      if (this.status === 'initial') {
        this.transition('start').then(
          (response) => {
            resolve(response);
            this.postInsights('Start').catch(() => {});
          },
          err => reject(err));
      } else {
        resolve('Flow has already started');
      }
    });
  }

  /**
   * Transition between states using actions
   *
   * @param  {!String} action - The action must be either 'collect' or 'start'
   * @return {Promise}        - Returns a promise but will alsoassign the new state to the Flow
   *                            intance
   *
   * @example
   * flow.transition('collect').then((data) => {
   *   console.log('new status', flow.status);
   * });
   */
  transition(action) {
    validate('Flow Helper Transition', isValid((['collect', 'start'].indexOf(action) > -1)));

    return new Promise((resolve, reject) => {
      if (this.settings.transition === 'auto') {
        const objectType = this.objectType;
        const object = this.permanentLink;
        const ownerType = this._options.owner_type;
        const owner = this._options.owner;

        (new StatusService()).transition(
          action,
          objectType,
          object,
          ownerType,
          owner
        ).then(
          (data) => {
            this.status = data.status.value;
            resolve(this.status);
          },
          reason => reject(reason)
        ).catch(reason => reject(reason));
      } else {
        resolve();
      }
    });
  }

  /**
   * Generates Plant UML text so that the developer can visualize the flow using external tools.
   * @return {String} - Plant UML String
   */
  toPlantUML() {
    let _return = '';
    _return += '@startuml\n\n';
    this.steps.forEach(step => (_return += `state "${step.title}" as ${step.id} : ${step.description}\n`));

    _return += `\n`;
    _return += `[*] --> ${this.first.id}\n`;

    this.steps.forEach((step) => {
      step.paths.forEach((path) => {
        if (path.steps.length === 0) {
          _return += `${step.id} --> [*]\n`;
        }
        path.steps.forEach((_step) => {
          const conditions = (_step.conditions.map(s => `: (${Object.values(s).join(' ')})`)).join(' and ');
          _return += `${step.id} --> ${_step.step.id} : ${path.button_name}${conditions}\n`;
        });
      });
    });
    _return += `\n`;
    _return += '@enduml';
    return _return;
  }
}

export default Flow;