form-preview-df
Version:
Resusable Form Preview Components
576 lines (482 loc) • 17.4 kB
text/typescript
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();