///
///
namespace cf {
// interface
export interface FlowDTO{
tag?: ITag | ITagGroup,
text?: string;
errorText?: string;
input?: UserInputElement,
controlElements?: Array ;
}
export interface FlowManagerOptions{
cfReference: ConversationalForm;
eventTarget: EventDispatcher;
tags: Array;
flowStepCallback?: (dto: FlowDTO, success: () => void, error: (optionalErrorMessage?: string) => void) => void;
}
export const FlowEvents = {
USER_INPUT_UPDATE: "cf-flow-user-input-update",
USER_INPUT_INVALID: "cf-flow-user-input-invalid",
// detail: string
FLOW_UPDATE: "cf-flow-update",
FORM_SUBMIT: "cf-form-submit",
// detail: ITag | ITagGroup
}
// class
export class FlowManager {
private static STEP_TIME: number = 1000;
private flowStepCallback: (dto: FlowDTO, success: () => void, error: (optionalErrorMessage?: string) => void) => void;
private eventTarget: EventDispatcher;
private cfReference: ConversationalForm;
private tags: Array;
private stopped: boolean = false;
private maxSteps: number = 0;
private step: number = 0;
private savedStep: number = -1;
private stepTimer: number = 0;
/**
* ignoreExistingTags
* @type boolean
* ignore existing tags, usually this is set to true when using startFrom, where you don't want it to check for exisintg tags in the list
*/
private ignoreExistingTags: boolean = false;
private userInputSubmitCallback: () => void;
public get currentTag(): ITag | ITagGroup {
return this.tags[this.step];
}
constructor(options: FlowManagerOptions){
this.cfReference = options.cfReference;
this.eventTarget = options.eventTarget;
this.flowStepCallback = options.flowStepCallback;
this.setTags(options.tags);
this.userInputSubmitCallback = this.userInputSubmit.bind(this);
this.eventTarget.addEventListener(UserInputEvents.SUBMIT, this.userInputSubmitCallback, false);
}
public userInputSubmit(event: CustomEvent){
ConversationalForm.illustrateFlow(this, "receive", event.type, event.detail);
let appDTO: FlowDTO = event.detail;
if(!appDTO.tag)
appDTO.tag = this.currentTag;
let isTagValid: Boolean = this.currentTag.setTagValueAndIsValid(appDTO);
let hasCheckedForTagSpecificValidation: boolean = false;
let hasCheckedForGlobalFlowValidation: boolean = false;
const onValidationCallback = () =>{
// check 1
if(this.currentTag.validationCallback && typeof this.currentTag.validationCallback == "function"){
if(!hasCheckedForTagSpecificValidation && isTagValid){
hasCheckedForTagSpecificValidation = true;
this.currentTag.validationCallback(appDTO, () => {
isTagValid = true;
onValidationCallback();
}, (optionalErrorMessage?: string) => {
isTagValid = false;
if(optionalErrorMessage)
appDTO.errorText = optionalErrorMessage;
onValidationCallback();
});
return;
}
}
// check 2, this.currentTag.required <- required should be handled in the callback.
if(this.flowStepCallback && typeof this.flowStepCallback == "function"){
if(!hasCheckedForGlobalFlowValidation && isTagValid){
hasCheckedForGlobalFlowValidation = true;
// use global validationCallback method
this.flowStepCallback(appDTO, () => {
isTagValid = true;
onValidationCallback();
}, (optionalErrorMessage?: string) => {
isTagValid = false;
if(optionalErrorMessage)
appDTO.errorText = optionalErrorMessage;
onValidationCallback();
});
return;
}
}
// go on with the flow
if(isTagValid){
// do the normal flow..
ConversationalForm.illustrateFlow(this, "dispatch", FlowEvents.USER_INPUT_UPDATE, appDTO)
// update to latest DTO because values can be changed in validation flow...
if(appDTO.input)
appDTO = appDTO.input.getFlowDTO();
this.eventTarget.dispatchEvent(new CustomEvent(FlowEvents.USER_INPUT_UPDATE, {
detail: appDTO //UserTextInput value
}));
// goto next step when user has answered
setTimeout(() => this.nextStep(), ConversationalForm.animationsEnabled ? 250 : 0);
}else{
ConversationalForm.illustrateFlow(this, "dispatch", FlowEvents.USER_INPUT_INVALID, appDTO)
// Value not valid
this.eventTarget.dispatchEvent(new CustomEvent(FlowEvents.USER_INPUT_INVALID, {
detail: appDTO //UserTextInput value
}));
}
}
// TODO, make into promises when IE is rolling with it..
onValidationCallback();
}
public startFrom(indexOrTag: number | ITag, ignoreExistingTags: boolean = false){
if(typeof indexOrTag == "number")
this.step = indexOrTag;
else{
// find the index..
this.step = this.tags.indexOf(indexOrTag);
}
this.ignoreExistingTags = ignoreExistingTags;
if(!this.ignoreExistingTags){
this.editTag(this.tags[this.step]);
}else{
//validate step, and ask for skipping of current step
this.showStep();
}
}
/**
* @name editTag
* @param tagWithConditions, the tag containing conditions (can contain multiple)
* @param tagConditions, the conditions of the tag to be checked
*/
private activeConditions: any;
public areConditionsInFlowFullfilled(tagWithConditions: ITag, tagConditions: Array ): boolean{
if(!this.activeConditions){
// we don't use this (yet), it's only to keep track of active conditions
this.activeConditions = [];
}
let numConditionsFound: number = 0;
// find out if tagWithConditions fullfills conditions
for(var i = 0; i < this.tags.length; i++){
const tag: ITag | ITagGroup = this.tags[i];
if(tag !== tagWithConditions){
// check if tags are fullfilled
for (var j = 0; j < tagConditions.length; j++) {
let tagCondition: ConditionalValue = tagConditions[j];
// only check tags where tag id or name is defined
const tagName: string = (tag.name || tag.id || "").toLowerCase();
if(tagName !== "" && "cf-conditional-"+tagName === tagCondition.key.toLowerCase()){
// key found, so check condition
const flowTagValue: string | string[] = typeof tag.value === "string" ? ( tag).value : ( tag).value;
let areConditionsMeet: boolean = Tag.testConditions(flowTagValue, tagCondition);
if(areConditionsMeet){
this.activeConditions[tagName] = tagConditions;
// conditions are meet
if(++numConditionsFound == tagConditions.length){
return true;
}
}
}
}
}
}
return false;
}
public start(){
this.stopped = false;
this.validateStepAndUpdate();
}
public stop(){
this.stopped = true;
}
public nextStep(){
if(this.stopped)
return;
if(this.savedStep != -1){
// if you are looking for where the none EDIT tag conditionsl check is done
// then look at a tags disabled getter
let foundConditionsToCurrentTag: boolean = false;
// this happens when editing a tag..
// check if any tags has a conditional check for this.currentTag.name
for (var i = 0; i < this.tags.length; i++) {
var tag: ITag | ITagGroup = this.tags[i];
if(tag !== this.currentTag && tag.hasConditions()){
// tag has conditions so check if it also has the right conditions
if(tag.hasConditionsFor(this.currentTag.name)){
foundConditionsToCurrentTag = true;
this.step = this.tags.indexOf(this.currentTag);
break;
}
}
}
// no conditional linking found, so resume flow
if(!foundConditionsToCurrentTag){
this.step = this.savedStep;
}
}
this.savedStep = -1;//reset saved step
this.step++;
this.validateStepAndUpdate();
}
public previousStep(){
this.step--;
this.validateStepAndUpdate();
}
public getStep(): number{
return this.step;
}
public addTags(tags: Array, atIndex: number = -1) : Array{
// used to append new tag
if(atIndex !== -1 && atIndex < this.tags.length){
const pre: Array = this.tags.slice(0, atIndex)
const post: Array = this.tags.slice(atIndex, this.tags.length)
this.tags = this.tags.slice(0, atIndex).concat(tags).concat(post);
}else{
this.tags = this.tags.concat(tags);
}
this.setTags(this.tags);
return this.tags;
}
public dealloc(){
this.eventTarget.removeEventListener(UserInputEvents.SUBMIT, this.userInputSubmitCallback, false);
this.userInputSubmitCallback = null;
}
/**
* @name editTag
* go back in time and edit a tag.
*/
public editTag(tag: ITag): void {
this.ignoreExistingTags = false;
this.savedStep = this.step - 1;//save step
this.step = this.tags.indexOf(tag); // === this.currentTag
this.validateStepAndUpdate();
if(this.activeConditions && Object.keys(this.activeConditions).length > 0){
this.savedStep = -1;//don't save step, as we wont return
// clear chatlist.
this.cfReference.chatList.clearFrom(this.step + 1);
//reset from active tag, brute force
const editTagIndex: number = this.tags.indexOf(tag);
for(var i = editTagIndex + 1; i < this.tags.length; i++){
const tag: ITag | ITagGroup = this.tags[i];
tag.reset();
}
}
}
private setTags(tags: Array){
this.tags = tags;
for(var i = 0; i < this.tags.length; i++){
const tag: ITag | ITagGroup = this.tags[i];
tag.eventTarget = this.eventTarget;
tag.flowManager = this;
}
this.maxSteps = this.tags.length;
}
private skipStep(){
this.nextStep();
}
private validateStepAndUpdate(){
if(this.maxSteps > 0){
if(this.step == this.maxSteps){
// console.warn("We are at the end..., submit click")
this.eventTarget.dispatchEvent(new CustomEvent(FlowEvents.FORM_SUBMIT, {}));
this.cfReference.doSubmitForm();
}else{
this.step %= this.maxSteps;
if(this.currentTag.disabled){
// check if current tag has become or is disabled, if it is, then skip step.
this.skipStep();
}else{
this.showStep();
}
}
}
}
private showStep(){
if(this.stopped)
return;
ConversationalForm.illustrateFlow(this, "dispatch", FlowEvents.FLOW_UPDATE, this.currentTag);
this.currentTag.refresh();
setTimeout(() => {
this.eventTarget.dispatchEvent(new CustomEvent(FlowEvents.FLOW_UPDATE, {
detail: {
tag: this.currentTag,
ignoreExistingTag: this.ignoreExistingTags,
step: this.step,
maxSteps: this.maxSteps
}
}));
}, 0);
}
}
}