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;