import { Component, Input, OnDestroy, OnInit, Optional } from '@angular/core'; import { FormGroupDirective, ValidatorFn } from '@angular/forms'; import { ApplicationFileService } from '@core/services/application-file.service'; import { CurrencyService } from '@core/services/currency.service'; import { FileUploadProgressService } from '@core/services/file-upload-progress.service'; import { FormMaskingService } from '@core/services/form-masking.service'; import { SpinnerService } from '@core/services/spinner.service'; import { ValidatorsService } from '@core/services/validators.service'; import { ReferenceFieldAPI } from '@core/typings/api/reference-fields.typing'; 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 { ReferenceFieldsUI } from '@core/typings/ui/reference-fields.typing'; import { ApplicationAttachmentService } from '@features/application-view/application-attachments/application-attachments.service'; import { FormAudience, FormioAnswerValues } from '@features/configure-forms/form.typing'; import { ExternalAPISelection } from '@features/formio/component-configuration/external-api-selector-settings/external-api-selector-settings.component'; import { FormBuilderService } from '@features/formio/form-builder/services/form-builder/form-builder.service'; import { GcFormRendererComponent } from '@features/formio/form-renderer/gc-form-renderer/gc-form-renderer.component'; import { ComponentHelperService } from '@features/formio/services/component-helper/component-helper.service'; import { FormHelperService } from '@features/formio/services/form-helper/form-helper.service'; import { ReferenceFieldsService } from '@features/reference-fields/services/reference-fields.service'; import { TypeaheadSelectOption, TypeSafeFormBuilder, TypeToken, YcFile } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import { CurrencyRadioOptions, CurrencyValue } from '@yourcause/common/masking'; import { isEqual, isUndefined } from 'lodash'; import moment, { isMoment } from 'moment'; import { Subscription } from 'rxjs'; import { MaskImp } from 'vanilla-text-mask'; import { BaseFormioComponent } from '../../base/base.component'; @Component({ selector: 'gc-reference-field', templateUrl: './gc-reference-field.component.html', styleUrls: ['./gc-reference-field.component.scss'] }) export class GCReferenceFieldComponent extends BaseFormioComponent implements OnInit, OnDestroy { @Input() inline = false; @Input() prefix: string; @Input() suffix: string; @Input() isFormBuilderView: boolean; @Input() masked: boolean; @Input() requireDecimal: boolean; @Input() decimalLimit: number; @Input() showWordCount: boolean; @Input() showCharCount: boolean; @Input() inputMask: string; @Input() itemsShownBeforeScroll: number; @Input() rows: number; @Input() isManagerForm: boolean; @Input() hideWithoutParentVal: boolean; @Input() validationTotal: number; @Input() notAutoSave = false; // prevents save of files @Input() uploadRequestStatusText: string; @Input() allOptionsMustHaveResponse: boolean; @Input() currencyOptions: TypeaheadSelectOption[] = []; @Input() useCustomCurrency: CurrencyRadioOptions; @Input() customCurrency: string; // External API @Input() displayType: AdHocReportingUI.DisplayTypes; @Input() relatedComponent: string; @Input() visibleToApplicants: boolean; @Input() visibleToManagers: boolean; @Input() dataUpdates: UIExternalAPI.DataUpdates; @Input() apiConfig: ExternalAPISelection; @Input() required: boolean; type: ReferenceFieldsUI.ReferenceFieldTypes; FieldTypes = ReferenceFieldsUI.ReferenceFieldTypes; field: ReferenceFieldAPI.ReferenceFieldDisplayModel; formFieldMask = this.formMaskingService.formFieldMask; formattingData = this.currencyService.formattingData; CurrencyRadioOptions = CurrencyRadioOptions; refKey: string; hasOptions = false; customMask: MaskImp|(string | RegExp)[]; multiTextAnswers: string[]; sub = new Subscription(); dropzonePlaceholder: string; manuallyPatchValue = false; maxFiles: number; initialCustomValidation: string; $stringOrStringArray = new TypeToken(); $string = new TypeToken(); $number = new TypeToken(); $currencyValue = new TypeToken(); $tableResponseRowArray = new TypeToken(); constructor ( private i18n: I18nService, private fileUploadProgressService: FileUploadProgressService, private formMaskingService: FormMaskingService, private applicationFileService: ApplicationFileService, private referenceFieldsService: ReferenceFieldsService, public formBuilder: TypeSafeFormBuilder, private formHelperService: FormHelperService, public formBuilderService: FormBuilderService, private applicationAttachmentService: ApplicationAttachmentService, private spinnerService: SpinnerService, private currencyService: CurrencyService, private validatorsService: ValidatorsService, public componentHelper: ComponentHelperService, @Optional() formGroupDir: FormGroupDirective, @Optional() renderer: GcFormRendererComponent ) { super(renderer, formGroupDir, formBuilder, formBuilderService, componentHelper); } get readOnly () { return this.disabledOverride === true; } get addRequiredAsterisk () { if (this.isRequired) { return true; } if (this.field?.type === ReferenceFieldsUI.ReferenceFieldTypes.FileUpload) { return this.field.supportsMultiple ? this.min > 0 : false; } return false; } get min () { return this.comp.validate.min; } get max () { return this.comp.validate.max; } private get onManagerForm () { return this.isManagerForm ?? (this.formBuilderService.currentFormBuilderFormAudience === FormAudience.MANAGER); } get isDisabled () { if ( !isUndefined(this.disabledOverride) && this.disabledOverride !== null ) { return this.disabledOverride; } else { return ( this.onManagerForm && this.field?.formAudience === FormAudience.APPLICANT ) || this.componentHelper.isCompDisabled(this.comp) || // disable if manager field is on applicant form ( !this.onManagerForm && this.field?.formAudience === FormAudience.MANAGER ); } } get isMaskedView () { return this.masked && this.isDisabled && this.field?.isMasked; } setDefaultTranslations () { super.setDefaultTranslations(); this.suffix = this.translations[this.suffix] || this.suffix; this.prefix = this.translations[this.prefix] || this.prefix; } async ngOnInit () { super.ngOnInit(); this.refKey = this.extractReferenceFieldKey(); this.field = this.referenceFieldsService.allReferenceFields.find((field) => { return field.key === this.refKey; }); this.showCharCount = !this.readOnly && this.showCharCount; this.showWordCount = !this.readOnly && this.showWordCount; if (this.field?.type === ReferenceFieldsUI.ReferenceFieldTypes.FileUpload) { this.maxFiles = this.field?.supportsMultiple ? (this.max || 10) : 1; } const isMultiText = this.field.supportsMultiple && [ ReferenceFieldsUI.ReferenceFieldTypes.TextArea, ReferenceFieldsUI.ReferenceFieldTypes.TextField ].includes(this.field.type); // Subsets, currency, and tables do not use form group, so set control manually this.manuallyPatchValue = [ ReferenceFieldsUI.ReferenceFieldTypes.Subset, ReferenceFieldsUI.ReferenceFieldTypes.Currency, ReferenceFieldsUI.ReferenceFieldTypes.Table ].includes(this.field.type) || isMultiText; this.initialCustomValidation = this.comp.validate.custom; this.saveDisabled = this.formHelperService.getSaveIsDisabled( this.field, this.onManagerForm ); this.hasOptions = this.referenceFieldsService.doesTypeHaveOptions( this.field.type ); if ( !this.rows && this.field.type === ReferenceFieldsUI.ReferenceFieldTypes.TextArea ) { this.rows = 3; } this.setInputMask(); if (isMultiText) { this.multiTextAnswers = [ ...(this.data as string[]) ]; } let validators: ValidatorFn[] = []; if (!this.inFormBuilder) { validators = this.validatorsService.getValidatorsForReferenceFieldComponent( this.comp, this.field, this.referenceFieldsService.dataPointsMap, this.translations ); } this.setFormGroup(this.data, validators); const isSubset = this.field.type === ReferenceFieldsUI.ReferenceFieldTypes.Subset; if (isSubset) { const dataPoints = this.referenceFieldsService.dataPointsMap[ this.field.referenceFieldId ]; if (!dataPoints) { await this.referenceFieldsService.setDataPointsForSubset( this.field.referenceFieldId ); } } this.sub.add(this.control.valueChanges .subscribe(() => { if (this.dirty !== this.control.dirty) { this.dirty = this.control.dirty; } })); if (this.hasOptions) { this.referenceFieldsService.setParentPicklistValueMap( this.field.referenceFieldId, this.data as string|string[] ); } else if (this.field.type === ReferenceFieldsUI.ReferenceFieldTypes.FileUpload) { this.setDropzonePlaceholder(); } if (this.comp.appliedDefaultVal) { setTimeout(() => { this.forceUpdate(); }); } } patchValue () { if (this.control) { let formValue: FormioAnswerValues = this.data; if (formValue === null || isUndefined(formValue)) { formValue = this.referenceFieldsService.getBlankValueForFormField( this.field, this.comp, false ); } if (!isEqual(this.control.value, formValue)) { // This will trigger data changed if using form group this.control.setValue(formValue); if (this.field.type === ReferenceFieldsUI.ReferenceFieldTypes.Currency) { // currency doesn't use a form group, so we need to make sure calculated value changes get out this.onValueChange.emit({ value: formValue, updateFormGroup: this.manuallyPatchValue }); } } } } extractReferenceFieldKey () { return this.componentHelper.getRefFieldKeyFromCompType( this.comp.type ); } setInputMask () { if (this.field.formatType) { if (this.field.formatType === ReferenceFieldAPI.ReferenceFieldFormattingType.EIN) { this.inputMask = '99-9999999'; } else { this.inputMask = null; // Formatting takes precedence over existing input mask, except for EIN } } if (this.inputMask) { this.customMask = this.validatorsService.getInputMaskRegExpForValidation( this.inputMask ); // If no placeholder, show the input mask placeholder if (!this.placeholder) { const placeholder = (this.customMask as any)?.map((char: any) => (char instanceof RegExp) ? '_' : char).join(''); this.translations = { ...this.translations, placeholder }; } } // If field formatting exists, we handle the validation instead of formio if (this.field.formatType) { if (this.comp?.inputMask) { this.comp.inputMask = ''; } if (this.comp?.validate.pattern) { this.comp.validate.pattern = ''; } } } setupTextFieldsForMulti ( formValue: string[], kickOffUpdate = false ) { if (!formValue) { formValue = ['']; // displays one empty input if no answers exist } this.multiTextAnswers = [ ...formValue ]; const validators = this.validatorsService.getValidatorsForReferenceFieldComponent( this.comp, this.field, this.referenceFieldsService.dataPointsMap, this.translations ); this.setFormGroup(this.multiTextAnswers, validators); if (kickOffUpdate) { setTimeout(() => { this.dataChanged([ ...this.multiTextAnswers ]); }); } } onCurrencyAmountChange (amount: number) { this.control.markAsDirty(); this.dataChanged(amount); } onCurrencyChange (currency: string) { this.data = { ...this.data as CurrencyValue, currency }; this.dataChanged(this.data.amountForControl); } multiTextAnswerChanged () { this.dataChanged([ ...this.multiTextAnswers ]); } addTextRow () { this.multiTextAnswers = [ ...this.multiTextAnswers, '' ]; this.dataChanged([ ...this.multiTextAnswers ]); } removeTextRow (index: number) { this.multiTextAnswers = [ ...this.multiTextAnswers.slice(0, index), ...this.multiTextAnswers.slice(index + 1) ]; this.dataChanged([ ...this.multiTextAnswers ]); } trackBy (index: number) { return index; } getValueOnShow () { return this.data ?? this.defaultVal ?? this.control.value; } // Files setDropzonePlaceholder () { let max = this.max; if (!this.field.supportsMultiple) { max = 1; } const files = this.control.value as YcFile[]; if (files?.length >= max) { this.dropzonePlaceholder = this.i18n.translate( 'common:textMaximumFilesUploaded', {}, 'Maximum files uploaded' ); } else if (this.placeholder) { this.dropzonePlaceholder = this.placeholder; } else { this.dropzonePlaceholder = this.i18n.translate( 'common:textClickOrDropFilesHere', {}, 'Click or drop files here to upload' ); } } filesChanged (files: YcFile[]) { this.dataChanged(files); this.setDropzonePlaceholder(); } async handleOpenFile (file: YcFile) { this.spinnerService.startSpinner(); await this.applicationAttachmentService.tryOpenReferenceFileUpload( file ); this.spinnerService.stopSpinner(); } async handleDownloadFile (file: YcFile) { this.spinnerService.startSpinner(); await this.applicationAttachmentService.downloadReferenceFileUpload( file ); this.spinnerService.stopSpinner(); } handleRemoveFile (file: YcFile) { const newData = (this.control.value as YcFile[]) .filter(previousFile => { return previousFile.fileUrl !== file.fileUrl; }); this.control.setValue(newData); } async handleFileUpload (uploadRequest: YcFile) { const originalData = this.control.value; const newData = [ uploadRequest, ...originalData ]; this.control.setValue(newData); if (this.notAutoSave) { if (this.uploadRequestStatusText) { uploadRequest.setStatusText(this.uploadRequestStatusText); } return; } try { const fileName = uploadRequest.fileName; const rawUploadReq = this.applicationFileService.uploadFileWithProgress( this.parentFields.applicationId, this.parentFields.applicationFormId, (uploadRequest as YcFile).file, fileName, this.field.referenceFieldId ?? null ); const fileUrl = await this.fileUploadProgressService.processFileUploadRequest( rawUploadReq, uploadRequest ); const details = this.applicationFileService.breakDownloadUrlDownToObject( fileUrl ); let fileUploadId: number; if (details.fileId) { fileUploadId = +details.fileId; } const newValue = [ new YcFile( uploadRequest.fileName, uploadRequest.file, fileUrl, fileUploadId ), ...originalData ]; this.control.setValue(newValue); } catch { // Remove file that failed this.control.setValue(originalData); } } forceUpdate () { const isCurrency = this.field.type === ReferenceFieldsUI.ReferenceFieldTypes.Currency; if (isCurrency) { this.dataChanged((this.data as CurrencyValue).amountForControl); } else { this.dataChanged(this.data); } } async dataChanged (value: FormioAnswerValues) { if (this.field.type === ReferenceFieldsUI.ReferenceFieldTypes.Date) { if (value) { if (!isMoment(value)) { value = moment(value as string); } if (isMoment(value) && !value.isValid()) { return; } } } else if ( !this.notAutoSave && this.field.type === ReferenceFieldsUI.ReferenceFieldTypes.FileUpload ) { // Only proceed if the file(s) have actually been uploaded const files = value as YcFile[] || []; const filesNotReady = files.some((file) => { return !file.fileUploadId; }); if (filesNotReady) { return; } } else if (this.field.type === ReferenceFieldsUI.ReferenceFieldTypes.Currency) { value = { amountForControl: value as number, amountEquivalent: value as number, amountInDefaultCurrency: value as number, currency: (this.data as CurrencyValue).currency }; } this.data = value; this.onValueChange.emit({ value, updateFormGroup: this.manuallyPatchValue }); if (this.hasOptions) { // if this is a parent, fire a function on ref fields service that updates a map this.referenceFieldsService.setParentPicklistValueMap( this.field.referenceFieldId, value as string|string[] ); } } ngOnDestroy () { this.sub.unsubscribe(); } }