src/helpers/flow/FlowAnswer.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 FlowAnswer extends aggregate(FlowModel, Helper, Commentable) {
/**
* Constructor for FlowAnswer. 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 - FlowAnswer process settings
* @param {?String} [settings.transition='auto'] - Either 'auto' or 'manual', defaults to 'auto'
*/
constructor(response, settings = {}) {
super(response);
this._fields = {};
this._options = { owner_type: this.owner_type || 'mobile_user' };
this._settings = {
transition: 'auto'
};
this._status = 'initial';
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();
}
/**
* 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 newFlowAnswer = 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;
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));
});
}
/**
* 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('FlowAnswer 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('FlowAnswer 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();
}
});
}
}
export default FlowAnswer;