/* eslint-disable @typescript-eslint/ban-types */ import { DecimalPipe } from '@angular/common'; import { Injectable } from '@angular/core'; import { CurrencyService } from '@core/services/currency.service'; import { TimeZoneService } from '@core/services/time-zone.service'; import { FilterHelpersService, FilterModalTypes, FilterValue, SelectOption, TypeaheadSelectOption } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import { CurrencyValue } from '@yourcause/common/masking'; import { GuidService } from '@yourcause/common/utils'; import { get, isEqual, isUndefined } from 'lodash'; import moment from 'moment'; import { BehaviorSubject } from 'rxjs'; import { ConditionalLogicResultType, EvaluationType, GlobalLogicGroup, GlobalValueLogicGroup, ListLogicForColumn, ListLogicState, LogicColumn, LogicColumnDisplay, LogicCondition, LogicEvaluationTypeDisplayOptionsConditional, LogicEvaluationTypeDisplayOptionsValidity, LogicForColumn, LogicGroup, LogicRunResult, LogicState, LogicValueFormatType } from './logic-builder.typing'; import { LogicStateService } from './logic-state.service'; import { BaseLogicState } from './logic-state.typing'; export type GlobalLogicGroupType = GlobalLogicGroup|GlobalValueLogicGroup; @Injectable({ providedIn: 'root' }) export class LogicBuilderService { private decimal = new DecimalPipe('en-US'); constructor ( private logicStateService: LogicStateService, private guidService: GuidService, private i18n: I18nService, private filterHelperService: FilterHelpersService, private timezoneService: TimeZoneService, private currencyService: CurrencyService ) { } getResultTypeOptions (): TypeaheadSelectOption[] { return [{ label: this.i18n.translate( 'common:textSpecificValue', {}, 'Specific value' ), value: ConditionalLogicResultType.STATIC_VALUE }, { label: this.i18n.translate( 'common:textValueFromAnotherComponent', {}, 'Value from another component' ), value: ConditionalLogicResultType.OTHER_COLUMN }]; } getEvaluationTypeOptionsForFormTabs (): LogicEvaluationTypeDisplayOptionsConditional { return [{ label: this.i18n.translate('FORMS:lblAlwaysShowPage', {}, 'Always show page'), value: EvaluationType.AlwaysTrue }, { label: this.i18n.translate('FORMS:lblAlwaysHidePage', {}, 'Always hide page'), value: EvaluationType.AlwaysFalse }, { label: this.i18n.translate('FORMS:lblShowPageWhen', {}, 'Show page when'), value: EvaluationType.ConditionallyTrue }, { label: this.i18n.translate('FORMS:lblHidePageWhen', {}, 'Hide page when'), value: EvaluationType.ConditionallyFalse }]; } getEvaluationTypeOptionsForConditional (): LogicEvaluationTypeDisplayOptionsConditional { return [{ label: this.i18n.translate( 'FORMS:lblAlwaysShowComponent', {}, 'Always show component' ), value: EvaluationType.AlwaysTrue }, { label: this.i18n.translate( 'FORMS:lblAlwaysHideComponent', {}, 'Always hide component' ), value: EvaluationType.AlwaysFalse }, { label: this.i18n.translate( 'FORMS:lblShowComponentWhen', {}, 'Show component when' ), value: EvaluationType.ConditionallyTrue }, { label: this.i18n.translate( 'FORMS:lblHideComponentWhen', {}, 'Hide component when' ), value: EvaluationType.ConditionallyFalse }]; } getEvaluationTypeOptionsForValidity (): LogicEvaluationTypeDisplayOptionsValidity { return [{ label: this.i18n.translate( 'FORMS:lblAlwaysValid', {}, 'Always valid' ), value: EvaluationType.AlwaysTrue }, { label: this.i18n.translate( 'FORMS:lblValidWhen', {}, 'Valid when' ), value: EvaluationType.ConditionallyTrue }, { label: this.i18n.translate( 'FORMS:lblInvalidComponentWhen', {}, 'Invalid when' ), value: EvaluationType.ConditionallyFalse }]; } getDefaultConditionalLogic (): GlobalLogicGroup { const group: GlobalLogicGroup = { evaluationType: EvaluationType.AlwaysTrue, useAnd: false, identifier: this.guidService.nonce(), conditions: [] }; return group; } getDefaultConditionalValueLogic ( logicValueFormatType?: LogicValueFormatType ): GlobalValueLogicGroup { let result = null; if (logicValueFormatType === 'checkbox') { result = false; } const group: GlobalValueLogicGroup = { evaluationType: EvaluationType.ConditionallyTrue, useAnd: false, identifier: this.guidService.nonce(), conditions: [], result: result as any, resultType: ConditionalLogicResultType.STATIC_VALUE }; return group; } getDefaultCondition (): LogicCondition> { return { sourceColumn: null, value: null, comparison: null, relatedColumn: null, useAnd: false, identifier: this.guidService.nonce() }; } /** * Kick off the *conditional* logic for every group in a set * * @param logicGroups Each portion of logic to be run, tuples of a LogicColumn and that column's LogicGroup * @param record Record used for evaluation * * @returns the state of the logic execution, containing a map of each column to its individual state, and a map containing each column's dependent groups. A dependent group is one that could change based on the value of the column */ runConditionalLogic ( logicGroups: (readonly [LogicColumn, GlobalLogicGroup])[], record: T ): LogicState { const sourceMap = new Map>(); const dependentMap = new Map[]>(); // go over each group and execute the logic logicGroups.forEach(([column, group]) => { const { result$, dependencies, previousResult } = this.executeConditionalLogicForGroup( group, record, undefined ); const logicForColumn: LogicForColumn = { result$, previousResult, dependencies, group, column }; sourceMap.set(column.join('.'), logicForColumn); // go over each dependent column and add this group to the map dependencies.forEach(dep => { let existing = dependentMap.get(dep.join('.')) ?? []; existing = [ ...existing, logicForColumn ]; dependentMap.set(dep.join('.'), existing); }); }); return { sourceMap, dependentMap }; } /** * Kick off the logic for every group in a set * * @param logicGroups Each portion of logic to be run, tuples of a LogicColumn and that column's LogicGroup * @param record Record used for evaluation * * @returns the state of the logic execution, containing a map of each column to its individual state, and a map containing each column's dependent groups. A dependent group is one that could change based on the value of the column */ runValueLogic ( logicGroups: (readonly [LogicColumn, GlobalValueLogicGroup[]])[], record: T ): ListLogicState { const sourceMap = new Map>(); const dependentMap = new Map[]>(); // go over each group and execute the logic logicGroups.forEach(([column, groups]) => { const { result$, dependencies, previousResult } = this.executeValueLogicForGroup( groups, record, undefined ); const logicForColumn: ListLogicForColumn = { result$, dependencies, previousResult, groups, column }; sourceMap.set(column.join('.'), logicForColumn); // go over each dependent column and add this group to the map dependencies.forEach(dep => { let existing = dependentMap.get(dep.join('.')) ?? []; existing = [ ...existing, logicForColumn ]; dependentMap.set(dep.join('.'), existing); }); }); return { sourceMap, dependentMap }; } /** * Extract the current value given the current state of logic * * @param column The column to be looked up * @param logicState The current state of all logic * @returns The current value of the column. Returns `null` if nothing exists */ getCurrentLogicValueOfColumn ( column: LogicColumn, logicState: BaseLogicState> ) { return this.logicStateService.getCurrentLogicValueOfColumn>(column.join('.'), logicState); } /** * Re-runs logic for groups that need to be run based on a single changed column * * @param changedColumn The column that triggered any logic * @param logicState The result from the previous execution * @param record Record used for evaluation * * @returns A new logic state */ rerunConditionalLogic ( changedColumn: LogicColumn, logicState: LogicState, record: T ) { // Get the groups that depend on the value of this column const { dependents } = this.logicStateService.getRecordsFromState( changedColumn.join('.'), logicState ); // execute each one and propagate the result dependents.forEach((dependentGroup) => { const result = this.evaluateGlobalLogicGroup( dependentGroup.group, record ); dependentGroup.previousResult = dependentGroup.result$.value; dependentGroup.result$.next(result); }); // return a new object (primarily to trigger angular change detection) return { ...logicState }; } /** * Re-runs logic for groups that need to be run based on a single changed column * * @param changedColumn The column that triggered any logic * @param logicState The result from the previous execution * @param record Record used for evaluation * * @returns A new logic state */ rerunValueLogic ( changedColumn: LogicColumn, logicState: ListLogicState, record: T ) { // Get the groups that depend on the value of this column const { dependents } = this.logicStateService.getRecordsFromState( changedColumn.join('.'), logicState ); // execute each one and propagate the result dependents.forEach((dependentGroup) => { const result = this.getValueFromConditionalValueGroups( dependentGroup.groups, record ); dependentGroup.previousResult = dependentGroup.result$.value; dependentGroup.result$.next(result); }); // return a new object (primarily to trigger angular change detection) return { ...logicState }; } /** * Extract dependent columns and run group initially * * @param visibilityGroup Column's group to be evaluated * @param record Record used for evaluation * * @returns A BehaviorSubject with the result of the evaluation and all of this group's dependent columns */ executeConditionalLogicForGroup ( group: GlobalLogicGroup, record: T, currentValue: boolean ): LogicRunResult { // pull out all of the conditions const extractedConditions = this.extractConditionsFromGroup(group); const dependencies = extractedConditions.reduce[]>((acc, condition) => { // return any column in any condition (plus the related column e.g. columnA == columnB) return [ ...acc, ...('relatedColumn' in condition ? [ condition.relatedColumn, condition.sourceColumn ] : [ condition.sourceColumn ]) ].filter(column => column !== null); }, []); // run the logic const result = this.evaluateGlobalLogicGroup( group, record ); const result$ = new BehaviorSubject(result); // return the result and any dependent columns this logic uses // so that we know when to re-run this logic return { result$, dependencies, previousResult: currentValue as boolean }; } /** * Extract dependent columns and run group initially * * @param visibilityGroup Column's group to be evaluated * @param record Record used for evaluation * * @returns A BehaviorSubject with the result of the evaluation and all of this group's dependent columns */ executeValueLogicForGroup ( groups: GlobalValueLogicGroup[], record: T, currentValue: V ): LogicRunResult { // pull out all of the conditions const extractedConditions = this.extractConditionsFromGroups( groups ); const conditionlessDependencies = this.extractConditionlessDependenciesFromGroups(groups); let dependencies = extractedConditions.reduce[]>((acc, condition) => { // return any column in any condition (plus the related column e.g. columnA == columnB) return [ ...acc, ...('relatedColumn' in condition ? [ condition.relatedColumn, condition.sourceColumn ] : [ condition.sourceColumn ]) ].filter(column => column !== null); }, []); if (conditionlessDependencies) { dependencies = [ ...dependencies, ...conditionlessDependencies ]; } // run the logic const result = this.getValueFromConditionalValueGroups( groups, record ); const result$ = new BehaviorSubject(result); // return the result and any dependent columns this logic uses // so that we know when to re-run this logic return { result$, dependencies, previousResult: currentValue }; } extractConditionsFromGroups ( conditionalValueGroups: GlobalValueLogicGroup[] ): LogicCondition>[] { let conditions: LogicCondition>[] = []; conditionalValueGroups.forEach((group) => { const groupConditions = this.extractConditionsFromGroup(group); conditions = [ ...conditions, ...groupConditions.map((_condition) => { return _condition; }) ]; }); return conditions; } extractConditionlessDependenciesFromGroups ( conditionalValueGroups: GlobalValueLogicGroup[] ): LogicColumn[] { const columns: LogicColumn[] = []; conditionalValueGroups.forEach((group) => { if (group.resultType === ConditionalLogicResultType.OTHER_COLUMN) { columns.push(group.result as any); } }); return columns; } /** * Get all source/related columns out of the conditions within a group * * @param group */ extractConditionsFromGroup ( group: LogicGroup ): LogicCondition>[] { return group.conditions .reduce>[]>((acc, conditionOrGroup) => { if ('conditions' in conditionOrGroup) { return [ ...acc, ...this.extractConditionsFromGroup(conditionOrGroup) ]; } return [ ...acc, conditionOrGroup ]; }, []); } /** * Go over each child condition/group and evaluate the conditions * * @param group * @param record Record used for evaluation */ evaluateConditionalGroup ( group: LogicGroup, record: T ): boolean { const result = group.conditions.reduce<{ state: boolean; wasAnd: boolean; }>((previousResult, conditionOrGroup) => { /** * This catches: * * Previous result was an "OR" (wasAnd == false) and the previous result passed (state == true) * e.g. `true || thisCondition` doesn't care about `thisCondition` since because of the `true ||` * * Previous result was an "AND" (wasAnd == true) and the previous result failed (state == false) * e.g. `false && thisCondition` doesn't care about `thisCondition` since because of the `false &&` * * Both of these scenarios mean we can skip this condition */ if (previousResult.wasAnd !== previousResult.state) { return { state: previousResult.state, wasAnd: conditionOrGroup.useAnd }; } let pass: boolean; // If this item is a group, re-run this function with the child group if ('conditions' in conditionOrGroup) { pass = this.evaluateConditionalGroup( conditionOrGroup, record ); } else { pass = this.evaluateConditionalCondition( conditionOrGroup, record ); } return { state: pass, wasAnd: conditionOrGroup.useAnd }; }, { state: true, wasAnd: true }).state; return result; } private getValueFromConditionalValueGroups ( conditionalValueGroups: GlobalValueLogicGroup[], record: T ): V { // TODO: this would be a good place to automatically sort conditional value groups // first, evaluate the ones where type is NOT always true // then evalue the always true const passingGroup = conditionalValueGroups.find((group) => { return this.evaluateGlobalLogicGroup( group, record ); }); if (passingGroup) { if ( passingGroup.resultType === ConditionalLogicResultType.STATIC_VALUE ) { return passingGroup.result; } else if ( passingGroup.resultType === ConditionalLogicResultType.OTHER_COLUMN ) { // this will return the value from whichever field is set in the config const valFromOtherField = this.getRecordValue(record, passingGroup.result as any); if (!!valFromOtherField) { if (passingGroup.resultConfig?.constant) { const numericConstant = passingGroup.resultConfig?.constant; const dateString = this.applyDateCalculation(valFromOtherField, passingGroup, numericConstant); return this.formatDate(dateString) as any; } else { return valFromOtherField; } } } else if (passingGroup.resultType === ConditionalLogicResultType.RELATIVE_DATE) { const today = moment().format(); const numericConstant = passingGroup.resultConfig?.constant; const dateString = this.applyDateCalculation(today, passingGroup, numericConstant); return this.formatDate(dateString) as any; } else { return passingGroup.result; } } return '' as any; } applyDateCalculation ( today: string, passingGroup: GlobalValueLogicGroup, numericConstant: number ): string { let dateString = today; if (passingGroup.resultConfig) { const constantUnits = passingGroup.resultConfig.constantUnits; switch (passingGroup.resultConfig.operator) { case 'plus': { dateString = moment(today).add(numericConstant, constantUnits).format(); } break; case 'minus': { dateString = moment(today).subtract(numericConstant, constantUnits).format(); break; } default: { dateString = today; break; } } } return dateString; } /** * Runs the high level group and applies the inverse if set, used for kicking off/re-running logic * * @param group A column's group * @param record Record used for evaluation */ private evaluateGlobalLogicGroup ( group: GlobalLogicGroupType, record: T ): boolean { if (!group) { return true; } switch (group.evaluationType) { case EvaluationType.AlwaysFalse: return false; case EvaluationType.AlwaysTrue: return true; case EvaluationType.ConditionallyTrue: return this.evaluateConditionalGroup( group, record ); case EvaluationType.ConditionallyFalse: return !this.evaluateConditionalGroup( group, record ); } } /** * Extracts values from a single condition and performs the comparisons * * @param condition The actual condition to be evaluated * @param record Record used for evaluation */ evaluateConditionalCondition ( condition: LogicCondition>, record: T ) { const comparisonValue = this.getComparisonValue(condition, record); const recordValue = this.getRecordValue(record, condition.sourceColumn); const numericRecordValue = this.getNumericValue(recordValue); const arrayRecordValue = this.getArrayRecordValue(recordValue); const numericComparisonValue = this.getNumericValue(comparisonValue); const comparison: FilterModalTypes = condition.comparison; switch (comparison) { case FilterModalTypes.isBlank: { return this.isEmpty(recordValue); } case FilterModalTypes.notBlank: { return !this.isEmpty(recordValue); } case FilterModalTypes.startsWith: { return (recordValue as string)?.startsWith(comparisonValue) ?? false; } case FilterModalTypes.endsWith: { return (recordValue as string)?.endsWith(comparisonValue) ?? false; } case FilterModalTypes.contains: { return (recordValue as string)?.includes(comparisonValue) ?? false; } case FilterModalTypes.multiValueIncludes: { return (arrayRecordValue).some((val: any) => { return (comparisonValue as any[])?.includes(val); }); } case FilterModalTypes.multiValueNotIncludes: { return (arrayRecordValue).every((val: any) => { return !(comparisonValue as any[])?.includes(val); }); } case FilterModalTypes.multiValueEquals: case FilterModalTypes.multiValueNotEquals: { const invert = comparison === FilterModalTypes.multiValueNotEquals; let exactMatch = false; // Multi picklists use arrays, Select boxes use objects if (recordValue instanceof Array) { exactMatch = comparisonValue?.every((val: string) => { return recordValue?.includes(val); }) && recordValue.length === comparisonValue.length; } else if (recordValue instanceof Object) { const recordValueLength = Object.keys(recordValue ?? {}).filter((item) => { return !!item; }).length; exactMatch = comparisonValue?.every((val: string) => { return recordValue[val]; }) && recordValueLength === comparisonValue.length; } return invert ? !exactMatch : exactMatch; } case FilterModalTypes.equals: { return this.directEquals( numericRecordValue ?? recordValue, numericComparisonValue ?? comparisonValue ); } case FilterModalTypes.notEqual: { return !this.directEquals( numericRecordValue ?? recordValue, numericComparisonValue ?? comparisonValue ); } case FilterModalTypes.greaterThan: { return numericRecordValue > numericComparisonValue; } case FilterModalTypes.greaterThanOrEquals: { return numericRecordValue >= numericComparisonValue; } case FilterModalTypes.lessThan: { return numericRecordValue < numericComparisonValue; } case FilterModalTypes.lessThanOrEquals: { return numericRecordValue <= numericComparisonValue; } case FilterModalTypes.between: case FilterModalTypes.Today: case FilterModalTypes.Tomorrow: case FilterModalTypes.LastWeek: case FilterModalTypes.ThisWeek: case FilterModalTypes.LastMonth: case FilterModalTypes.Last6Months: case FilterModalTypes.ThisMonth: case FilterModalTypes.LastYear: case FilterModalTypes.ThisYear: case FilterModalTypes.Last30Days: case FilterModalTypes.Last365Days: { let compareArray: [moment.Moment, moment.Moment]; if (comparison === FilterModalTypes.between) { compareArray = comparisonValue; } else { compareArray = this.filterHelperService.getClientSideRelativeDates( comparison ); } if (recordValue && moment.isMoment(recordValue)) { return recordValue.isBetween(compareArray[0], compareArray[1]); } return false; } } return null; } /** * * @param record: the record * @param sourceColumn: the source column * @returns the record value */ getRecordValue ( record: T, sourceColumn: LogicColumn ) { const value = get(record, sourceColumn); if (this.isCurrencyType(value)) { return value.amountForControl; } return value; } /** * * @param value: the value * @returns if it's a currency type value */ isCurrencyType (value: any): value is CurrencyValue { return value instanceof Object && 'amountForControl' in value && 'currency' in value; } /** * * @param condition: the condition * @param record: the record to get the value from * @returns the value from the record */ getComparisonValue ( condition: LogicCondition>, record: T ) { let comparisonValue: any = null; if ('relatedColumn' in condition && !!condition.relatedColumn) { comparisonValue = this.getRecordValue(record, condition.relatedColumn); } else if ('value' in condition) { comparisonValue = condition.value; } return comparisonValue; } // helpers isEmpty (recordValue: any): boolean { if (recordValue instanceof Object) { return Object.keys(recordValue).length === 0; } else if (recordValue instanceof Array) { return recordValue.length === 0; } return !recordValue && recordValue !== 0; } private directEquals ( recordValue: T, comparisonValue: T|T[] ) { // "One of" patch - If the stored comparison value is an array // And we are doing an "eq", then it is a oneOf filter if (comparisonValue instanceof Array && !(recordValue instanceof Array)) { return comparisonValue.some((v) => { return isEqual(v, recordValue); }); } return recordValue instanceof Array ? recordValue.every(value => (comparisonValue as T & any[]).includes(value)) : isEqual(recordValue, comparisonValue); } getNumericValue ( value: any ): number { const numericValue = (value ?? false) ? +value : 0; // Things like ['14-25'] were getting flagged as a date by moment // Forcing it to be an ISO will actually tell us if it's one of our dates const dateValue = value ? moment(value, moment.ISO_8601) : null; if (!isNaN(numericValue)) { return numericValue; } if (dateValue?.isValid()) { return +dateValue; } return null; } private getArrayRecordValue ( value: any ) { if (!(value instanceof Array)) { if (!value) { return []; } if (value instanceof Object) { return Object.keys(value).filter((key) => { return value[key]; }).map((key) => { return key; }); } } return value; } getHasConditionalLogic (evaluationType: EvaluationType) { return [ EvaluationType.ConditionallyTrue, EvaluationType.ConditionallyFalse ].includes(evaluationType); } getHasValidConditions (logic: GlobalLogicGroupType) { if (this.getHasConditionalLogic(logic?.evaluationType)) { const conditions = this.extractConditionsFromGroup(logic); return conditions.every((condition) => { return !!condition.sourceColumn && !!condition.comparison; }); } return false; } hasValidResult (result: V) { return !isUndefined(result) && result !== null && (result as any) !== ''; } getLogicAsSentence ( logic: GlobalLogicGroupType, availableColumns: LogicColumnDisplay[], options: (TypeaheadSelectOption|SelectOption)[], logicValueFormatType: LogicValueFormatType, noLogicMessage = this.i18n.translate( 'common:textAddNewRuleOrConditionAlert', {}, 'Click "Add new rule" to create a complex set of rules or "Add condition" to create a simple condition' ), fallbackToNoLogicMessage = false ): string { const hasConditionalLogic = this.getHasConditionalLogic(logic?.evaluationType); const isResult = this.checkLogicIsValueLogic(logic); if (hasConditionalLogic || isResult) { const hasValidResult = isResult ? this.hasValidResult( (logic as GlobalValueLogicGroup).result ) : true; const hasValidConditions = this.getHasValidConditions(logic); if (logic.evaluationType === EvaluationType.AlwaysTrue) { // if we have a result and it's always TRUE, we are showing a simple statement of that value return this.constructSummarySentenceWithResult( logic as GlobalValueLogicGroup, availableColumns, options, logicValueFormatType ); } else if (logic?.conditions.length === 0) { return noLogicMessage; } else if ((logic as GlobalValueLogicGroup)?.resultType === ConditionalLogicResultType.RELATIVE_DATE) { return this.constructSummarySentenceWithResult( logic as GlobalValueLogicGroup, availableColumns, options, logicValueFormatType ); } else if (hasValidResult && hasValidConditions) { if (isResult) { return this.constructSummarySentenceWithResult( logic as GlobalValueLogicGroup, availableColumns, options, logicValueFormatType ); } else { return this.constructSummarySentence( logic, availableColumns ); } } } return fallbackToNoLogicMessage ? noLogicMessage : ''; } private checkLogicIsValueLogic (logic: GlobalLogicGroupType): logic is GlobalValueLogicGroup { return 'result' in logic; } constructSummarySentence ( logic: GlobalLogicGroupType, availableColumns: LogicColumnDisplay[], depth = 0 ): string { const conditionalSentence = `${logic.conditions.reduce((sentence, group, index) => { let currentSentence = ''; if (!('conditions' in group)) { // This is a condition const foundColumn = availableColumns.find((col) => { return group.sourceColumn?.join('.') === col?.column.join('.'); }); if (foundColumn) { const filter = this.filterHelperService.getFilterOptionAndValue( { filterType: group.comparison, filterValue: ('value' in group ? group.value : '') as FilterValue }, foundColumn.type ); const filterDisplay = this.i18n.translate( filter.filterOption?.displayKey, {}, filter.filterOption?.display )?.toLowerCase() ?? ''; let value: any = ''; // used if evaluation is based on value of another comp let foundRelatedColumn: LogicColumnDisplay; if ('relatedColumn' in group && !!group.relatedColumn) { foundRelatedColumn = availableColumns.find((col) => { return group.relatedColumn.join('.') === col?.column.join('.'); }); if (foundRelatedColumn) { value = foundRelatedColumn.label; } } else { value = 'value' in group ? group.value ?? '' : ''; } if (!foundRelatedColumn) { // proceed to get value for sentence unless it's based on another column (field) switch (foundColumn.type) { case 'date': value = this.formatDate(value as any); break; case 'list': case 'multi-list': case 'multiListFuzzyText': value = this.formatSelect(value, value, 'select', foundColumn.filterOptions); break; default: value = value; } } value = value ? ` ${value}` : value; currentSentence = `${foundColumn.label} ${filterDisplay}${value}`; } } else if (group.conditions.length > 0) { currentSentence = `(${this.constructSummarySentence( group as GlobalLogicGroupType, availableColumns, depth + 1 )})`; } sentence += currentSentence; const lastCondition = index === logic.conditions.length - 1; const addAndOrOr = ('conditions' in group ? group.conditions.length > 0 : true) && !lastCondition; if (addAndOrOr) { sentence += ` ${this.i18n.translate( group.useAnd ? 'GLOBAL:lblAND' : 'GLOBAL:lblOR', {}, group.useAnd ? 'AND' : 'OR' )} `; } return sentence; }, '')}`; return conditionalSentence; } constructSummarySentenceWithResult ( logic: GlobalValueLogicGroup, availableColumns: LogicColumnDisplay[], options: (TypeaheadSelectOption|SelectOption)[], logicValueFormatType: LogicValueFormatType, depth = 0 ) { const base = this.constructSummarySentence( logic, availableColumns, depth ); const result = (logic as GlobalValueLogicGroup).result; let value: string; // if the result is based on another component, we need to display "value of nameOfComponent" if (logic.resultType === ConditionalLogicResultType.OTHER_COLUMN) { const columnForSetValue = availableColumns.find((col) => { return col.column.join('.') === (logic.result as any)?.join('.'); }); if (columnForSetValue) { value = this.i18n.translate( 'common:textValueOfDynamic', { nameOfComponent: columnForSetValue.label }, 'value of __nameOfComponent__' ); } else { value = ''; } if (logic?.resultConfig?.constant) { const config = logic.resultConfig; value = value + ' ' + config.operator + ' ' + config.constant + ' ' + config.constantUnits; } } else if (logic.resultType === ConditionalLogicResultType.RELATIVE_DATE) { // get the number of days and construct a sentence value = this.i18n.translate( 'common:lblTodaysDate', {}, 'Today\'s Date' ); if (logic?.resultConfig?.constant) { const config = logic.resultConfig; value = value + ' ' + config.operator + ' ' + config.constant + ' ' + config.constantUnits; } } else { switch (logicValueFormatType) { default: case 'text': value = this.formatText(result as any); break; case 'select': value = this.formatSelect(result, value, logicValueFormatType, options); break; case 'checkbox': value = this.formatCheckbox(result as any); break; case 'date': value = this.formatDate(result as any); break; case 'number': value = this.formatNumber(result as any); break; case 'currency': value = this.formatCurrency(result as any); break; } } // Add line breaks if base sentence is present, otherwise only show 'Set value to ___' const sentence = base ? `${base}

Set value to ${value} ` : `Set value to ${value}`; return sentence; } private formatSelect ( result: T, value: string, logicValueFormatType: LogicValueFormatType, options: (TypeaheadSelectOption | SelectOption)[] ) { if (result instanceof Array) { value = this.formatValueForArray( result, logicValueFormatType, options ); } else if (result instanceof Object) { value = this.formatValueForObject( result, logicValueFormatType, options ); } else { const foundOption = this.getDisplayValueFromOptions( result as any, options ); if (foundOption) { value = foundOption; } else { value = value; } } return value; } formatText (value: string|string[]) { if (value instanceof Array) { return this.formatValueForArray( value as string[], 'text', [] ); } else { return value; } } formatNumber (value: number|number[]) { if (value instanceof Array) { return this.formatValueForArray( value as number[], 'number', [] ); } else { return this.decimal.transform(value); } } formatCurrency (value: CurrencyValue) { if (value) { return this.currencyService.formatMoney(value.amountForControl, value.currency); } return ''; } formatDate ( value: string|string[] ) { if (value instanceof Array) { value = value.map((val) => { return this.timezoneService.returnMidnightUTCDateShort( val.toString() ); }); value = value.join(' - '); } else { if (value) { value = this.timezoneService.returnMidnightUTCDateShort( value.toString() ); } } return value; } formatCheckbox ( value: string|boolean ) { if ( value === true || value === 'true' ) { return this.i18n.translate( 'common:textTrue', {}, 'True' ).toLowerCase(); } else { return this.i18n.translate( 'common:textFalse', {}, 'False' ).toLowerCase(); } } formatValueForObject ( result: V, type: LogicValueFormatType, options: (TypeaheadSelectOption|SelectOption)[] ) { const numberOfItems = Object.keys(result).filter((item) => { return (result as any)[item]; }).length; return Object.keys(result).reduce((acc, key, index) => { if ((result as any)[key]) { return this.accrueAnswersForSummary( acc, key, index, numberOfItems, options, type ); } return acc; }, ''); } formatValueForArray ( result: any[], type: LogicValueFormatType, options: (TypeaheadSelectOption|SelectOption)[] ) { return result.reduce((acc, value, index) => { return this.accrueAnswersForSummary( acc, value, index, result.length, options, type ); }, ''); } accrueAnswersForSummary ( accumulator: string, value: V, index: number, totalNumberOfItems: number, options: (TypeaheadSelectOption|SelectOption)[], type: LogicValueFormatType ) { let displayValue: string; switch (type) { case 'select': displayValue = this.getDisplayValueFromOptions( value as any, options ); break; case 'number': displayValue = this.formatNumber(value as any); break; case 'text': displayValue = this.formatText(value as any); break; } const returnString = `${accumulator}${displayValue}`; const isLastItem = (index + 1) === totalNumberOfItems; if (isLastItem) { return returnString; } else { return `${returnString}, `; } } getDisplayValueFromOptions ( value: string, options: (TypeaheadSelectOption|SelectOption)[] ) { const foundOption = options.find((option) => { return option.value === value; }); return foundOption?.label || (foundOption as SelectOption)?.display || ''; } /** * * @param otherColumnOptions: Other column options for condition * @param comparison: condition comparison * @returns if we should show the other column options selector */ shouldShowOtherColumnSelector ( otherColumnOptions: TypeaheadSelectOption[], comparison: FilterModalTypes ) { if (otherColumnOptions?.length > 0) { const nonColumOptionComparisons = [ FilterModalTypes.notBlank, FilterModalTypes.isBlank, FilterModalTypes.Yesterday, FilterModalTypes.Today, FilterModalTypes.Tomorrow, FilterModalTypes.LastWeek, FilterModalTypes.ThisWeek, FilterModalTypes.LastMonth, FilterModalTypes.Last6Months, FilterModalTypes.ThisMonth, FilterModalTypes.LastYear, FilterModalTypes.ThisYear, FilterModalTypes.Last30Days, FilterModalTypes.Last365Days ]; return !nonColumOptionComparisons.includes(comparison); } return false; } }