import { Injectable } from '@angular/core'; import { AbstractControl, ValidatorFn } from '@angular/forms'; import { ReferenceFieldAPI } from '@core/typings/api/reference-fields.typing'; import { ReferenceFieldsUI } from '@core/typings/ui/reference-fields.typing'; import { DataSetMinMaxValidator } from '@core/validators/data-set-min-max.validator'; import { DataSetPercentageSumValidator } from '@core/validators/data-set-percentage-sum.validator'; import { DataSetRequireAllValidator } from '@core/validators/data-set-require-all.validator'; import { DataSetTotalValidator } from '@core/validators/data-set-total.validator'; import { HasSelectedQuantityValidator } from '@core/validators/has-selected-quantity.validator'; import { MaxItemsValidator } from '@core/validators/max-items.validator'; import { SelectedOneOfValidator } from '@core/validators/selected-one-of.validator'; import { TotalValueOfValidator } from '@core/validators/total-value-of.validator'; import { FormDefinitionComponent } from '@features/configure-forms/form.typing'; import { IsValidTypes, ValidationTypes } from '@features/formio/component-configuration/component-configuration.typing'; import { ComponentHelperService } from '@features/formio/services/component-helper/component-helper.service'; import { MaskValidator, MinMaxArrayValidator, MinMaxValidator, MinMaxValidatorTypes, MinMaxWordsValidator, PatternValidator, RequiredArrayValidator, RequiredCheckboxValidator, RequiredCustomValidator } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import { InputRegexService } from './input-regex.service'; export const MaxDesignationLength = 300; @Injectable({ providedIn: 'root' }) export class ValidatorsService { constructor ( private i18n: I18nService, private inputRegexService: InputRegexService, private componentHelper: ComponentHelperService ) { } getKeyValidator ( uniqueKeys: string[] = [], // all unique keys existingKeys: string[] // keys used for this field(s) - edit or merge ) { return (control: AbstractControl) => { const key = (control.value || '').toLowerCase(); const correctFormat = (/^\w+$/).test(key); const notUnique = uniqueKeys.includes(key) && !existingKeys.includes(key); if (!correctFormat) { return { wrongFormat: { i18nKey: 'common:textKeyMustBeAlphanumericWithNoSpaces', context: {}, defaultValue: 'Key must be alphanumeric with no spaces.' } }; } else if (notUnique) { return { keyMustBeUnique: { i18nKey: 'common:textKeyAlreadyBeingUsedError', context: {}, defaultValue: 'Key is already being used. Please enter a unique key.' } }; } return null; }; } getDataSetValidators ( minResponses: number, maxResponses: number, validationTotal: number, allOptionsMustHaveResponse: boolean, collectionType: ReferenceFieldAPI.DataSetCollectionType, customErrorMessage: string, totalNumberOfRows: number ): ValidatorFn[] { const validators: ValidatorFn[] = []; const countZeroAsResponse = collectionType === ReferenceFieldAPI.DataSetCollectionType.YesOrNo; if (validationTotal) { const defaultError = this.i18n.translate( 'common:textMustAddUpTo', { validationTotal }, 'Must add up to __validationTotal__' ); const totalValidator = DataSetTotalValidator( validationTotal, customErrorMessage, defaultError, this.componentHelper ); validators.push(totalValidator); } if (allOptionsMustHaveResponse) { const requireAllValidator = DataSetRequireAllValidator( totalNumberOfRows, customErrorMessage, this.i18n.translate( 'common:textAllOptionsMustHaveResponse', {}, 'All options must have a response' ) ); validators.push(requireAllValidator); } else { if (minResponses) { const defaultErrorForCheckBox = this.i18n.translate( 'common:textMustHaveAtLeastXSelected', { minResponses }, 'Must have at least __minResponses__ selected' ); const defaultErrorForNumberOrPercent = this.i18n.translate( 'common:textMustHaveAtLeastXValuesGreaterThanZero', { minResponses }, 'Must have at least __minResponses__ values greater than zero' ); const defaultError = collectionType === ReferenceFieldAPI.DataSetCollectionType.YesOrNo ? defaultErrorForCheckBox : defaultErrorForNumberOrPercent; const minValidator = DataSetMinMaxValidator( minResponses, customErrorMessage, defaultError, true, countZeroAsResponse ); validators.push(minValidator); } if (maxResponses) { const defaultErrorForCheckBox = this.i18n.translate( 'common:textCannotHaveMoreThanXSelected', { maxResponses }, 'Cannot have more than __maxResponses__ selected' ); const defaultErrorForNumberOrPercent = this.i18n.translate( 'common:textCannotHaveMoreThanXValuesGreaterThanZero', { maxResponses }, 'Cannot have more than __maxResponses__ values greater than zero' ); const defaultError = collectionType === ReferenceFieldAPI.DataSetCollectionType.YesOrNo ? defaultErrorForCheckBox : defaultErrorForNumberOrPercent; const maxValidator = DataSetMinMaxValidator( maxResponses, customErrorMessage, defaultError, false, countZeroAsResponse ); validators.push(maxValidator); } } if (collectionType === ReferenceFieldAPI.DataSetCollectionType.Percent) { const percentageSumValidator = DataSetPercentageSumValidator(this.componentHelper); validators.push(percentageSumValidator); } return validators; } /** * * @param component: form component * @param validationItemName: item name we are validating * @returns array of validators for the in kind component */ getValidatorsForInKindComponent ( component: FormDefinitionComponent, validationItemName: string, translations: Record = {} ): ValidatorFn[] { const validators: ValidatorFn[] = []; const validationType = component.validationType; const customError = this.getTranslatedCustomErrorMessage( component.validate.customMessage || component.validationErrorMessage, translations ); let validator: ValidatorFn; if (validationType) { switch (validationType) { case ValidationTypes.HasSelectedItem: validator = SelectedOneOfValidator( component.validationItem, component.willBeValid, customError, this.getDefaultInKindValidationMessage( component.validationType, component.validationAmount, validationItemName, component.willBeValid ) ); break; case ValidationTypes.HasSelectedQuantity: validator = HasSelectedQuantityValidator( component.validationAmount, component.willBeValid, customError, this.getDefaultInKindValidationMessage( component.validationType, component.validationAmount, validationItemName, component.willBeValid ) ); break; case ValidationTypes.QuantityEqualTo: case ValidationTypes.QuantityGreaterThan: case ValidationTypes.QuantityLessThan: validator = TotalValueOfValidator( component.validationType, component.validationAmount, component.willBeValid, customError, this.getDefaultInKindValidationMessage( component.validationType, component.validationAmount, validationItemName, component.willBeValid ) ); break; } validators.push(validator); } if (!!component.maxItems) { validator = MaxItemsValidator( component.maxItems, customError, this.i18n.translate( 'common:textCannotSelectMoreThanNumberItems', { number: component.maxItems }, 'Cannot select more than __number__ items' ) ); validators.push(validator); } return validators; } /** * * @param component: form component * @param isCurrencyField: is currency field? * @param isCheckboxField: is checkbox field? * @param supportsMultiple: does the field support multiple values or is stored in an array? * @returns array of validators for component */ getValidatorsForSimpleComponent ( component: FormDefinitionComponent, isCurrencyField: boolean, isCheckboxField: boolean, supportsMultiple: boolean, translations: Record = {} ): ValidatorFn[] { const customErrorMessage = this.getTranslatedCustomErrorMessage( component.validate.custom, translations ); let validators: ValidatorFn[] = []; // Is Required const isRequired = component.validate.required || component.required; // External API uses this attr if (isRequired) { validators.push( RequiredCustomValidator( customErrorMessage, this.i18n.translate( 'common:textThisInputIsRequired', {}, 'This input is required' ) ) ); } if (isRequired) { if (isCurrencyField) { // When a currency field is required, it must be greater than zero validators.push( MinMaxValidator( 'min', .01, customErrorMessage, this.i18n.translate( 'common:textPleaseEnterANumberGreaterThanZero', {}, 'Amount must be greater than zero.' ), true ) ); } else if (isCheckboxField) { // When a checkbox field is required, it must be checked validators = [ RequiredCheckboxValidator( customErrorMessage, this.i18n.translate( 'common:textThisInputIsRequired', {}, 'This input is required' ) ) ]; } else if (supportsMultiple) { validators = [ RequiredArrayValidator( customErrorMessage, this.i18n.translate( 'common:textThisInputIsRequired', {}, 'This input is required' ) ) ]; } } return validators; } getTranslatedCustomErrorMessage ( message: string, translations: Record = {} ) { let customErrorMessage = message; customErrorMessage = translations[customErrorMessage] || customErrorMessage; return customErrorMessage; } /** * * @param component: form component * @param field: reference field * @param dataPointsMap: data points map * @returns array of validators for the reference field component */ getValidatorsForReferenceFieldComponent ( component: FormDefinitionComponent, field: ReferenceFieldAPI.ReferenceFieldDisplayModel, dataPointsMap: Record, translations: Record = {} ): ValidatorFn[] { const customErrorMessage = this.getTranslatedCustomErrorMessage( component.validate.customMessage, translations ); // Is Required const supportsMultiple = field.supportsMultiple || // files are stored as arrays field.type === ReferenceFieldsUI.ReferenceFieldTypes.FileUpload; let validators = this.getValidatorsForSimpleComponent( component, field.type === ReferenceFieldsUI.ReferenceFieldTypes.Currency, field.type === ReferenceFieldsUI.ReferenceFieldTypes.Checkbox, supportsMultiple, translations ); // Format Type if (field.formatType) { const { validator } = this.inputRegexService.getSelectedFormattingDetails( field.formatType, customErrorMessage ); validators.push(validator); } // Subsets if (field.type === ReferenceFieldsUI.ReferenceFieldTypes.Subset) { const response = this.getDataSetValidators( component.validate.min, component.validate.max, component.validationTotal, component.allOptionsMustHaveResponse, field.subsetCollectionType, customErrorMessage, dataPointsMap[ field.referenceFieldId ].length ); validators = [ ...validators, ...response ]; } else { // Min Max Validators const minMaxValidators = this.getMinMaxValidators( component, field, translations ); validators = [ ...validators, ...minMaxValidators ]; } // Pattern if (component.validate.pattern && !field.formatType) { const patternValidator = PatternValidator( component.validate.pattern, customErrorMessage, this.i18n.translate( 'common:textValueDoesNotMatchRequiredPattern', {}, 'Value does not match the required pattern' ) ); validators.push(patternValidator); } // Input Mask if (component.inputMask) { const maskValidator = MaskValidator( this.getInputMaskRegExpForValidation(component.inputMask), customErrorMessage, this.i18n.translate( 'common:textMustMatchTheMask', {}, 'Value does not match the required mask' ) ); validators.push(maskValidator); } return validators; } /** * * @param component: form component * @param field: reference field * @returns array of validators based on min / max validation settings */ getMinMaxValidators ( component: FormDefinitionComponent, field: ReferenceFieldAPI.ReferenceFieldDisplayModel, translations: Record = {} ): ValidatorFn[] { const customErrorMessage = this.getTranslatedCustomErrorMessage( component.validate.customMessage, translations ); const validators: ValidatorFn[] = []; const numberValidatorsToCheck = [ 'min', 'max', 'minLength', 'maxLength', 'minWords', 'maxWords' ] as MinMaxValidatorTypes[]; numberValidatorsToCheck.forEach((attr) => { const value = component.validate[attr]; if (value || value === 0) { let validator: ValidatorFn; const isArray = field?.supportsMultiple || [ ReferenceFieldsUI.ReferenceFieldTypes.FileUpload, ReferenceFieldsUI.ReferenceFieldTypes.Table ].includes(field?.type); if (isArray) { validator = MinMaxArrayValidator( attr as 'min'|'max', // Arrays only support these attrs value, customErrorMessage, this.getDefaultMinMaxMessage( attr, value, field?.type === ReferenceFieldsUI.ReferenceFieldTypes.FileUpload, true ) ); } else { switch (attr) { case 'min': case 'max': case 'minLength': case 'maxLength': validator = MinMaxValidator( attr, value, customErrorMessage, this.getDefaultMinMaxMessage( attr, value, field?.type === ReferenceFieldsUI.ReferenceFieldTypes.FileUpload, false ), false ); break; case 'minWords': case 'maxWords': validator = MinMaxWordsValidator( attr, value, customErrorMessage, this.getDefaultMinMaxMessage( attr, value, field?.type === ReferenceFieldsUI.ReferenceFieldTypes.FileUpload, false ) ); break; } } validators.push(validator); } }); return validators; } /** * * @param validationType: in kind validation type * @param validationAmount: in kind validation amount * @param validationItemName: in kind validation item name * @returns the default in kind validation error message */ getDefaultInKindValidationMessage ( validationType: ValidationTypes, validationAmount: number, validationItemName: string, isValidType: IsValidTypes ): string { const invalid = isValidType === IsValidTypes.Invalid; switch (validationType) { case ValidationTypes.HasSelectedItem: return this.i18n.translate( invalid ? 'common:textCannotSelectItem' : 'common:textMustSelectAtLeastOneOfItem', { name: validationItemName }, invalid ? 'Cannot select item __name__' : 'Must select at least one __name__' ); case ValidationTypes.HasSelectedQuantity: return this.i18n.translate( invalid ? 'common:textCannotSelectNumberItems' : 'common:textMustSelectNumberItems', { number: validationAmount }, invalid ? 'Cannot select __number__ items' : 'Must select __number__ items' ); case ValidationTypes.QuantityEqualTo: return this.i18n.translate( invalid ? 'common:textCannotSelectNumberUnits' : 'common:textMustSelectNumberUnits', { number: validationAmount }, invalid ? 'Cannot select _number__ units' : 'Must select __number__ units' ); case ValidationTypes.QuantityGreaterThan: return this.i18n.translate( invalid ? 'common:textCannotSelectMoreThanNumberUnits' : 'common:textMustSelectMoreThanNumberUnits', { number: validationAmount }, invalid ? 'Cannot select more than __number__ units' : 'Must select more than __number__ units' ); case ValidationTypes.QuantityLessThan: return this.i18n.translate( invalid ? 'common:textCannotSelectLessThanNumberUnits' : 'common:textMustSelectLessThanNumberUnits', { number: validationAmount }, invalid ? 'Cannot select less than __number__ units' : 'Must select less than __number__ units' ); } } /** * * @param attr: min max attribute * @param minMaxValue: min max value * @param isTypeFiles: is for files? * @returns the default error message */ getDefaultMinMaxMessage ( attr: MinMaxValidatorTypes, minMaxValue: number, isTypeFiles: boolean, isArray: boolean ): string { switch (attr) { case 'min': if (isArray) { return this.i18n.translate( isTypeFiles ? 'common:textAtLeastMinFilesRequired' : 'common:textAtLeastMinRowsRequired', { min: minMaxValue }, isTypeFiles ? 'At least __min__ file(s) required' : 'At least __min__ row(s) required' ); } else { return this.i18n.translate( 'common:textMustBeAtLeastMin', { min: minMaxValue }, 'Must be at least __min__.' ); } case 'max': if (isArray) { return this.i18n.translate( isTypeFiles ? 'common:textCannotHaveMoreThanMaxFiles' : 'common:textCannotHaveMoreThanMaxRows', { max: minMaxValue }, isTypeFiles ? 'Cannot have more than __max__ files' : 'Cannot have more than __max__ rows' ); } else { return this.i18n.translate( 'common:textCannotBeMoreThanMax', { max: minMaxValue }, 'Cannot be more than __max__.' ); } case 'minLength': return this.i18n.translate( 'common:textMustBeAtLeastMinChars', { min: minMaxValue }, 'Must be at least __min__ characters.' ); case 'maxLength': return this.i18n.translate( 'common:textCannotBeMoreThanMaxChars', { max: minMaxValue }, 'Cannot be more than __max__ characters' ); case 'minWords': return this.i18n.translate( 'common:textMustHaveAtLeastMinWords', { min: minMaxValue }, 'Must have at least __min__ words.' ); case 'maxWords': return this.i18n.translate( 'common:textCannotBeMoreThanMaxWords', { max: minMaxValue }, 'Cannot be more than __max__ words.' ); } } /** * Returns an input mask that is compatible with the input mask library. * * @param mask - The Form.io input mask. * @returns - The input mask for the mask library. */ getInputMaskRegExpForValidation (inputMask: string) { const maskArray: (RegExp|string)[] = []; for (const char of inputMask) { switch (char) { case '9': maskArray.push(/\d/); break; case 'A': maskArray.push(/[a-zA-Z]/); break; case 'a': maskArray.push(/[a-z]/); break; case '*': maskArray.push(/[a-zA-Z0-9]/); break; default: maskArray.push(char); break; } } return maskArray; } /** * * @param designation string value * @returns validity state */ getDesignationValidity (designation: string) { return !(designation && designation.length > MaxDesignationLength); } }