Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 71 additions & 12 deletions formulus-formplayer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { createTheme, getThemeOptions, CustomThemeColors } from './theme/theme';
import { tokens } from './theme/tokens-adapter';
import Ajv from 'ajv';
import type { ErrorObject } from 'ajv';
import addErrors from 'ajv-errors';
import addFormats from 'ajv-formats';
import * as MUI from '@mui/material';
Expand Down Expand Up @@ -77,6 +78,9 @@ import { loadExtensions } from './services/ExtensionsLoader';
import { getBuiltinExtensions } from './builtinExtensions';
import { FormEvaluationProvider } from './FormEvaluationContext';
import { loadCustomQuestionTypes } from './services/CustomQuestionTypeLoader';
import { loadCustomValidators } from './services/CustomValidatorLoader';
import { customValidatorRegistry } from './services/CustomValidatorRegistry';
import { executeAllCustomValidators } from './services/CustomValidatorExecutor';

// Import development dependencies (Vite will tree-shake these in production)
import { webViewMock } from './mocks/webview-mock';
Expand Down Expand Up @@ -298,6 +302,10 @@ function App() {
JsonFormsRendererRegistryEntry[]
>([]);
const [customTypeFormats, setCustomTypeFormats] = useState<string[]>([]);
// Custom validator errors (merged with AJV errors)
const [customValidatorErrors, setCustomValidatorErrors] = useState<
ErrorObject[]
>([]);

// Reference to the FormulusClient instance and loading state
const formulusClient = useRef<FormulusClient>(FormulusClient.getInstance());
Expand Down Expand Up @@ -418,6 +426,29 @@ function App() {
customQTResult.errors,
);
}

// Load custom validators if provided
if (customQTManifest.validators) {
try {
const validatorResult =
await loadCustomValidators(customQTManifest);
customValidatorRegistry.registerAll(validatorResult.validators);
console.log(
`[Formplayer] Loaded ${validatorResult.validators.size} custom validator(s): ${Array.from(validatorResult.validators.keys()).join(', ')}`,
);
if (validatorResult.errors.length > 0) {
console.warn(
'[Formplayer] Custom validator loading errors:',
validatorResult.errors,
);
}
} catch (error) {
console.error(
'[Formplayer] Failed to load custom validators:',
error,
);
}
}
} catch (error) {
console.error(
'[Formplayer] Failed to load custom question types:',
Expand Down Expand Up @@ -827,18 +858,6 @@ function App() {
}
}, [pendingFormInit, initializeForm]);

const handleDataChange = useCallback(
({ data }: { data: FormData }) => {
setData(data);

// Save draft data whenever form data changes
if (formInitData) {
draftService.saveDraft(formInitData.formType, data, formInitData);
}
},
[formInitData],
);

