Manual Reference Source Test

src/helpers/flow/FlowStep.js

import AnalyticsService from '../../services/AnalyticsService';
import Client from '../../Client';
// import DiscussService from '../../services/DiscussService';
import FlowStepModel from '../../models/flow/FlowStep';
import FlowService from '../../services/FlowService';
import Commentable from '../../utilities/mixins/Commentable';
import Helper from '../Helper';
import { aggregate } from '../../utilities/ClassUtility';
import FlowInput from './FlowInput';
import { evaluate } from '../../utilities/ConditionUtility';
import { validate, isValid } from '../../utilities/ValidationUtility';

/**
 * Helper class representing a flow step object.
 * Required values include:
 * - id
 * - title
 * - description
 * - content_type
 * - image
 * - inputs
 * - paths
 */
class FlowStep extends aggregate(FlowStepModel, Helper, Commentable) {
  /**
   * Constructor for FlowStep
   *
   * @param {!Object} response                  - Expects all required fields needed for
   *                                              FlowStepModel
   * @param {Number}  response.id               - The id of the step
   * @param {Object}  response.title            - The title of the step
   * @param {Object}  response.description      - Description, if any,  that explain how to answer
   *                                              the step
   * @param {String}  response.content_type     - The format of the step (e.g. single choice,
   *                                              multiple choice)
   * @param {Array}   response.choices          - An array of inputs available to the user
   * @param {Array}   response.paths            - An array of next steps available to the user
   * @param {Boolean} response.send_on_capture  - Save now or later
   * @param {?Flow}   flow                      - Required to link up fields to the flow.
   */
  constructor(response, flow) {
    super(response);
    /** @type {Object} */
    this._options = {};

    /** @type {Flow} */
    this.flow = flow;

    if (this.flowProcessId) {
      this.flow.id = this.flowProcessId;
    }

    /** @type {FlowInput[]} */
    this.inputs = this.inputs.map(obj => new FlowInput(obj));

    if (flow) {
      this.initFields();
    }

    if (this.flow && this.flow.existingId) {
      this.existingId = this.flow.existingId;
    }
  }

  /**
   * Gets the existing id for this FlowStep (in _options)
   * @type {Number}
   */
  get existingId() {
    return this._options.existing_id;
  }

  /**
   * Set the existing id for this FlowStep (id)
   * @type {Number}
   */
  set existingId(id) {
    this._options.existing_id = id;
  }

  /**
   * Get Fields used only for this step
   * @type {Object} Key/Value pair for quick retrival of values
   */
  get fields() {
    const _return = {};
    this.inputs.forEach(input => (_return[input.id] = this.flow._fields[input.id]));
    return _return;
  }

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

  /**
   * Save fields for later
   */
  addFieldsToSave() {
    let fields = Object.keys(this.fields).map(k => this.fields[k]); // turn fields into array
    fields = fields.filter(obj => (obj.value !== undefined));
    this.flow.addFieldsToSave(fields);
  }

  /**
   * 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.flow._fields[key]) {
      return this.flow._fields[key].value;
    }
    return null;
  }

  /**
   * Get Next Step based on an array of steps and the conditions belonging to it
   *
   * @param {Array<any>} steps
   * @return null or a FlowStep
   */
  getNextStep(steps) {
    for (const s of steps) { // eslint-disable-line no-restricted-syntax
      try {
        if (s.conditions.every(c => evaluate(this.get(c.criteria), c.operator, c.value))) {
          return s.step;
        }
      } catch (e) {
        throw new Error('FlowStep: Failure to evaluate conditions');
      }
    }
    return null;
  }

  /**
   * Get Path given the name of the path
   *
   * @param  {String} name The name of the path
   * @return {Object}      The path object with conditions, steps, and capture
   */
  getPath(name) {
    const _filter = this.paths.filter(obj => obj.button_name === name);
    if (_filter.length > 0) {
      return _filter[0];
    }
    return _filter;
  }

  /**
   * Go to a node given the path name
   *
   * @param  {String} name Name of the button
   * @return {Promise<FlowStep>}      The next step or null.
   */
  go(name) {
    const path = this.getPath(name);
    const self = this;
    let saved = false;

    return new Promise((resolve, reject) => {
      // get the next step
      const step = (self.hasSteps(path) > 0) ? self.getNextStep(path.steps) : null;

      // If there are fields, validate, and either save or prepare to save
      if (self.hasFields()) {
        self.validateRequiredFields(path);
        if (self.isAutoSave(path)) {
          saved = true;
          self.save().then(() => resolve(step), response => reject(response));
        } else if (self.sendOnCapture === true) {
          self.addFieldsToSave();
        }
      }

      // If we are not waiting for save, then resolve the step
      if (!saved) {
        resolve(step);
      }
    }).then(step => new Promise((resolve, reject) => {
      if (!saved && self.flow.status === 'initial') {
        // If nothing was saved and the flow status is 'initial', start the flow
        //   so the status is "in progress"
        self.flow.start().then(() => resolve(step), response => reject(response));
      } else if (!saved && !step) {
        // If there are no more steps and nothing was saved, manually complete the flow
        self.flow.complete().then(() => resolve(step), response => reject(response));
      } else {
        resolve(step);
      }
    })).then((step) => {
      // If there are no more steps, end the flow
      if (!step) {
        self.flow.postInsights('End');
        self.flow.end(self);
      }
      return step;
    });
  }

