UNPKG

form-preview-df

Version:

Resusable Form Preview Components

576 lines (482 loc) 17.4 kB
import { TFormComponent, ICondition } from '../types/form-builder-schema.model'; export interface IAvailableComponent { id: string; label: string; type: string; key: string; } export interface IConditionalLogic { action: 'show' | 'hide' | 'always'; when: 'all' | 'any'; conditions: ICondition[]; } export interface IConditionalEvaluationResult { shouldShow: boolean; evaluatedConditions: Array<{ condition: ICondition; result: boolean; componentValue: any; }>; } export class ConditionalLogicService { private static instance: ConditionalLogicService; private constructor() {} public static getInstance(): ConditionalLogicService { if (!ConditionalLogicService.instance) { ConditionalLogicService.instance = new ConditionalLogicService(); } return ConditionalLogicService.instance; } /** * Get all available components in the form for conditional logic * @param formSchema - The current form schema containing all components * @param excludeComponentId - Optional component ID to exclude from the list * @returns Array of available components with their metadata */ getAvailableComponentsForConditional( formSchema: TFormComponent[], excludeComponentId?: string ): IAvailableComponent[] { if (!formSchema || !Array.isArray(formSchema)) { return []; } const availableComponents = formSchema .filter(component => !excludeComponentId || component.id !== excludeComponentId) .map(component => { const mappedComponent = { id: component.id, label: component.basic?.label || component.id, type: component.name, key: component.id // Using id as key for consistency }; return mappedComponent; }) .sort((a, b) => a.label.localeCompare(b.label)); return availableComponents; } // Get applicable operators for a specific component type public getApplicableOperators(componentType: string): string[] { switch (componentType) { case 'text-input': case 'email-input': case 'textarea': return [ 'equals', 'notEquals', 'contains', 'notContains', 'isEmpty', 'isNotEmpty' ]; case 'number-input': return [ 'equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual', 'isEmpty', 'isNotEmpty' ]; case 'select': case 'radio': case 'segment': return [ 'equals', 'notEquals', 'isEmpty', 'isNotEmpty' ]; case 'checkbox': return [ 'contains', 'notContains', 'isEmpty', 'isNotEmpty' ]; case 'date-picker': case 'datetime-picker': return [ 'equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual', 'isEmpty', 'isNotEmpty' ]; default: return [ 'equals', 'notEquals', 'isEmpty', 'isNotEmpty' ]; } } // Validate conditional logic public validateConditionalLogic( conditional: IConditionalLogic, formSchema: TFormComponent[] ): { isValid: boolean; errors: string[] } { const errors: string[] = []; if (!conditional) { return { isValid: true, errors: [] }; } // Validate action if (!['show', 'hide', 'always'].includes(conditional.action)) { errors.push('Invalid conditional action'); } // Validate when condition if (!['all', 'any'].includes(conditional.when)) { errors.push('Invalid when condition'); } // Validate conditions if (!conditional.conditions || conditional.conditions.length === 0) { errors.push('At least one condition is required'); } else { conditional.conditions.forEach((condition, index) => { // Validate condition component exists const component = formSchema.find(comp => comp.id === condition.when); if (!component) { errors.push(`Condition ${index + 1}: Component not found`); return; } // Validate operator is applicable for component type const applicableOperators = this.getApplicableOperators(component.name); if (!applicableOperators.includes(condition.operator)) { errors.push(`Condition ${index + 1}: Operator not applicable for component type`); } // Validate condition value if (!this.isValidConditionValue(condition.value, component.name, condition.operator)) { errors.push(`Condition ${index + 1}: Invalid condition value`); } }); } return { isValid: errors.length === 0, errors }; } /** * Evaluate whether a component should be shown based on conditional logic * @param conditional - The conditional logic to evaluate * @param formSchema - The current form schema * @param formValues - Current form values (component values) * @returns Evaluation result with details */ evaluateConditionalLogic( conditional: IConditionalLogic, formSchema: TFormComponent[], formValues: Record<string, any> ): IConditionalEvaluationResult { // If action is 'always', always show the component if (conditional.action === 'always') { return { shouldShow: true, evaluatedConditions: [] }; } if (!conditional || !conditional.conditions || conditional.conditions.length === 0) { return { shouldShow: true, evaluatedConditions: [] }; } const evaluatedConditions = conditional.conditions.map(condition => { const componentValue = this.getComponentValue(condition.when, formSchema, formValues); const result = this.evaluateCondition(condition, componentValue); return { condition, result, componentValue }; }); const shouldShow = this.determineFinalResult(conditional, evaluatedConditions); return { shouldShow, evaluatedConditions }; } /** * Get the current value of a component * @param componentId - The component ID to get value for * @param formSchema - The current form schema * @param formValues - Current form values * @returns The component value or undefined if not found */ private getComponentValue( componentId: string, formSchema: TFormComponent[], formValues: Record<string, any> ): any { // First check if we have a direct form value if (formValues && formValues[componentId] !== undefined) { return formValues[componentId]; } // Fallback to component's default value const component = formSchema.find(comp => comp.id === componentId); if (component) { return component.basic.defaultValue; } return undefined; } /** * Evaluate a single condition * @param condition - The condition to evaluate * @param componentValue - The current value of the component * @returns Boolean result of the condition evaluation */ private evaluateCondition(condition: ICondition, componentValue: any): boolean { const { operator, value } = condition; // Handle checkbox-specific evaluation if (value === 'checked' || value === 'notChecked') { return this.evaluateCheckboxCondition(componentValue, value); } switch (operator) { case 'equals': return this.isEqual(componentValue, value); case 'notEquals': return !this.isEqual(componentValue, value); case 'isEmpty': return this.isEmpty(componentValue); case 'isNotEmpty': return !this.isEmpty(componentValue); case 'contains': return this.contains(componentValue, value); case 'notContains': return !this.contains(componentValue, value); case 'greaterThan': return this.isGreaterThan(componentValue, value); case 'lessThan': return this.isLessThan(componentValue, value); case 'greaterThanOrEqual': return this.isGreaterThanOrEqual(componentValue, value); case 'lessThanOrEqual': return this.isLessThanOrEqual(componentValue, value); default: return false; } } /** * Evaluate checkbox-specific conditions (checked/notChecked) * @param componentValue - The current value of the checkbox component * @param expectedValue - Either 'checked' or 'notChecked' * @returns Boolean result of the checkbox condition evaluation */ private evaluateCheckboxCondition(componentValue: any, expectedValue: string): boolean { // Determine if checkbox is checked based on component value const isChecked = this.isCheckboxChecked(componentValue); if (expectedValue === 'checked') { return isChecked; } else if (expectedValue === 'notChecked') { return !isChecked; } return false; } /** * Determine if a checkbox component is checked based on its value * @param componentValue - The current value of the checkbox component * @returns Boolean indicating if checkbox is checked */ private isCheckboxChecked(componentValue: any): boolean { if (componentValue === null || componentValue === undefined) { return false; } // Handle boolean values if (typeof componentValue === 'boolean') { return componentValue; } // Handle string values if (typeof componentValue === 'string') { return componentValue.toLowerCase() === 'true' || componentValue === '1'; } // Handle array values (for multi-select checkboxes) if (Array.isArray(componentValue)) { return componentValue.length > 0; } // Handle numeric values if (typeof componentValue === 'number') { return componentValue > 0; } // Default to false for any other type return false; } /** * Determine final result based on 'when' condition (all/any) and action (show/hide) * @param conditional - The conditional logic configuration * @param evaluatedConditions - Array of evaluated conditions * @returns Final boolean result */ private determineFinalResult( conditional: IConditionalLogic, evaluatedConditions: Array<{ result: boolean }> ): boolean { // First determine if conditions are met let conditionsMet: boolean; if (conditional.when === 'all') { conditionsMet = evaluatedConditions.every(condition => condition.result); } else if (conditional.when === 'any') { conditionsMet = evaluatedConditions.some(condition => condition.result); } else { conditionsMet = false; } // Then apply the action to determine final visibility let finalResult: boolean; if (conditional.action === 'show') { // Show when conditions are met finalResult = conditionsMet; } else if (conditional.action === 'hide') { // Hide when conditions are met (inverse of show) finalResult = !conditionsMet; } else if (conditional.action === 'always') { // Always show finalResult = true; } else { // Default to showing if no action specified finalResult = true; } return finalResult; } // Helper methods for condition evaluation private isEqual(a: any, b: any): boolean { if (a === b) return true; if (a == null || b == null) return false; // Handle arrays if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) return false; return a.every((val, index) => this.isEqual(val, b[index])); } // Handle objects if (typeof a === 'object' && typeof b === 'object') { const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; return keysA.every(key => this.isEqual(a[key], b[key])); } return false; } private isEmpty(value: any): boolean { if (value == null || value === undefined) return true; if (typeof value === 'string') return value.trim() === ''; if (Array.isArray(value)) return value.length === 0; if (typeof value === 'object') return Object.keys(value).length === 0; return false; } private contains(container: any, searchValue: any): boolean { if (container == null || searchValue == null) return false; if (typeof container === 'string') { return container.toLowerCase().includes(String(searchValue).toLowerCase()); } if (Array.isArray(container)) { return container.some(item => this.isEqual(item, searchValue)); } return false; } private isGreaterThan(a: any, b: any): boolean { const numA = Number(a); const numB = Number(b); return !isNaN(numA) && !isNaN(numB) && numA > numB; } private isLessThan(a: any, b: any): boolean { const numA = Number(a); const numB = Number(b); return !isNaN(numA) && !isNaN(numB) && numA < numB; } private isGreaterThanOrEqual(a: any, b: any): boolean { const numA = Number(a); const numB = Number(b); return !isNaN(numA) && !isNaN(numB) && numA >= numB; } private isLessThanOrEqual(a: any, b: any): boolean { const numA = Number(a); const numB = Number(b); return !isNaN(numA) && !isNaN(numB) && numA <= numB; } // Validate condition value private isValidConditionValue(value: any, componentType: string, operator: string): boolean { if (value === null || value === undefined) { return ['isEmpty', 'isNotEmpty'].includes(operator); } switch (componentType) { case 'number-input': return !isNaN(Number(value)) || ['isEmpty', 'isNotEmpty'].includes(operator); case 'date-picker': case 'datetime-picker': return !isNaN(Date.parse(value)) || ['isEmpty', 'isNotEmpty'].includes(operator); case 'checkbox': return Array.isArray(value) || typeof value === 'string' || typeof value === 'number'; default: return true; } } // Create default conditional logic public createDefaultConditionalLogic(): IConditionalLogic { return { action: 'show', when: 'all', conditions: [] }; } // Get operator display text public getOperatorDisplayText(operator: string): string { const operatorTexts: Record<string, string> = { 'equals': 'Is Equal To', 'notEquals': 'Is Not Equal To', 'contains': 'Contains', 'notContains': 'Does Not Contain', 'isEmpty': 'Is Empty', 'isNotEmpty': 'Is Not Empty', 'greaterThan': 'Is Greater Than', 'lessThan': 'Is Less Than', 'greaterThanOrEqual': 'Is Greater Than Or Equal To', 'lessThanOrEqual': 'Is Less Than Or Equal To' }; return operatorTexts[operator] || operator; } // Get action display text public getActionDisplayText(action: string): string { const actionTexts: Record<string, string> = { show: 'Show this field when', hide: 'Hide this field when', always: 'Always show this field' }; return actionTexts[action] || action; } // Get conditional logic summary text public getConditionalLogicSummary(conditional: IConditionalLogic, formSchema: TFormComponent[]): string { if (!conditional || conditional.conditions.length === 0) { return 'No conditions'; } if (conditional.action === 'always') { return 'Always show'; } const actionText = this.getActionDisplayText(conditional.action); const conditionTexts = conditional.conditions.map(condition => { const component = formSchema.find(comp => comp.id === condition.when); const componentLabel = component?.basic.label || 'Unknown component'; const operatorText = this.getOperatorDisplayText(condition.operator); const valueText = this.formatConditionValue(condition.value); return `${componentLabel} ${operatorText} ${valueText}`; }); const operatorText = conditional.when === 'all' ? ' and ' : ' or '; return `${actionText} ${conditionTexts.join(operatorText)}`; } // Format condition value for display private formatConditionValue(value: any): string { if (value === null || value === undefined) { return 'empty'; } if (Array.isArray(value)) { return `[${value.join(', ')}]`; } if (typeof value === 'string') { return `"${value}"`; } return String(value); } } // Export singleton instance export const conditionalLogicService = ConditionalLogicService.getInstance();