// Create AJV instance with extension definitions support
const ajv = useMemo(() => {
const instance = new Ajv({
Expand Down Expand Up @@ -891,6 +910,45 @@ function App() {
return instance;
}, [extensionDefinitions, customTypeFormats]);

const handleDataChange = useCallback(
({ data: newData }: { data: FormData }) => {
setData(newData);

// Save draft data whenever form data changes
if (formInitData) {
draftService.saveDraft(formInitData.formType, newData, formInitData);
}

// Execute custom validators when data changes
if (uischema && schema) {
try {
const customErrors = executeAllCustomValidators(
uischema,
schema,
newData,
ajv,
);

// Flatten errors map to array
const allCustomErrors: ErrorObject[] = [];
for (const fieldErrors of customErrors.values()) {
allCustomErrors.push(...fieldErrors);
}

setCustomValidatorErrors(allCustomErrors);
} catch (error) {
console.error(
'[Formplayer] Error executing custom validators:',
error,
);
// Graceful failure: clear errors on execution failure
setCustomValidatorErrors([]);
}
}
},
[formInitData, uischema, schema, ajv],
);

// Create dynamic theme based on dark mode preference and custom app colors.
// When a custom app provides themeColors, they override the default palette
// so that form controls (buttons, inputs, etc.) match the app's branding.
Expand Down Expand Up @@ -1076,6 +1134,7 @@ function App() {
onChange={handleDataChange}
validationMode="ValidateAndShow"
ajv={ajv}
additionalErrors={customValidatorErrors}
/>
</FormEvaluationProvider>
{/* Success Snackbar */}
Expand Down
248 changes: 248 additions & 0 deletions formulus-formplayer/src/services/CustomValidatorExecutor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/**
* CustomValidatorExecutor.ts
*
* Executes custom validators in the JSON Forms validation lifecycle.
* Extracts validators from UI schema options, executes them with form context,
* and converts errors to JSON Forms/AJV-compatible format.
*/

import type { ErrorObject } from 'ajv';
import type { JsonSchema7, UISchemaElement } from '@jsonforms/core';
import type { ValidationError as CustomValidationError } from '../types/CustomValidatorContract';
import { customValidatorRegistry } from './CustomValidatorRegistry';
import type Ajv from 'ajv';

/**
* Configuration for a custom validator reference in UI schema.
*/
export interface CustomValidatorReference {
/** Validator name (must match registry) */
name: string;
/** Validator configuration object */
config?: Record<string, unknown>;
}

/**
* Extract custom validators from UI schema options.
*
* @param uischema - UI schema element (Control, Layout, etc.)
* @returns Array of validator references, or empty array
*/
export function extractCustomValidators(
uischema: UISchemaElement | undefined,
): CustomValidatorReference[] {
if (!uischema || typeof uischema !== 'object') {
return [];
}

// Check if this is a Control with options.customValidators
const options = (uischema as any).options;
if (!options || typeof options !== 'object') {
return [];
}

const customValidators = options.customValidators;
if (!Array.isArray(customValidators)) {
return [];
}

return customValidators
.filter((ref: any) => ref && typeof ref === 'object' && ref.name)
.map((ref: any) => ({
name: String(ref.name),
config: ref.config || {},
}));
}

/**
* Convert a data path (e.g., "#/properties/age") to an instance path (e.g., "/age").
*
* @param path - JSON Schema path
* @returns Instance path for AJV errors
*/
function pathToInstancePath(path: string): string {
// Remove "#/properties/" prefix and convert to instance path
if (path.startsWith('#/properties/')) {
return '/' + path.replace('#/properties/', '').replace(/\//g, '/');
}
// If already an instance path, return as-is
if (path.startsWith('/')) {
return path;
}
// Fallback: remove leading "#" and convert
return path.replace(/^#/, '').replace(/\/properties\//g, '/');
}

/**
* Convert custom validation errors to AJV ErrorObject format.
*
* @param errors - Custom validation errors
* @param fieldPath - Field path (e.g., "#/properties/age")
* @returns AJV-compatible error objects
*/
function convertToAjvErrors(
errors: CustomValidationError[],
fieldPath: string,
): ErrorObject[] {
const instancePath = pathToInstancePath(fieldPath);

return errors.map(error => {
const ajvError: ErrorObject = {
instancePath: error.path ? pathToInstancePath(error.path) : instancePath,
schemaPath: fieldPath,
keyword: error.keyword || 'customValidator',
params: error.params || {},
message: error.message,
};
return ajvError;
});
}

/**
* Execute custom validators for a specific field.
*
* @param validatorRefs - Array of validator references from UI schema
* @param data - Full form data
* @param value - Current field value
* @param path - Field path (e.g., "#/properties/age")
* @param ajv - Optional AJV instance
* @returns Array of AJV-compatible error objects
*/
export function executeCustomValidators(
validatorRefs: CustomValidatorReference[],
data: Record<string, unknown>,
value: unknown,
path: string,
ajv?: Ajv,
): ErrorObject[] {
const allErrors: ErrorObject[] = [];

for (const ref of validatorRefs) {
const validator = customValidatorRegistry.get(ref.name);

if (!validator) {
console.warn(
`[CustomValidatorExecutor] Validator "${ref.name}" not found in registry. ` +
`Available validators: ${customValidatorRegistry.getNames().join(', ')}`,
);
continue;
}

try {
// Execute validator with full context
const result = validator({
data,
value,
path,
config: ref.config || {},
ajv,
});

// Convert result to array of errors
const errors: CustomValidationError[] = Array.isArray(result)
? result
: result !== undefined && result !== null
? [result as CustomValidationError]
: [];

// Convert to AJV format
const ajvErrors = convertToAjvErrors(errors, path);
allErrors.push(...ajvErrors);

if (ajvErrors.length > 0) {
console.debug(
`[CustomValidatorExecutor] Validator "${ref.name}" returned ${ajvErrors.length} error(s) for path "${path}"`,
);
}
} catch (err) {
// Graceful failure: log error but don't crash
const errorMessage = err instanceof Error ? err.message : String(err);
console.error(
`[CustomValidatorExecutor] Validator "${ref.name}" failed for path "${path}":`,
errorMessage,
);
// Optionally return a generic error to indicate validator failure
// For now, we just log and continue (graceful degradation)
}
}

return allErrors;
}

/**
* Execute custom validators for all fields in the form.
* Scans the UI schema to find all fields with custom validators and executes them.
*
* @param uischema - Root UI schema
* @param schema - JSON schema
* @param data - Full form data
* @param ajv - Optional AJV instance
* @returns Map of field path → array of AJV error objects
*/
export function executeAllCustomValidators(
uischema: UISchemaElement | undefined,
schema: JsonSchema7 | undefined,
data: Record<string, unknown>,
ajv?: Ajv,
): Map<string, ErrorObject[]> {
const errors = new Map<string, ErrorObject[]>();

if (!uischema || !schema) {
return errors;
}

// Recursively traverse UI schema to find all Controls with custom validators
function traverseUISchema(
element: UISchemaElement | UISchemaElement[] | undefined,
currentPath: string = '',
): void {
if (!element) {
return;
}

if (Array.isArray(element)) {
element.forEach(item => traverseUISchema(item, currentPath));
return;
}

const elem = element as any;

// If this is a Control, check for custom validators
if (elem.type === 'Control' && elem.scope) {
const validatorRefs = extractCustomValidators(elem);
if (validatorRefs.length > 0) {
// Extract field path from scope (e.g., "#/properties/age" -> "age")
const fieldPath = elem.scope;
const fieldName = fieldPath.replace('#/properties/', '');
const fieldValue = data[fieldName];

// Execute validators for this field
const fieldErrors = executeCustomValidators(
validatorRefs,
data,
fieldValue,
fieldPath,
ajv,
);

if (fieldErrors.length > 0) {
errors.set(fieldPath, fieldErrors);
}
}
}

// Recursively process children
if (elem.elements) {
traverseUISchema(elem.elements, currentPath);
}
if (elem.elements && Array.isArray(elem.elements)) {
elem.elements.forEach((child: UISchemaElement) =>
traverseUISchema(child, currentPath),
);
}
}

traverseUISchema(uischema);

return errors;
}
Loading
Loading