  /**
   * Has Fields
   *
   * @return {Boolean} True if the step has fields, otherwise false
   */
  hasFields() {
    return Object.keys(this.fields).length > 0;
  }

  /**
   * Has Steps
   *
   * @param  {Object}  path Path object
   *
   * @return {Boolean}      True if the path has steps
   */
  hasSteps(path) {
    return path.steps.length > 0;
  }

  /**
   * Initializes the fields for insertion
   */
  initFields() {
    const self = this;
    this.inputs.forEach((input) => {
      // TODO need to look at if question types require different input
      const { id } = input;
      if (!{}.hasOwnProperty.call(self.flow._fields, id)) {
        self.flow._fields[id] = { input_id: id };
      }
    });
  }

  /**
   * Is AutoSave will return whether or not the path has capture on or off
   *
   * @param  {Object}  path Path object
   *
   * @return {Boolean}      True if autosave / capture is on for the path
   */
  isAutoSave(path) {
    return path.capture || path.capture === 'true';
  }

  /**
   * Indicates whether a field is required based on the flowstep.input array
   *
   * @param {String} id - The input id
   *
   * @return {Boolean} - Returns true if the input is required else false
   */
  isFieldRequired(id) {
    return this.inputs.filter(obj => (obj.id === id && obj.required === true)).length > 0;
  }

  /**
   * Is run after a successful save (FlowStep Scope).
   *
   * @param {Object} results - results of the save event
   *
   * @return {Object} - returns the results
   */
  onSave(results) {
    return results;
  }

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

  /**
   * Save makes a server call to update/insert the current fields.
   * This requires that the array is [{input_id: <input_id>, value: <value>},...]
   *
   * @return {Promise} A Promise returning success or failure from FlowService
   */
  save() {
    this.addFieldsToSave();
    return new Promise((resolve, reject) => {
      (new FlowService()).collectFields(this.flow, this.flow._toSave).then(
        (response) => {
          this.flow._toSave.length = 0; // no longer have anything to save
          if (response.data && response.data.process_status) {
            this.flow.status = response.data.process_status;
          }
          this.onSave(response);
          this.postInsights('Collect');
          resolve(response);
        },
        reason => reject(reason)
      );
    });
  }

  /**
   * Set value to main key
   *
   * @param {String} key - Key must be 'input_id'
   * @param {Any} value - This must be the expected value for this question type.
   * @param {?Number} existingId - The existing id to update an object to
   */
  set(key, value, existingId = undefined) {
    let _value = value;

    // See if it's the id of a single or multiple choice question
    // TODO remove this after Platform is updated to return id
    const inputs = this.inputs.filter(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.flow._fields[key].value = _value;

    // Set the existing id
    if (existingId || this.existingId || this.flow.existingId) {
      this.flow._fields[key].existing_id = existingId || this.existingId || this.flow.existingId;
    } else if ('existing_id' in this.flow._fields[key]) {
      delete this.flow._fields[key].existing_id;
    }
  }

  /**
   * Validate Required Fields for a path.  If the path has fields to be captured and are empty,
   * throw an error indicating which path and what fields.
   *
   * @param {any} path - The Path the flow step has taken to see if related inputs are required.
   */
  validateRequiredFields(path) {
    // turn fields into array
    let fields = Object.keys(this.fields).map(k => this.fields[k]);
    // let fields = this.flows.toSave;

    // get list of culprits
    fields = fields.filter(obj => (
      (obj.value === undefined || obj.value === '' || obj.value === null)
      && this.isFieldRequired(obj.input_id)
    ));

    // get array of field ids
    const fieldIdsArray = fields.map(obj => obj.input_id);

    // convert to readable text `${input.title} (id: ${input.id})`
    const fieldString = JSON.stringify(this.inputs
      .filter(input => fieldIdsArray.indexOf(input.id) !== -1) // get the FlowInput information
      .map(input => `'${input.title}' (id: ${input.id})`)) // stringify culprits
      .replace(/["[\]\\]/g, '') // replace quotes and array elements
      .replace(/[,]/g, '$& '); // add spaces to commas if exists


    validate('Clinical6 FlowStep Helper',
      isValid(fields.length === 0
        || !((this.isAutoSave(path) || this.sendOnCapture === true) && fields.length >= 0),
      `cannot proceed to path '${path.button_name}' with empty required field(s) ${fieldString}`));
  }
}

export default FlowStep;