import { CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop'; import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { PortalDeterminationService } from '@core/services/portal-determination.service'; import { TranslationService } from '@core/services/translation.service'; import { BaseApplication } from '@core/typings/application.typing'; import { AdHocReportingUI } from '@core/typings/ui/ad-hoc-reporting.typing'; import { UIExternalAPI } from '@core/typings/ui/external-api.typing'; import { BaseApplicationForLogic, ComponentTabIndexMap, FormDefinitionComponent, FormDefinitionForUi, FormioChangesWithCompKey, FormTab, FormTranslations, FormTypes, ValueLogicResult } from '@features/configure-forms/form.typing'; import { FormsService } from '@features/configure-forms/services/forms/forms.service'; import { ExternalAPIService } from '@features/external-api/external-api.service'; import { ExternalAPISelection } from '@features/formio/component-configuration/external-api-selector-settings/external-api-selector-settings.component'; import { FormBuilderActionEvent, FormBuilderDropEvent } from '@features/formio/form-builder/form-builder.typing'; import { FormBuilderService } from '@features/formio/form-builder/services/form-builder/form-builder.service'; import { EvaluationFn } from '@features/formio/form-logic-evaluator.typing'; import { FormRendererFormGroup } from '@features/formio/formio-components/base/base.component'; import { ComponentHelperService } from '@features/formio/services/component-helper/component-helper.service'; import { FormHelperService } from '@features/formio/services/form-helper/form-helper.service'; import { FormLogicEvaluatorService } from '@features/formio/services/form-logic-evaluator/form-logic-evaluator.service'; import { FormLogicService } from '@features/formio/services/form-logic/form-logic.service'; import { ReportFieldService } from '@features/formio/services/report-field/report-field.service'; import { FormulaBuilderService } from '@features/formula-builder/formula-builder.service'; import { FormulaState } from '@features/formula-builder/formula-builder.typing'; import { LogicBuilderService } from '@features/logic-builder/logic-builder.service'; import { ListLogicState, LogicColumn, LogicState, NestedPropColumn } from '@features/logic-builder/logic-builder.typing'; import { ReferenceFieldsService } from '@features/reference-fields/services/reference-fields.service'; import { SignatureService } from '@features/signature/signature.service'; import { ArrayHelpersService, SimpleStringMap, Tab, TypeSafeFormBuilder, TypeToken, YcFile, YCTwoWayEmitter } from '@yourcause/common'; import { DomService } from '@yourcause/common/dom'; import { I18nService } from '@yourcause/common/i18n'; import { LogService } from '@yourcause/common/logging'; import { ModalFactory } from '@yourcause/common/modals'; import { NotifierService } from '@yourcause/common/notifier'; import { SignatureModalComponent, SignatureModalResponse } from '@yourcause/common/signature'; import { Subscription } from 'rxjs'; /** * TODO: correctly associate drop list directives * * Thoughts: * * fetch from and use from parent instance * * flow: assign to parent, pass back into directive in each layout comp. not sure why they aren't connected to themselves */ @Component({ selector: 'gc-form-renderer', templateUrl: './gc-form-renderer.component.html', styleUrls: ['./gc-form-renderer.component.scss'] }) export class GcFormRendererComponent implements OnInit, OnChanges, OnDestroy { @Input() form: FormDefinitionForUi[]; @Input() applicationId: number; @Input() applicationFormId: number; @Input() formType: FormTypes; @Input() programId: string|number; @Input() formId: number; @Input() hideSubmitButton = false; @Input() disableSubmitButton = false; @Input() savedSignature: YcFile; @Input() requireSignature: boolean; @Input() signatureDescription: string; @Input() supportsBypassSignature: boolean; @Input() formRevisionId: number; @Input() isManagerEditingApplicantForm = false; @Input() masked = true; @Input() externalFields: Partial; @Input() notAutoSave: boolean; @Input() uploadRequestStatusText: string; @Input() triggerSubmit: YCTwoWayEmitter>; @Input() showTabs = true; @Input() set readOnly (value: boolean) { this._readOnly = value; } get readOnly (): boolean { return this._readOnly; } @Input() editable: boolean; @Input() tabIndex = 0; @Input() orgId: number; // eslint-disable-next-line @angular-eslint/no-output-native @Output() submit = new EventEmitter(); @Output() onChange = new EventEmitter(); @Output() onConditionalVisibilityStateChanged = new EventEmitter< LogicState >(); @Output() formChange = new EventEmitter(); @Output() dropListsChanged = new EventEmitter(); @Output() componentActionClick = new EventEmitter(); @Output() onTranslationsReady = new EventEmitter(); tabInittedMap: Record = {}; conditionalVisibilityState: LogicState; validityState: LogicState; setValueState: ListLogicState>; formulaState: FormulaState; hiddenCompKeys: string[] = []; initializing = true; private _readOnly: boolean; translations: SimpleStringMap = {}; richTextTranslations: SimpleStringMap = {}; isAfterInit = false; reportFieldResponse: AdHocReportingUI.ReportResponseRow; sub: Subscription; tabs: FormTab[] = []; firstVisibleTabIndex: number; lastVisibleTabIndex: number; submitting = false; parentFields: Partial; $tabType = new TypeToken(); // This form group map will store form groups for each tab. Will be used to track validation formGroupMap: Record = {}; componentTabIndexMap: ComponentTabIndexMap = {}; dropLists: Record = {}; isManagerForm: boolean; tabsWithComponents: FormDefinitionForUi[]; customJSExecutions: EvaluationFn[] = []; jsExecutionCount = 0; constructor ( private logger: LogService, private formLogicEvaluatorService: FormLogicEvaluatorService, private formBuilderService: FormBuilderService, private formulaBuilderService: FormulaBuilderService, private arrayHelper: ArrayHelpersService, private logicBuilderService: LogicBuilderService, private externalAPIService: ExternalAPIService, private translationService: TranslationService, private formService: FormsService, private portal: PortalDeterminationService, private reportFieldService: ReportFieldService, private formHelperService: FormHelperService, private referenceFieldService: ReferenceFieldsService, private formBuilder: TypeSafeFormBuilder, private notifierService: NotifierService, private domService: DomService, private i18n: I18nService, private signatureService: SignatureService, private modalFactory: ModalFactory, private componentHelper: ComponentHelperService, private formLogicService: FormLogicService ) { } get currentFormDefinition () { return this.form[this.tabIndex]; } get allTabsValid () { return this.tabs.every((tab) => tab.valid); } async ngOnInit () { this.registerTriggerSubmit(); await this.setTranslations(); if (this.form) { await this.fetchFormDependencies(); this.prepFormHelpers(); this.initLogic(); this.registerJSFunctions(); this.isAfterInit = true; } } ngOnChanges (changes: SimpleChanges) { if (changes.formId) { this.isManagerForm = this.portal.isManager ? this.formService.isManagerForm(this.formId) : false; } if (changes.form) { if (this.isAfterInit) { this.prepFormHelpers(); } } else if (changes.externalFields && this.isAfterInit) { this.assignParentFields(); if (changes.orgId) { this.resetReportFieldsIfOrgChange(); } } else if (changes.orgId && this.isAfterInit) { this.resetReportFieldsIfOrgChange(); // check for after init // only pass in org id at apply page or forms tab } if (changes.parentFields && this.isAfterInit) { this.executeJSFunctions(this.customJSExecutions, false); } } async resetReportFieldsIfOrgChange () { await this.setReportFieldResponse(); this.assignParentFields(); } detectEmptyTab () { if (this.initializing && this.tabInittedMap[this.tabIndex].totalCount === 0) { this.initializeNextTab(); } } prepFormHelpers () { this.setComponentTabIndexMap(); this.assignParentFields(); this.componentHelper.trimFromDefinitionData(this.form); this.form = this.adaptFormDefinition(this.form); this.setTabsAndFormGroupMap(); this.replaceRichText(); this.executeServices(); } handleDropListChange (dropLists: CdkDropList[], index: number) { this.dropLists = { ...this.dropLists, [index]: dropLists }; const allDroplists = Object.keys(this.dropLists).reduce((acc, key) => { return [ ...acc, ...this.dropLists[+key] ]; }, []); this.dropListsChanged.emit(allDroplists); } async handleComponentDrop (drop: CdkDragDrop) { const dropType = await this.formBuilderService.handleComponentDrop( drop, this.formId, this.form ); switch (dropType) { case FormBuilderDropEvent.NewComponent: case FormBuilderDropEvent.ReorderComponent: this.formChange.emit(this.form); break; } } onFormGroupReady (details: FormRendererFormGroup) { this.setComponentTabIndexMap(); const tabIndex = this.componentTabIndexMap[details.compKey].tabIndex; this.formGroupMap[tabIndex].addControl(details.compKey, details.formGroup); const tabInittedTracker = this.tabInittedMap[tabIndex]; ++tabInittedTracker.loadedCount; const shouldContinueInitializing = this.formHelperService.shouldInitializeNextTabOnFormChange( this.initializing, tabInittedTracker.loadedCount, tabInittedTracker.totalCount ); if (shouldContinueInitializing) { this.initializeNextTab(); } } initializeNextTab () { let nextIndex = this.tabIndex + 1; if (!this.form[nextIndex]) { // Done loading components const appliedResults = this.executeJSFunctions(this.customJSExecutions, true); if (!appliedResults) { this.applyComponentLogicResults(); } this.determineFirstAndLastVisibleTabs(); nextIndex = this.firstVisibleTabIndex ?? 0; this.initializing = false; } this.tabIndex = nextIndex; this.detectEmptyTab(); } setComponentTabIndexMap () { this.componentTabIndexMap = this.formBuilderService.getComponentTabIndexMap(this.form); } async setTranslations () { const { richTextMap, standardMap } = await this.translationService.getFormTranslationsByLanguage( this.formId ); this.translations = standardMap; this.richTextTranslations = richTextMap; this.onTranslationsReady.emit({ translations: this.translations, richTextTranslations: this.richTextTranslations }); } replaceRichText () { this.componentHelper.replaceRichText( this.form, this.richTextTranslations ); } adaptFormDefinition (definitions: FormDefinitionForUi[]) { const forceDefaultCurrency = this.formHelperService.shouldForceDefaultCurrencyInAmountRequested( this.isManagerForm, this.isManagerEditingApplicantForm ); return definitions.map((tab, index) => { this.componentHelper.eachComponent(tab.components, (component) => { component.value = this.formHelperService.getValueFromComponent( component, this.parentFields, forceDefaultCurrency ); }); tab.index = index; return tab; }); } async fetchFormDependencies () { await Promise.all([ this.setReportFieldResponse(), this.formHelperService.prepareComponentsForRenderForm( [this.form], [this.formId] ) ]); } initLogic () { const response = this.formLogicService.initFormDefinitionLogic( this.form, this.externalFields as BaseApplication, this.reportFieldResponse ); this.conditionalVisibilityState = response.conditionalVisibilityState; this.validityState = response.validityState; this.setValueState = response.setValueState; this.formulaState = response.formulaState; this.emitConditionalVisibilityState(); } assignParentFields (externalFields = this.externalFields) { this.parentFields = { ...externalFields, applicationId: this.applicationId, applicationFormId: this.applicationFormId, formId: this.formId, revisionId: this.formRevisionId, programId: this.programId, formType: this.formType, reportFieldResponse: this.reportFieldResponse }; } async setReportFieldResponse () { const hasReportField = this.reportFieldService.formContainsReportField( this.form ); if (hasReportField && !this.editable) { const portal = this.portal.isManager ? 'manager' : 'portal'; this.reportFieldResponse = await this.reportFieldService.getReportFieldResponseFromAPI( portal, this.applicationId ); } } getFormFieldResponseFromCompKey (compKey: string) { return this.externalFields.referenceFields[ this.componentTabIndexMap[compKey].refKey ]; } private executeServices ( uniqueIntegrations = this.formLogicService.getExternalAPICalls(this.form) ) { // make the calls if we are in an application // OR we are in the applicant portal filling out an eligibility form if ( (this.applicationFormId && this.applicationId) || (this.portal.isApply && this.formType === FormTypes.ELIGIBILITY) ) { uniqueIntegrations.forEach((req) => { const formFieldResponse = this.getFormFieldResponseFromCompKey(req.relatedComponent); if ( // make the call if there is no related component (!req.relatedComponent || (req.relatedComponent === UIExternalAPI.EMPTY_FORM_FIELD_SOURCE)) || // OR there is a related component that has an answer (formFieldResponse !== '' && formFieldResponse !== null && formFieldResponse !== undefined) ) { this.externalAPIService.setUpIntegrationRequest( req.integrationId, req.relatedComponent, { formField: formFieldResponse?.toString() ?? '', applicationId: this.applicationId, applicationFormId: this.applicationFormId, formId: this.formId, formRevisionId: this.formRevisionId } ); } }); } } setTabsAndFormGroupMap () { setTimeout(() => { if (this.initializing) { this.logger.log('FORM RENDER ERROR: Error initializing form', { formId: '' + this.formId, formRevisionId: '' + this.formRevisionId }); } }, 10000); this.tabs = this.form.map((tab, index) => { this.formGroupMap[index] = this.formBuilder.group({}); let totalCount = 0; this.componentHelper.eachComponent(tab.components, (comp) => { if ( !comp.type.startsWith('referenceFieldsType') && !comp.type.startsWith('failedComponent') && comp.type !== 'button' && comp.type !== 'content'&& comp.type !== 'htmlelement' ) { ++totalCount; } }); this.tabInittedMap[index] = { totalCount, loadedCount: 0 }; return { order: index, label: this.translations[tab.tabName] || tab.tabName, active: this.tabIndex === index, actions: [], context: tab, valid: true, logic: tab.logic }; }); this.tabsWithComponents = this.form.filter((_, i) => { const tab = this.tabInittedMap[i]; return tab.totalCount > 0; }); this.detectEmptyTab(); if (this.tabsWithComponents.length === 0) { this.initializing = false; } } rerunLogic ( changedColumn: NestedPropColumn| NestedPropColumn ) { const column = changedColumn as LogicColumn; const recordForLogic = this.formLogicService.getRecordForLogic( this.externalFields as BaseApplication, this.reportFieldResponse ); this.conditionalVisibilityState = this.logicBuilderService.rerunConditionalLogic( column, this.conditionalVisibilityState, recordForLogic ); this.setValueState = this.logicBuilderService.rerunValueLogic( column, this.setValueState, recordForLogic ); this.validityState = this.logicBuilderService.rerunConditionalLogic( column, this.validityState, recordForLogic ); this.formulaState = this.formulaBuilderService.rerunFormulas( column.join('.'), this.formulaState, recordForLogic ); this.emitConditionalVisibilityState(); } emitConditionalVisibilityState () { this.onConditionalVisibilityStateChanged.emit(this.conditionalVisibilityState); } applyComponentLogicResults () { const changes = this.form.reduce((acc, tab, index) => { let tabChanges = this.formLogicService.applyComponentLogicResults( tab, this.setValueState, this.conditionalVisibilityState, this.formulaState, this.readOnly, this.formService.getAudienceFromFormType(this.formType) ); // if the tab is currently visible, the components will handle propogating the changes if (!this.initializing && index === this.tabIndex) { tabChanges = []; } return [ ...acc, ...tabChanges ]; }, []); changes.forEach(change => { this.handleValueChange(change); }); this.setHiddenCompKeys(); } setHiddenCompKeys () { this.hiddenCompKeys = []; this.form.forEach((tab) => { this.componentHelper.eachComponent(tab.components, (comp) => { if (comp.isHidden || comp.hiddenFromParent) { this.hiddenCompKeys.push(comp.key); } }, true); }); } markFormGroupAsDirty () { for (const key in this.formGroupMap[this.tabIndex].controls) { if (Object.prototype.hasOwnProperty.call(this.formGroupMap[this.tabIndex].controls, key)) { const group = this.formGroupMap[this.tabIndex].controls[key] as FormGroup; for (const subKey in group.controls) { if (Object.prototype.hasOwnProperty.call(group.controls, subKey)) { const control = group.controls[subKey]; if (control.invalid) { control.markAsDirty(); control.markAsTouched(); } } } } } } activeTabChanged (tab: Tab) { this.markFormGroupAsDirty(); const newIndex = this.getNewIndexFromActiveTabChanged(tab); if (this.tabIndex !== newIndex) { // Check tab validity of old tab this.checkTabValidity(this.tabIndex); this.tabIndex = newIndex; if (!this.tabs[this.tabIndex].valid) { // If new tab is not valid, re-run logic to see if it has become valid this.checkTabValidity(this.tabIndex); } this.determineFirstAndLastVisibleTabs(); } } getNewIndexFromActiveTabChanged (tab: Tab): number { return this.form.findIndex((def) => { return tab.context.uniqueId === def.uniqueId; }); } nextTab () { const newTabIndex = this.tabs.findIndex((_, index) => { const tabVisible = this.getTabIsVisible(index); if (index > this.tabIndex && tabVisible) { return true; } return false; }); this.updateTabIndex(newTabIndex); this.domService.scrollToTop(); } previousTab () { const newTab = this.arrayHelper.reverse(this.tabs).find((tab) => { const index = this.tabs.indexOf(tab); const tabVisible = this.getTabIsVisible(index); if (index < this.tabIndex && tabVisible) { return true; } return false; }); const newTabIndex = this.tabs.indexOf(newTab); this.updateTabIndex(newTabIndex); this.domService.scrollToTop(); } private determineFirstAndLastVisibleTabs () { let firstVisibleIndex = this.tabs.findIndex((_, index) => { return this.getTabIsVisible(index); }); const lastVisible = this.arrayHelper.reverse(this.tabs).find((tab) => { const index = this.tabs.indexOf(tab); return this.getTabIsVisible(index); }); let lastVisibleTabIndex = this.tabs.indexOf(lastVisible); if (firstVisibleIndex === -1) { firstVisibleIndex = 0; } if (lastVisibleTabIndex === -1) { lastVisibleTabIndex = this.tabs.length - 1; } this.firstVisibleTabIndex = firstVisibleIndex; this.lastVisibleTabIndex = lastVisibleTabIndex; } private getTabIsVisible (index: number) { if (!this.conditionalVisibilityState) { return true; } const column: NestedPropColumn = ['tabs', index]; const tabCurrentLogicState = this.logicBuilderService.getCurrentLogicValueOfColumn( column as LogicColumn, this.conditionalVisibilityState ); return tabCurrentLogicState ?? true; } updateTabIndex (newIndex: number) { this.tabs = this.tabs.map((tab, index) => { if (index === newIndex) { tab.active = true; } else { tab.active = false; } return tab; }); this.activeTabChanged(this.tabs[newIndex]); } /* returns whether all tabs are valid */ checkAllTabValidity () { this.tabs.map((_, index) => { this.checkTabValidity(index); }); return this.allTabsValid; } /** * * @param index: Tab index to check validity */ async checkTabValidity (index: number) { const visible = this.getTabIsVisible(index); if (visible) { const valid = this.formHelperService.checkFormGroupValidity( this.formGroupMap[index], this.form[index] ); this.updateTabValidity(valid, index); return valid; } return true; } /* updates the tab with an icon if the tab is invalid */ updateTabValidity ( valid: boolean, index: number ) { if (this.tabs[index].valid !== valid) { this.tabs = [ ...this.tabs.slice(0, index), { ...this.tabs[index], icon: valid ? '' : 'exclamation-circle text-danger', valid }, ...this.tabs.slice(index + 1) ]; } return this.allTabsValid; } private registerTriggerSubmit () { if (this.triggerSubmit) { this.triggerSubmit.registerAction(async () => { const valid = this.checkAllTabValidity(); if (!valid) { this.goToFirstInvalidTab(); } return valid; }); } } async onSubmit () { this.submitting = true; const allTabsValid = this.checkAllTabValidity(); if (allTabsValid) { if (this.requireSignature) { const signatureModalResponse = await this.signatureModal(); if (signatureModalResponse) { this.submit.emit(signatureModalResponse); this.updateTabIndex(this.firstVisibleTabIndex); } } else { this.submit.emit(); this.updateTabIndex(this.firstVisibleTabIndex); } } else { this.notifierService.error(this.i18n.translate( 'APPLY:textFormIsInvalidFixErrorsBelow', {}, 'Form is invalid. Please fix the errors below.' )); this.domService.scrollToTop(); this.goToFirstInvalidTab(); } this.submitting = false; } signatureModal (): Promise { return this.modalFactory.open( SignatureModalComponent, { savedSignature: this.savedSignature, supportsBypass: this.supportsBypassSignature, signatureDescription: this.signatureDescription || this.signatureService.getDefaultFormDescriptionApplicant( this.formType === FormTypes.NOMINATION ) } ); } goToFirstInvalidTab () { const firstInvalidTabIndex = this.tabs.findIndex((tab) => { return !tab.valid; }); this.updateTabIndex(firstInvalidTabIndex); } hasErrorsChange (hasErrors: boolean, index: number) { const isValid = !hasErrors; // Only update tab validity in this case if tab is becoming valid if (isValid) { this.updateTabValidity(isValid, index); } } executeJSFunctions ( fns: EvaluationFn[], isInit: boolean ) { const data = this.formHelperService.generateDataForFormDefinition( this.parentFields, this.form ); const runResult = this.formLogicEvaluatorService.executeJSFunctions( fns, isInit, this.readOnly, data ); let applyResults = runResult.applyResults; const numberOfExecutions = runResult.numberOfExecutions; this.jsExecutionCount = this.jsExecutionCount + numberOfExecutions; setTimeout(() => { this.jsExecutionCount = this.jsExecutionCount - numberOfExecutions; }, this.formLogicEvaluatorService.jsExecutionDuration); if (this.jsExecutionCount > this.formLogicEvaluatorService.jsExecutionThreshold) { this.logger.error({ message: 'potential infinite loop detected' }); applyResults = false; } if (applyResults) { this.applyComponentLogicResults(); return true; } return false; } registerJSFunctions () { this.customJSExecutions = this.formLogicEvaluatorService.getFunctionCalls( this.form ); } handleValueChange (change: FormioChangesWithCompKey) { if (this.isAfterInit) { this.onChange.emit(change); if (change.updateFormGroup) { const group = this.formGroupMap[ this.componentTabIndexMap[change.componentKey].tabIndex ]; const control = group.get(change.componentKey)?.get(change.componentKey); control?.setValue(change.value); } this.referenceFieldService.handleChangeTracking( [change], this.externalFields ); this.assignParentFields(); this.handleCallsToMake(change); if (change.isReferenceField) { this.rerunLogic(['referenceFields', change.key]); } else { this.rerunLogic(['application', change.type as keyof BaseApplication]); } this.determineFirstAndLastVisibleTabs(); if (this.customJSExecutions.length > 0) { setTimeout(() => { const appliedResults = this.executeJSFunctions(this.customJSExecutions, false); if (!appliedResults) { this.applyComponentLogicResults(); } }); } else { this.applyComponentLogicResults(); } } } handleCallsToMake (change: FormioChangesWithCompKey) { const callsToMake: (ExternalAPISelection&{relatedComponent: string})[] = []; this.formLogicService.getExternalAPICalls(this.form).forEach((service) => { if ( service.relatedComponent && change.componentKey === service.relatedComponent ) { callsToMake.push(service); } }); this.executeServices(callsToMake); } ngOnDestroy () { this.sub?.unsubscribe(); } }