import { Injectable } from '@angular/core'; import { BaseApplication } from '@core/typings/application.typing'; import { AdHocReportingUI } from '@core/typings/ui/ad-hoc-reporting.typing'; import { ReferenceFieldsUI } from '@core/typings/ui/reference-fields.typing'; import { ClientSettingsService } from '@features/client-settings/client-settings.service'; import { CreateEditFormModalResponse, StartFromFormTemplate } from '@features/configure-forms/create-edit-form-modal/create-edit-form-modal.component'; import { AdaptedForm, AdaptedFormDefResponse, BaseApplicationForLogic, BaseFormForMigration, BasicForm, ExportForm, Form, FormAudience, FormDefinitionComponent, FormDefinitionForUi, FormDefinitionWithLogic, FormioAnswerValues, FormioChangesWithCompKey, LogicGroupForForm, SaveForm, SaveFormResponseObj, ValueLogicResult } from '@features/configure-forms/form.typing'; import { CustomDataTablesService } from '@features/custom-data-tables/custom-data-table.service'; import { PicklistDataType } from '@features/custom-data-tables/custom-data-tables.typing'; import { ExternalAPIService } from '@features/external-api/external-api.service'; import { DefaultValType } from '@features/formio/component-configuration/component-configuration.typing'; import { ExternalAPISelection } from '@features/formio/component-configuration/external-api-selector-settings/external-api-selector-settings.component'; import { FormulaBuilderService } from '@features/formula-builder/formula-builder.service'; import { FormulaState, RootFormula } from '@features/formula-builder/formula-builder.typing'; import { LogicBuilderService } from '@features/logic-builder/logic-builder.service'; import { EvaluationType, GlobalLogicGroup, GlobalValueLogicGroup, ListLogicState, LogicColumn, LogicColumnDisplay, LogicFilterTypes, LogicState, NestedPropColumn, SelectLogicColumnDisplay } from '@features/logic-builder/logic-builder.typing'; import { ReferenceFieldsService } from '@features/reference-fields/services/reference-fields.service'; import { ArrayHelpersService, FilterModalTypes, TypeaheadSelectOption } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import { LogService } from '@yourcause/common/logging'; import { CurrencyRadioOptions, CurrencyValue } from '@yourcause/common/masking'; import { NotifierService } from '@yourcause/common/notifier'; import { AttachYCState, BaseYCService } from '@yourcause/common/state'; import { GuidService } from '@yourcause/common/utils'; import { isEqual, uniq } from 'lodash'; import { v4 } from 'uuid'; import { ComponentHelperService, EMPLOYEE_SSO_TYPE_PREFIX } from '../component-helper/component-helper.service'; import { FormLogicResources } from './form-logic.resources'; import { FormLogicState } from './form-logic.state'; @AttachYCState(FormLogicState) @Injectable({ providedIn: 'root' }) export class FormLogicService extends BaseYCService { constructor ( private referenceFieldService: ReferenceFieldsService, private formulaBuilderService: FormulaBuilderService, private logicBuilderService: LogicBuilderService, private componentHelper: ComponentHelperService, private customDataTableService: CustomDataTablesService, private arrayHelper: ArrayHelpersService, private i18n: I18nService, private guidService: GuidService, private logger: LogService, private clientSettingsService: ClientSettingsService, private formLogicResources: FormLogicResources, private externalApiService: ExternalAPIService, private notifier: NotifierService ) { super(); } get formDetail () { return this.get('formDetails'); } get reportFieldLogicColumns () { return this.get('reportFieldLogicColumns'); } get pageOptionsMap () { return this.get('pageOptionsMap'); } /** * * @param component form field component * @returns boolean of whether we should skip the value related logic for this component. If the component is hidden and set to clear on hide, then we don't need to set the value of that component, it should stay cleared */ shouldSkipValueLogic (component: FormDefinitionComponent) { return (component.isHidden || component.hiddenFromParent) && component.clearOnHide; } setPageOptionsMap (map: Record[]>) { this.set('pageOptionsMap', map); } /** * * @param id: form id * @param revisionId: revision id * @returns the form detail */ getFormDetail (id: number, revisionId: number) { return this.formDetail[+('' + id + revisionId)]; } /** * * @param id: form id * @param revisionId: revision id * @param form: the form to set */ setFormDetail (id: number, revisionId: number, form: Form) { this.set('formDetails', { ...this.formDetail, ['' + id + revisionId]: form }); } /** * * @param formId: form id * @param revisionId: revision id * @returns the fetched form */ async getAndSetForm ( formId: number, revisionId: number ): Promise
{ const form = this.getFormDetail(formId, revisionId); if (!form) { await this.fetchFormDetail(formId, revisionId); } return this.getFormDetail(formId, revisionId); } /** * * @param formId: form id * @param revisionId: revision id * @returns the form detail */ async fetchFormDetail ( formId: number, revisionId: number ) { const details = await this.formLogicResources.getForm(formId, revisionId); const adapted = this.adaptFormForTabs( details, formId, revisionId ); this.setFormDetail(formId, revisionId, adapted); return adapted; } /** * * @param formDefinition: form definition * @returns the form schema */ getFormSchema (formDefinition: FormDefinitionForUi[]) { const formSchema: { key: string; label: string; type: string; values: any[]; }[] = []; formDefinition.forEach((tab) => { this.componentHelper.eachComponent( tab.components, (component) => { const inputData = this.componentHelper.getComponentInputData(component); if (inputData) { formSchema.push(inputData); } }, true); }); return formSchema; } /** * * @param response: create form modal response * @returns the new form route */ async handleCreateForm ( response: CreateEditFormModalResponse ) { const { formDefinition, formSchema, referenceFieldIds, externalApiRequestIds, picklistGuids } = await this.getFormDetailsForCreate( response.startFromTemplate ); try { const payload: SaveForm = { revisionId: null, id: null, name: response.formName, description: response.description ?? '', formType: response.formType, formDefinition, formSchema, availableForTranslation: true, defaultLanguageId: response.defaultFormLang, picklistGuids, referenceFieldIds, externalApiRequestIds, requireSignature: response.requireSignature, signatureDescription: response.signatureDescription }; const form = await this.formLogicResources.saveForm(payload); this.notifier.success(this.i18n.translate( 'FORMS:textSuccessCreateForm', {}, 'Successfully created the custom form' )); return `/management/program-setup/forms/${form.id}/draft`; } catch (e) { this.notifier.error(this.i18n.translate( 'FORMS:textErrorCreatingForm', {}, 'There was an error creating the custom form' )); return null; } } /** * * @param startFromTemplate: template to start from * @returns form details for create */ async getFormDetailsForCreate ( startFromTemplate: StartFromFormTemplate ): Promise<{ formDefinition: FormDefinitionForUi[]; formSchema: any; referenceFieldIds: number[]; externalApiRequestIds: number[]; picklistGuids: string[]; }> { if (startFromTemplate?.revisionId) { const details = await this.getAndSetForm( startFromTemplate.formId, startFromTemplate.revisionId ); const { referenceFieldIds, customDataTableGuids } = this.referenceFieldService.extractReferenceFieldsFromForm( details.formDefinition ); const externalApiRequests = this.getExternalAPICalls( details.formDefinition ); return { formDefinition: details.formDefinition, formSchema: this.getFormSchema(details.formDefinition), referenceFieldIds, externalApiRequestIds: this.externalApiService.extractIds(externalApiRequests), picklistGuids: customDataTableGuids }; } return { formDefinition: [{ tabName: 'Page One', components: [], uniqueId: v4(), index: 0, logic: null }], formSchema: [], referenceFieldIds: [], externalApiRequestIds: [], picklistGuids: [] }; } /** * * @param e: the error * @returns if there is a pending form error */ handleSaveFormError (e: any): boolean { if ( e.error && e.error.message === 'This form already has a draft revision pending to be published' ) { return true; } else { this.notifier.error(this.i18n.translate( 'FORMS:textErrorUpdatingTheForm', {}, 'There was an error updating the form' )); return false; } } /** * * @param formId: form id * @param revisionId: revision id */ async resetAfterSave (formId: number, revisionId: number) { this.setFormDetail(formId, revisionId, undefined); } /** * * @param data: payload to save form * @returns response details */ async saveFormNewRevision ( data: SaveForm ): Promise { try { const response = await this.formLogicResources.saveForm(data); await this.resetAfterSave(data.id, data.revisionId); return { success: true, id: response?.id, hasPendingFormError: false }; } catch (e) { this.logger.error(e); const hasPendingFormError = this.handleSaveFormError(e); return { success: false, id: null, hasPendingFormError }; } } /** * * @param data: payload to save form * @returns response details */ async saveFormExistingRevision ( data: SaveForm ): Promise { try { const response = await this.formLogicResources.updateRevision( data.id, data.revisionId, data ); await this.resetAfterSave(data.id, data.revisionId); return { success: true, id: response?.id, hasPendingFormError: false }; } catch (e) { this.logger.error(e); const hasPendingFormError = this.handleSaveFormError(e); return { success: false, id: null, hasPendingFormError }; } } /** * * @param formInfo: form info to copy * @returns response details */ async copyForm ( formInfo: BasicForm ) { const form = await this.fetchFormDetail( formInfo.formId, formInfo.revisionId ); const { referenceFieldIds, customDataTableGuids } = this.referenceFieldService.extractReferenceFieldsFromForm( form.formDefinition ); const externalApiRequests = this.getExternalAPICalls(form.formDefinition); const data: SaveForm = { name: formInfo.name + ' - Copy', description: formInfo.description, formDefinition: form.formDefinition, formSchema: this.getFormSchema(form.formDefinition), formType: formInfo.formType, availableForTranslation: true, defaultLanguageId: formInfo.defaultLanguageId, picklistGuids: customDataTableGuids, referenceFieldIds, externalApiRequestIds: this.externalApiService.extractIds(externalApiRequests), requireSignature: form.requireSignature, signatureDescription: form.signatureDescription }; try { const result = await this.saveFormNewRevision(data); this.notifier.success(this.i18n.translate( 'FORMS.notificationSuccessCopyForm', {}, 'Successfully copied form' )); return result; } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'FORMS.notificationErrorCopyForm', {}, 'There was an error copying the form' )); throw e; } } /** * * @param form: form to adapt * @param formId: form id * @param revisionId: revision id * @returns the adapted form */ adaptFormForTabs ( form: T, formId: number, revisionId: number ): AdaptedForm { const { formDefinition } = this.adaptFormDefinitionForTabs( form.formDefinition, formId, revisionId ); return { ...form, formDefinition }; } /** * * @param formDefinition: form definition to adapt * @param formId: form id * @param revisionId: revision id * @returns the adapted form definition */ adaptFormDefinitionForTabs ( formDefinition: FormDefinitionWithLogic|FormDefinitionWithLogic[], formId: number, revisionId: number ): AdaptedFormDefResponse { let adaptedDefinition: FormDefinitionForUi[]; if (!(formDefinition instanceof Array)) { adaptedDefinition = [{ tabName: 'Page One', logic: formDefinition.logic, components: [ ...formDefinition.components ], uniqueId: v4(), index: 0 }]; } else { adaptedDefinition = formDefinition.map((def, index) => { return { ...def, uniqueId: v4(), index }; }); } const { adaptedFormDefinition } = this.adaptComponentsForLogic( adaptedDefinition, formId, revisionId ); return { formDefinition: adaptedFormDefinition }; } /** * * @param reportFieldLogicColumns: Report field logic columns to set */ setReportFieldLogicColumns (reportFieldLogicColumns: LogicColumnDisplay[]) { this.set('reportFieldLogicColumns', reportFieldLogicColumns); } /** * * @param formDefinition Form Definition * @param externalFields: External fields for this form response / application * @param reportFieldResponse: Report field response from API * @returns Logic States for Conditional Visibility, Validity, Set Value, and Formulas */ initFormDefinitionLogic ( formDefinition: FormDefinitionForUi[], externalFields: BaseApplication, reportFieldResponse: AdHocReportingUI.ReportResponseRow ): { conditionalVisibilityState: LogicState; validityState: LogicState; setValueState: ListLogicState>; formulaState: FormulaState; } { const { logicGroups, recordForLogic, formulas } = this.getLogicContext( formDefinition, externalFields, reportFieldResponse ); const conditionals = logicGroups.reduce((acc, group) => { return [ ...acc, [ group[0], group[1].visibilityGroup ] as const ]; }, [] as (readonly [LogicColumn, GlobalLogicGroup])[]) .filter(value => !!value[0] && !!value[1]); const validities = logicGroups.reduce((acc, group) => { return [ ...acc, [ group[0], group[1].validityGroup ] as const ]; }, [] as (readonly [LogicColumn, GlobalLogicGroup])[]) .filter(value => !!value[0] && !!value[1]); const setValues = logicGroups.reduce((acc, group) => { return [ ...acc, [ group[0], group[1].conditionalValueGroups ] as const ]; }, [] as (readonly [LogicColumn, GlobalValueLogicGroup>[]])[]); const conditionalVisibilityState = this.logicBuilderService.runConditionalLogic( conditionals, recordForLogic ); const validityState = this.logicBuilderService.runConditionalLogic( validities, recordForLogic ); const setValueState = this.logicBuilderService.runValueLogic>( setValues, recordForLogic ); const formulaState = this.formulaBuilderService.startRootFormulas( formulas, recordForLogic ); return { conditionalVisibilityState, validityState, setValueState, formulaState }; } /** * * @param formDefinition: Form definition (array of tabs) * @param externalFields: External fields for this form response / application * @param reportFieldResponse: Report field response from API * @returns Logic groups array, Record for logic, and Formulas array */ getLogicContext ( formDefinition: FormDefinitionForUi[], externalFields: BaseApplication, reportFieldResponse: AdHocReportingUI.ReportResponseRow ): { logicGroups: LogicGroupForForm[]; recordForLogic: BaseApplicationForLogic; formulas: RootFormula[]; } { const recordForLogic: BaseApplicationForLogic = this.getRecordForLogic( externalFields, reportFieldResponse ); const formFieldLogicGroups = this.getFormFieldLogicGroups( formDefinition ); const formulas = this.getFormFieldFormulas(formDefinition); const tabLogicGroups = formDefinition.reduce((acc, tab, index) => { // TODO: fix after ts 4.1 upgrade (ng 11.1) const column = ['tabs', index] as LogicColumn; return [ ...acc, [ column, { visibilityGroup: tab.logic || null, validityGroup: null, conditionalValueGroups: [] } ] ]; }, []); const logicGroups = [ ...formFieldLogicGroups, ...tabLogicGroups ].filter(([ _, logic ]) => { return !!logic.visibilityGroup || !!logic.validityGroup || logic.conditionalValueGroups?.length > 0; }); return { logicGroups, recordForLogic, formulas }; } /** * * @param form: the form * @returns the form field formulas */ getFormFieldFormulas ( form: FormDefinitionForUi[] ) { const formulas: RootFormula[] = []; form.forEach((tab) => { this.componentHelper.eachComponent(tab.components, (comp) => { const formula = comp.formula; if (formula?.step) { const prop = comp.type.split('-').join('.'); formulas.push({ ...formula, property: prop }); } }); }); return formulas; } /** * * @param form: the form * @returns the form field logic groups */ getFormFieldLogicGroups ( form: FormDefinitionForUi[] ) { const groups: LogicGroupForForm[] = []; form.forEach((tab) => { this.componentHelper.eachComponent(tab.components, (comp) => { const group = this.getComponentLogic(comp); if (group) { groups.push(group); } }, true); }); return groups; } /** * * @param externalFields: External Fields for this response / application * @param reportFieldResponse: Report field response from API * @returns The record to use for running logic */ getRecordForLogic ( externalFields: BaseApplication, reportFieldResponse: AdHocReportingUI.ReportResponseRow ): BaseApplicationForLogic { const application = (externalFields ?? {}) as BaseApplication; return { tabs: [], layoutComponents: {}, application: { ...application, amountRequested: externalFields.amountRequestedForEdit }, reportFieldResponse, referenceFields: externalFields?.referenceFields }; } /** * * @param comp: form component * @returns the components logic */ getComponentLogic ( comp: FormDefinitionComponent ): LogicGroupForForm { const refFieldColumn = this.getRefFieldColumnLogic(comp); const appCompColumn = this.getAppCompColumnLogic(comp); const layoutCompColumn = this.getLayoutColumnLogic(comp); const reportFieldColumn = this.getReportFieldColumnLogic(comp); if (!!refFieldColumn) { return refFieldColumn; } else if (!!appCompColumn) { return appCompColumn; } else if (!!layoutCompColumn) { return layoutCompColumn; } else if (!!reportFieldColumn) { return reportFieldColumn; } return null; } /** * * @param comp: form component * @returns reference field column logic */ getRefFieldColumnLogic (comp: FormDefinitionComponent): LogicGroupForForm { const refField = this.referenceFieldService.getReferenceFieldFromCompType( comp.type ); if (refField) { const column = ['referenceFields', refField.key] as LogicColumn; return this.getLogicGroupForComp(column, comp); } return null; } /** * * @param column: logic column * @param comp: form component * @returns the logic group for the component */ getLogicGroupForComp ( column: LogicColumn, comp: FormDefinitionComponent ): LogicGroupForForm { return [ column, { visibilityGroup: comp.conditionalLogic || null, validityGroup: comp.customValidation || null, conditionalValueGroups: comp.conditionalValue || [] } ]; } /** * * @param comp: form component * @returns the app components column logic */ getAppCompColumnLogic (comp: FormDefinitionComponent): LogicGroupForForm { if (comp.type.includes(`${EMPLOYEE_SSO_TYPE_PREFIX}-`)) { const split = comp.type.split('-'); const column = ['application', split[0], split[1]] as LogicColumn; return this.getLogicGroupForComp(column, comp); } const isAppComp = this.componentHelper.isStandardComponent(comp.type); if (isAppComp) { const column = ['application', comp.type] as LogicColumn; return this.getLogicGroupForComp(column, comp); } return null; } /** * * @param comp: the form component * @returns layout component column logic */ getLayoutColumnLogic (comp: FormDefinitionComponent): LogicGroupForForm { if (this.componentHelper.isLayoutComponent(comp.type)) { const column = ['layoutComponents', comp.key] as LogicColumn; return this.getLogicGroupForComp(column, comp); } return null; } /** * * @param comp: the form component * @returns report field column logic */ getReportFieldColumnLogic (comp: FormDefinitionComponent): LogicGroupForForm { if (comp.type === 'reportField') { const column = [ 'reportFieldResponse', comp.reportFieldDataOptions.reportFieldObject, comp.reportFieldDataOptions.reportFieldDisplay ] as LogicColumn; return this.getLogicGroupForComp(column, comp); } return null; } getReportFieldColumnsForLogic ( allCompsOnForm: FormDefinitionComponent[] ): LogicColumnDisplay[] { return (this.reportFieldLogicColumns || []).filter((reportFieldColumn) => { return allCompsOnForm.some((compOnForm) => { return reportFieldColumn.column.includes(compOnForm.reportFieldDataOptions?.reportFieldDisplay as keyof BaseApplicationForLogic ); }); }); } /** * * @param formDefinition Form definition array * @param index Index of form tab * @param formAudience Audience of form * @param isComponent Only pass if for component logic modal. Otherwise it's for tabs * @param isInitialAdaptOfOldLogic: If we are adapting old logic, we skip call the check for related picklist bc that data should not be needed for adapting and allows us not to fetch CDTS everytime we adapt a form definition's old logic */ getAvailableColumnsForLogicModal ( formDefinition: FormDefinitionForUi[], index: number, formAudience: FormAudience, isComponent: boolean, isInitialAdaptOfOldLogic: boolean ): LogicColumnDisplay[] { const filteredFormDef = !isComponent ? formDefinition.filter((_, defIndex) => index !== defIndex) : formDefinition; const allComps = this.componentHelper.getAllComponents(filteredFormDef); const reportFieldColumns: LogicColumnDisplay[] = this.getReportFieldColumnsForLogic(allComps); const refColumns = allComps.map>((component) => { return this.getReferenceFieldLogicColumnDisplay( component.type, component.label, isInitialAdaptOfOldLogic ); }).filter((item) => !!item); const applicationFields = this.getApplicationFieldsForLogic(formAudience); const availableColumns = [ ...reportFieldColumns, ...refColumns, ...applicationFields.filter((field) => { if (!isComponent) { return !this.componentHelper.checkIfCompTypeExistsOnTab( field.column[1], formDefinition[index] ); } return true; }) ]; this.addOtherColumnOptions(availableColumns); return availableColumns; } /** * * @param formAudience: the form audience * @returns the application fields for logic */ getApplicationFieldsForLogic (formAudience: FormAudience) { let applicationFields = this.getApplicantApplicationFieldsForLogic(); if (formAudience === FormAudience.MANAGER) { applicationFields = [ ...applicationFields, ...this.getManagerApplicationFieldsForLogic() ]; } return applicationFields; } /** * * @returns the application applicant fields for logic */ getApplicantApplicationFieldsForLogic (): LogicColumnDisplay[] { return [{ label: this.i18n.translate( 'GLOBAL:lblCashAmountRequested', {}, 'Cash amount requested' ), column: ['application', 'amountRequested'] as NestedPropColumn, type: 'number', otherColumnOptions: [] }, { label: this.i18n.translate( 'GLOBAL:textDesignation', {}, 'Designation' ), column: ['application', 'designation'] as NestedPropColumn, type: 'text', otherColumnOptions: [] }]; } /** * * @returns the manager application fields for logic */ getManagerApplicationFieldsForLogic (): LogicColumnDisplay[] { return [{ label: this.i18n.translate( 'GLOBAL:lblDecision', {}, 'Decision' ), column: ['application', 'decision'] as NestedPropColumn, type: 'multi-list', filterOptions: this.referenceFieldService.decisionOptions, otherColumnOptions: [] }, { label: this.i18n.translate( 'GLOBAL:textReviewerRecommendedFundingAmount', {}, 'Reviewer recommended funding amount' ), column: ['application', 'reviewerRecommendedFundingAmount'] as NestedPropColumn, type: 'number', otherColumnOptions: [] }]; } /** * * @param availableColumns: available columns for logic for a given form definition */ addOtherColumnOptions (availableColumns: LogicColumnDisplay[]) { availableColumns.forEach((column) => { const filteredOptions = availableColumns.filter((col) => { return !isEqual(column.column, col.column); }); column.otherColumnOptions = filteredOptions.filter((col) => { return this.isMatchingFilterType(column.type, col.type); }).map((col) => { return { label: col.label, value: col.column }; }); column.otherColumnOptions = this.arrayHelper.sort(column.otherColumnOptions, 'label'); }); } /** * * @param componentFilterType: The components filter type * @param otherComponentFilterType: the other components filter type we are comparing * @returns if the types are compatible */ isMatchingFilterType ( componentFilterType: LogicFilterTypes, otherComponentFilterType: LogicFilterTypes ) { switch (componentFilterType) { default: return otherComponentFilterType === componentFilterType; case 'number': case 'currency': return ['number', 'currency'].includes(otherComponentFilterType); } } /** * * @param compType: the component type * @param compLabel: the component label * @param isInitialAdaptOfOldLogic: are we adapting old logic on init? * @returns the reference field logic column display */ getReferenceFieldLogicColumnDisplay ( compType: string, compLabel: string, isInitialAdaptOfOldLogic = false ): LogicColumnDisplay { const refField = this.referenceFieldService.getReferenceFieldFromCompType( compType ); const invalidRefTypes = [ ReferenceFieldsUI.ReferenceFieldTypes.Table ]; if ( refField && !refField.isMasked && !refField.isEncrypted && !invalidRefTypes.includes(refField.type) ) { const [config] = this.referenceFieldService.getReferenceFieldColumnDef( { ...refField, formIds: [] }, true ); const refFieldColumn = ['referenceFields', refField.key] as NestedPropColumn; const base = { label: compLabel, column: refFieldColumn, type: config.type }; const hasFilterOptions = [ 'list', 'typeaheadSingleEquals', 'multiValueList', 'multi-list', 'multiListFuzzyText' ].includes(base.type); if (hasFilterOptions) { let type = base.type; if (!isInitialAdaptOfOldLogic) { const relatedPicklist = this.customDataTableService.getCDTFromGuid( refField.customDataTableGuid ); if (relatedPicklist?.dataType === PicklistDataType.Numeric) { type = 'number'; } } return >{ ...base, type, filterOptions: 'filterOptions' in config ? config.filterOptions : [] }; } return base as LogicColumnDisplay; } return null; } /** * @param component: form component * @param filterOutComp Component to filter out of available columns * @param formDefinition Only pass if adapting, otherwise it uses current value * @param isInitialAdaptOfOldLogic: If we are adapting old logic, we skip call the check for related picklist bc that data should not be needed for adapting and allows us not to fetch CDTS everytime we adapt a form definition's old logic */ getAvailableLogicColumnsForComponent ( component: FormDefinitionComponent, filterOutComp: boolean, formDefinition: FormDefinitionForUi[], currentFormBuilderIndex: number, currentFormBuilderFormAudience: FormAudience, isInitialAdaptOfOldLogic: boolean ): { availableColumns: LogicColumnDisplay[]; sourceColumn: LogicColumnDisplay; } { const cols = this.getAvailableColumnsForLogicModal( formDefinition, currentFormBuilderIndex, currentFormBuilderFormAudience, true, isInitialAdaptOfOldLogic ); let sourceColumn: LogicColumnDisplay; const availableColumns = cols.filter((col) => { const column = this.getComponentLogic(component); if (column) { const isCurrentColumn = isEqual(column[0], col.column); if (isCurrentColumn) { sourceColumn = col; if (filterOutComp) { return false; } } // only return columns that are not the current source column col.otherColumnOptions = col.otherColumnOptions.filter((oco) => { return !isEqual(oco.value, column[0]); }); } return true; }); return { availableColumns, sourceColumn }; } /** * * @param currentFormDefinition: the form definition * @param setValueState: the set value state * @param conditionalVisibilityState: the conditional visibility state * @param formulaState: the formula state * @param isReadOnly: is the form read only? * @param formAudience: Current form audience */ applyComponentLogicResults ( currentFormDefinition: FormDefinitionForUi, setValueState: ListLogicState>, conditionalVisibilityState: LogicState, formulaState: FormulaState, isReadOnly: boolean, formAudience: FormAudience ) { let changes: FormioChangesWithCompKey[] = []; if (!isReadOnly) { changes = [ ...changes, ...this.applySetValueResults( currentFormDefinition, setValueState ) ]; } if (!isReadOnly) { changes = [ ...changes, ...this.applyFormulaValues( currentFormDefinition, formulaState ) ]; } // If a component is hidden and needs cleared, we need to trigger a change here const clearOnHideChanges = this.applyConditionalLogicResults( currentFormDefinition, conditionalVisibilityState, formAudience ); if (!isReadOnly && clearOnHideChanges.length > 0) { changes = [ ...changes, ...clearOnHideChanges ]; } return changes; } /** * For a given form and visibility state, go through and apply the correct overrides for the values of the components with logic * * @param currentFormDefinition The form to be evaluated * @param setValueState The current state of the set value calculations */ applySetValueResults ( currentFormDefinition: FormDefinitionForUi, setValueState: ListLogicState> ) { const changes: FormioChangesWithCompKey[] = []; this.componentHelper.eachComponent(currentFormDefinition.components, (component) => { // no need to set value if comp is hidden and clears on hide const skipLogic = this.shouldSkipValueLogic(component); if (!skipLogic) { const logic = this.getComponentLogic(component); const column = logic?.[0]; if (column) { const groups = logic[1]?.conditionalValueGroups; if (groups?.length > 0) { const changed = this.handleConditionalValueGroups( component, column, setValueState ); if (changed) { changes.push({ componentKey: component.key, isReferenceField: this.componentHelper.isReferenceFieldComp(component.type), key: this.componentHelper.getRefFieldKeyFromCompType(component.type), type: component.type, value: component.value, updateFormGroup: true }); } } } } }); return changes; } /** * For a given form and visibility state, go through and apply the correct overrides for formio to hide/show the components * * @param currentFormDefinition The form to be evaluated * @param conditionalVisibilityState The current state of logic * @param formAudience: Audience of current form being viewed * @returns array of changes made during applying results */ applyConditionalLogicResults ( currentFormDefinition: FormDefinitionForUi, conditionalVisibilityState: LogicState, formAudience: FormAudience ) { let changes: FormioChangesWithCompKey[] = []; this.componentHelper.eachComponent(currentFormDefinition.components, (component) => { const logic = this.getComponentLogic(component); const column = logic?.[0]; const groups = logic?.[1]; const visibilityGroup = groups?.visibilityGroup; // this would mean that it's always showing (new logic) and if they have old JS, the old JS takes priority const useCustomConditional = ( !visibilityGroup || visibilityGroup.evaluationType === EvaluationType.AlwaysTrue ) && !!component.customConditional; if (useCustomConditional) { const customChanges = this.applyVisibilityResultToComponent( component, component.isHidden, false, formAudience ); changes = [ ...changes, ...customChanges ]; } else if (column && visibilityGroup) { const groupChanges = this.handleVisibilityLogic( component, column, visibilityGroup, conditionalVisibilityState, formAudience ); changes = [ ...changes, ...groupChanges ]; } }, true); return changes; } /** * * @param currentFormDefinition: the current form definition * @param formulaState: the formula state * @returns array of changes made */ applyFormulaValues ( currentFormDefinition: FormDefinitionForUi, formulaState: FormulaState ) { const changes: FormioChangesWithCompKey[] = []; this.componentHelper.eachComponent(currentFormDefinition.components, (component) => { // no need to set value if comp is hidden and clears on hide const skipLogic = this.shouldSkipValueLogic(component); if (!skipLogic) { if (component.formula?.step) { const changed = this.handleFormulaResult( component, component.type.split('-').join('.'), formulaState ); if (changed) { changes.push( this.componentHelper.adaptToFormChanges(component, component.value, true) ); } } } }, true); return changes; } /** * * @param comp: form component * @param column: column * @param visibilityGroup: the visibility group * @param conditionalVisibilityState: the conditional visibility state * @returns array of changes made during applying results */ handleVisibilityLogic ( comp: FormDefinitionComponent, column: LogicColumn, visibilityGroup: GlobalLogicGroup, conditionalVisibilityState: LogicState, formAudience: FormAudience ) { let changes: FormioChangesWithCompKey[] = []; const evaluationType = visibilityGroup?.evaluationType; const hasLogic = this.logicBuilderService.getHasConditionalLogic(evaluationType); if (hasLogic) { const show = this.logicBuilderService.getCurrentLogicValueOfColumn( column as LogicColumn, conditionalVisibilityState ) ?? true; if (!comp.hiddenFromParent) { const initialChanges = this.applyVisibilityResultToComponent( comp, !show, false, formAudience ); changes = [ ...changes, ...initialChanges ]; } } else if (evaluationType) { // if doesnt have logic but does have evaluation type, then either always hide or always show const alwaysValue = evaluationType === EvaluationType.AlwaysTrue; if (!comp.isHidden && !comp.hiddenFromParent) { // If this component is already hidden by it's parent, do not overwrite this const evalChanges = this.applyVisibilityResultToComponent( comp, !alwaysValue, false, formAudience ); changes = [ ...changes, ...evalChanges ]; } } return changes; } /** * * @param component: the form component * @param newValue: new value for the component * @returns true if it did set the value */ setValueForComp ( component: FormDefinitionComponent, newValue: FormioAnswerValues ) { let didSetValue = false; if ( !this.componentHelper.isLayoutComponent(component.type) && component.type !== 'button' && !isEqual(component.value, newValue) ) { didSetValue = true; component.value = newValue; } return didSetValue; } /** * This will apply the hidden property to nested components * * @param comp: component to apply visibility results to * @param hidden: is hidden? * @param fromParent: whether this evaluation is recursive (should be false most of the time) * @param formAudience: Audience of the current form being viewed * @returns array of changes made * (for visibility, it would be that a component was hidden and the value was cleared) */ applyVisibilityResultToComponent ( comp: FormDefinitionComponent, hidden: boolean, fromParent: boolean, formAudience: FormAudience ): FormioChangesWithCompKey[] { let changes: FormioChangesWithCompKey[] = []; if (fromParent) { comp.hiddenFromParent = hidden; } else { comp.isHidden = hidden; } if (hidden && comp.clearOnHide) { const field = this.referenceFieldService.getReferenceFieldFromCompType( comp.type ); const audienceIsSame = field?.formAudience === formAudience; const isSubsetOrTable = [ ReferenceFieldsUI.ReferenceFieldTypes.Subset, ReferenceFieldsUI.ReferenceFieldTypes.Table ].includes(field?.type); if (!isSubsetOrTable && audienceIsSame) { const blankValue = this.referenceFieldService.getBlankValueForFormField( field, comp, false ); const changed = this.setValueForComp(comp, blankValue); if (changed) { changes = [ ...changes, this.componentHelper.adaptToFormChanges(comp, comp.value, true) ]; } } } switch (comp.type) { case 'columns': comp.columns.forEach((column) => { this.componentHelper.eachComponent(column.components, (columnComp) => { const columnCompChanges = this.applyVisibilityResultToComponent( columnComp, hidden, true, formAudience ); if (columnCompChanges.length > 0) { changes = [ ...changes, ...columnCompChanges ]; } }, true); }); break; case 'fieldset': case 'panel': case 'well': this.componentHelper.eachComponent(comp.components, (layoutComp) => { const layoutCompChanges = this.applyVisibilityResultToComponent( layoutComp, hidden, true, formAudience ); if (layoutCompChanges.length > 0) { changes = [ ...changes, ...layoutCompChanges ]; } }, true); break; case 'table': comp.rows.forEach((rowComponents) => { rowComponents.forEach((componentObj) => { this.componentHelper.eachComponent(componentObj.components, (rowComp) => { const tableChanges = this.applyVisibilityResultToComponent( rowComp, hidden, true, formAudience ); if (tableChanges.length > 0) { changes = [ ...changes, ...tableChanges ]; } }, true); }); }); break; } return changes; } /** * * @param component: the form component * @param column: column * @param setValueState: the set value state * @returns if changes were made */ handleConditionalValueGroups ( component: FormDefinitionComponent, column: LogicColumn, setValueState: ListLogicState> ) { let value = this.logicBuilderService.getCurrentLogicValueOfColumn< BaseApplicationForLogic, ValueLogicResult >( column as LogicColumn, setValueState ) ?? null; const refField = this.referenceFieldService.getReferenceFieldFromCompType( component.type ); if (refField?.supportsMultiple && !(value instanceof Array)) { value = []; } return this.setValueForComp(component, value as FormioAnswerValues); } /** * * @param component: the form component * @param column: column * @param conditionalVisibilityState: the conditional visibility state * @returns if changes were made */ handleFormulaResult ( component: FormDefinitionComponent, column: string, conditionalVisibilityState: FormulaState ) { let formResponse = component.value; if (this.isCurrencyField(component.type)) { formResponse = (component.value as CurrencyValue)?.amountForControl ?? 0; } const logicStateForColumn = conditionalVisibilityState.sourceMap.get(column); let previousCalculatedValue = logicStateForColumn.previousResult; const isFirstRun = previousCalculatedValue === undefined; const runResult = logicStateForColumn.result$.value; if (isFirstRun) { previousCalculatedValue = runResult; } const { currentValue, usedRunResult } = this.componentHelper.determineCorrectCalculatedValueResult( isFirstRun, runResult, previousCalculatedValue, formResponse, component.type, component.allowCalculateOverride ); if (usedRunResult) { previousCalculatedValue = +currentValue; } const updatedVal = this.convertCalculatedValueToCurrencyVal( currentValue, component.type, component.value ) as FormioAnswerValues; return this.setValueForComp(component, updatedVal); } /** * * @param compType: component type * @returns if the component is a currency field */ isCurrencyField (compType: string) { const field = this.referenceFieldService.getReferenceFieldFromCompType(compType); const isCurrencyField = field?.type === ReferenceFieldsUI.ReferenceFieldTypes.Currency; const isAmountRequested = compType === 'amountRequested'; const isRecommendedFunding = compType === 'reviewerRecommendedFundingAmount'; return isCurrencyField || isAmountRequested || isRecommendedFunding; } /** * * @param calculatedValue: the calculated value number * @param compType: the component type * @param value: the current value of the component * @returns the correct model for the new value */ convertCalculatedValueToCurrencyVal ( calculatedValue: unknown, compType: string, value: FormioAnswerValues ) { if (this.isCurrencyField(compType)) { calculatedValue = { ...value as CurrencyValue, amountForControl: calculatedValue } as CurrencyValue; } return calculatedValue; } /** * * @param formDefinition: the form definition to adapt * @param formId: form id * @param formRevisionId: revision id * @returns the adapted form definition */ adaptComponentsForLogic ( formDefinition: FormDefinitionForUi[], formId: number, formRevisionId: number ): { adaptedFormDefinition: FormDefinitionForUi[]; } { formDefinition.forEach((tab) => { this.componentHelper.eachComponent(tab.components, (comp) => { if (comp.validate) { comp.validate.custom = this.componentHelper.adaptInitialCustomValidation(comp.validate.custom); } const hasJavascriptLogic = !!comp.customConditional || !!comp.validate?.custom || !!comp.calculateValue; if (hasJavascriptLogic) { this.logger.log('Found form with old javascript', { formId: formId + '', formRevisionId: formRevisionId + '', clientName: this.clientSettingsService.clientBranding?.name, componentLabel: comp.label, componentKey: comp.key, customConditional: comp.customConditional, calculateValue: comp.calculateValue, customValidation: comp.validate?.custom }); } this.componentHelper.adaptConditionalValue(comp, this.isCurrencyField(comp.type)); this.componentHelper.adaptConditionalLogic(comp); this.componentHelper.parseConfigurationOptions(comp); this.adaptInitialComponentValues(comp); this.adaptValueAndComparisonForLogic(comp, formDefinition); }, true); }); return { adaptedFormDefinition: formDefinition }; } /** * * @param comp: the component */ adaptInitialComponentValues ( comp: FormDefinitionComponent ) { const field = this.referenceFieldService.getReferenceFieldFromCompType(comp.type); // This should always start as false, and will be applied based on conditions comp.isHidden = false; comp.hiddenFromParent = false; // Clear any value stored on definition. Value will set in gc-form-renderer comp.value = undefined; comp.useCustomCurrency = comp.useCustomCurrency || CurrencyRadioOptions.USE_ONE_CURRENCY; const isStandardComp = this.componentHelper.isStandardComponent(comp.type); const isTableOrSubset = [ ReferenceFieldsUI.ReferenceFieldTypes.Table, ReferenceFieldsUI.ReferenceFieldTypes.Subset ].includes(field?.type); // These types of field do not support clear on hide, so make sure it's false if (isStandardComp || isTableOrSubset) { comp.clearOnHide = false; } // If the field doesn't support default val, clear out the setting if (field && !!comp.defaultVal) { const { defaultValType } = this.referenceFieldService.getEditFormSupportsSettings(field, false); if (defaultValType === DefaultValType.None) { comp.defaultVal = undefined; } } if (field?.supportsMultiple) { // We do not support input masks or patterns on fields that support multiple values comp.inputMask = ''; if (!!comp.validate?.pattern) { comp.validate.pattern = ''; } } // even if there is something saved here, we don't want to use validationResult until the form has been edited if (comp.validate?.validationResult) { comp.validate.validationResult = null; } } /** * * @param component: form component * @param formDefinition: form definition */ adaptValueAndComparisonForLogic ( component: FormDefinitionComponent, formDefinition: FormDefinitionForUi[] ) { if (!component.conditionalLogic) { if ( !!component.conditional?.show && !!component.conditional?.when && !!component.conditional?.eq ) { const { availableColumns } = this.getAvailableLogicColumnsForComponent( component, false, formDefinition, null, null, true ); let foundComponent: FormDefinitionComponent; formDefinition.forEach((tab) => { this.componentHelper.eachComponent(tab.components, (comp) => { if (comp.key === component.conditional.when) { foundComponent = comp; } }, true); }); if (foundComponent) { const sourceColumn = availableColumns.find((col) => { const uniqueColumnKey = col.column[col.column.length - 1]; if (this.componentHelper.isStandardComponent(foundComponent.type)) { return foundComponent.type === uniqueColumnKey; } else if (foundComponent.type.includes('referenceFields')) { const refKey = this.componentHelper.getRefFieldKeyFromCompType( foundComponent.type ); return refKey === uniqueColumnKey; } else { return foundComponent.key === uniqueColumnKey; } }); if (sourceColumn) { const type = sourceColumn?.type; const value = component.conditional.eq; const result = this.componentHelper.getValueAndCondition(value, type, component.type); const show = component.conditional?.show; const evaluationType = (show === 'true') || show === true ? EvaluationType.ConditionallyTrue : EvaluationType.ConditionallyFalse; component.conditionalLogic = { evaluationType, useAnd: false, identifier: this.guidService.nonce(), conditions: [{ useAnd: false, identifier: this.guidService.nonce(), conditions: [{ comparison: result.comparison as FilterModalTypes.equals|FilterModalTypes.multiValueEquals, identifier: this.guidService.nonce(), sourceColumn: sourceColumn.column, useAnd: false, value: result.value }] }] } as GlobalLogicGroup; this.componentHelper.clearConditional(component); } } else { // If no found component, the component no longer exists this.componentHelper.clearConditional(component); } } if (component.hidden) { component.conditionalLogic = { ...this.logicBuilderService.getDefaultConditionalLogic(), evaluationType: EvaluationType.AlwaysFalse }; component.hidden = false; } } } /** * * @param formDefinition: form definition * @returns external api calls for hat definition */ getExternalAPICalls ( formDefinition: FormDefinitionForUi[] ) { let externalAPIComponents: FormDefinitionComponent[] = []; formDefinition.forEach((tab) => { const response = this.extractComponentsByRefFieldType( tab, ReferenceFieldsUI.ReferenceFieldTypes.ExternalAPI ); externalAPIComponents = [ ...externalAPIComponents, ...response ]; }); const configs: (ExternalAPISelection&{ relatedComponent: string })[] = externalAPIComponents.map(comp => { const conf = comp.apiConfig; return { ...(typeof conf === 'string' ? JSON.parse(conf) : conf), relatedComponent: comp.relatedComponent }; }); return configs.filter((item, index) => { if (!!item.relatedComponent) { const configIndex = configs.findIndex(comp => { return comp.integrationId === item.integrationId && comp.relatedComponent === item.relatedComponent; }); return item && (configIndex === index); } return false; }); } /** * * @param tab: form definition tab * @param refType: ref type to find * @returns components of that type */ extractComponentsByRefFieldType ( tab: FormDefinitionForUi, refType: ReferenceFieldsUI.ReferenceFieldTypes ) { const comps: FormDefinitionComponent[] = []; this.componentHelper.eachComponent(tab.components, (comp) => { const refField = this.referenceFieldService.getReferenceFieldFromCompType(comp.type); if (refField?.type === refType) { comps.push(comp); } }); return comps; } /** * * @param forms: forms to export */ async ensureReferenceFieldPicklistsAreSaved (forms: ExportForm[]) { if (this.referenceFieldService.allReferenceFields.length > 0) { await Promise.all(forms.map(async (f) => { const detail = await this.getAndSetForm(f.formId, f.revisionId); const response = this.referenceFieldService.extractReferenceFieldsFromForm( detail.formDefinition ); if (response.customDataTableGuids.length > 0) { const formSchema = this.getFormSchema(detail.formDefinition); await this.saveFormExistingRevision({ revisionId: f.revisionId, id: f.formId, name: detail.name, description: detail.description, formType: detail.formType, formDefinition: detail.formDefinition, formSchema, availableForTranslation: true, defaultLanguageId: detail.defaultLanguageId, picklistGuids: uniq(response.customDataTableGuids), referenceFieldIds: response.referenceFieldIds, externalApiRequestIds: uniq(detail.externalApiRequestIds), requireSignature: detail.requireSignature, signatureDescription: detail.signatureDescription }); } })); } } /** * * @param formId: This is the ID of the form currently being edited * @param revisionId: revision id */ async getPageTemplateOptions ( formId: number, revisionId: number ): Promise { let pageOptions = this.pageOptionsMap[formId]; if (!pageOptions) { const formDetails = await this.getAndSetForm(formId, revisionId); pageOptions = formDetails.formDefinition.filter((def) => { return def.components.length > 0; }).map((definition) => { return { value: definition.uniqueId, label: definition.tabName }; }); this.setPageOptionsMap({ ...this.pageOptionsMap, [formId]: pageOptions }); } return pageOptions; } }