Skip to content
Draft
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
43 changes: 31 additions & 12 deletions formulus-formplayer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ import { draftService } from './services/DraftService';
import DraftSelector from './components/DraftSelector';
import { loadExtensions } from './services/ExtensionsLoader';
import { getBuiltinExtensions } from './builtinExtensions';
import { FormEvaluationProvider } from './FormEvaluationContext';
import {
FormEvaluationProvider,
ExtensionFunction,
} from './FormEvaluationContext';

// Import development dependencies (Vite will tree-shake these in production)
import { webViewMock } from './mocks/webview-mock';
Expand Down Expand Up @@ -262,11 +265,9 @@ function App() {
const [extensionRenderers, setExtensionRenderers] = useState<
JsonFormsRendererRegistryEntry[]
>([]);
// Store extension functions for potential future use (e.g., validation context injection)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// Store extension functions for evaluation context
const [extensionFunctions, setExtensionFunctions] = useState<
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
Map<string, Function>
Map<string, ExtensionFunction>
>(new Map());
const [extensionDefinitions, setExtensionDefinitions] = useState<
Record<string, any>
Expand Down Expand Up @@ -295,7 +296,10 @@ function App() {
try {
const properties = (formSchema as any)?.properties || {};
const dynamicEnumFields = Object.entries(properties)
.filter(([, propSchema]: [string, any]) => !!propSchema?.['x-dynamicEnum'])
.filter(
([, propSchema]: [string, any]) =>
!!propSchema?.['x-dynamicEnum'],
)
.map(([key]) => key);

console.log('[Formplayer] Form init received', {
Expand All @@ -306,7 +310,10 @@ function App() {
dynamicEnumFields,
});
} catch (schemaLogError) {
console.warn('[Formplayer] Failed to log schema details', schemaLogError);
console.warn(
'[Formplayer] Failed to log schema details',
schemaLogError,
);
}

// Extract dark mode preference from params
Expand All @@ -320,17 +327,20 @@ function App() {
if (extensions) {
try {
const extensionResult = await loadExtensions(extensions);

// Merge loaded functions with built-ins (loaded functions take precedence)
extensionResult.functions.forEach((func, name) => {
allFunctions.set(name, func);
});

setExtensionRenderers(extensionResult.renderers);
setExtensionFunctions(allFunctions);
setExtensionDefinitions(extensionResult.definitions);

console.log('[Formplayer] Final extension functions:', Array.from(allFunctions.keys()));
console.log(
'[Formplayer] Final extension functions:',
Array.from(allFunctions.keys()),
);

// Log errors but don't fail form initialization
if (extensionResult.errors.length > 0) {
Expand Down Expand Up @@ -734,6 +744,9 @@ function App() {
({ data }: { data: FormData }) => {
setData(data);

// Expose current form data globally for extension renderers
(window as any).formulusCurrentFormData = data;

// Save draft data whenever form data changes
if (formInitData) {
draftService.saveDraft(formInitData.formType, data, formInitData);
Expand All @@ -747,6 +760,7 @@ function App() {
const instance = new Ajv({
allErrors: true,
strict: false, // Allow custom keywords like x-formulus-validation
$data: true, // Enable cross-field validation via $data references (e.g., {"const": {"$data": "1/otherField"}})
});
addErrors(instance);
addFormats(instance);
Expand Down Expand Up @@ -836,10 +850,15 @@ function App() {
p: 3,
backgroundColor: 'background.paper',
}}>
<Typography variant="h6" color="error" sx={{ mb: 2, textAlign: 'center' }}>
<Typography
variant="h6"
color="error"
sx={{ mb: 2, textAlign: 'center' }}>
Error Loading Form
</Typography>
<Typography variant="body2" sx={{ textAlign: 'center', color: 'text.secondary' }}>
<Typography
variant="body2"
sx={{ textAlign: 'center', color: 'text.secondary' }}>
{loadError}
</Typography>
</Box>
Expand Down
95 changes: 64 additions & 31 deletions formulus-formplayer/src/DynamicEnumControl.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* DynamicEnumControl.tsx
*
*
* Custom JSON Forms renderer for dynamic choice lists.
* Supports x-dynamicEnum schema property to populate enum/oneOf values
* from database queries at runtime.
Expand All @@ -11,7 +11,14 @@
import { ControlProps, rankWith } from '@jsonforms/core';
import { useFormEvaluation } from './FormEvaluationContext';
import { useJsonForms } from '@jsonforms/react';
import { Autocomplete, TextField, Box, Typography, Alert, CircularProgress } from '@mui/material';
import {
Autocomplete,
TextField,
Box,
Typography,
Alert,
CircularProgress,
} from '@mui/material';

/**
* Interface for x-dynamicEnum configuration
Expand All @@ -29,12 +36,15 @@
* Helper to resolve the actual field schema from a scope path
* Example: scope="#/properties/test_village" -> schema.properties.test_village
*/
function resolveSchemaFromScope(scope: string | undefined, rootSchema: any): any {
function resolveSchemaFromScope(
scope: string | undefined,
rootSchema: any,
): any {
if (!scope || !rootSchema) return rootSchema;

// Parse scope like "#/properties/field_name" or "#/properties/nested/properties/field"
const parts = scope.split('/').filter(p => p && p !== '#');

let resolved = rootSchema;
for (const part of parts) {
if (resolved && typeof resolved === 'object') {
Expand All @@ -43,7 +53,7 @@
return rootSchema; // Fallback to root if path invalid
}
}

return resolved || rootSchema;
}

Expand All @@ -52,7 +62,7 @@
*/
export const dynamicEnumTester = rankWith(
100, // High priority for x-dynamicEnum fields
(uischema: any, schema: any, context: any) => {
(uischema: any, schema: any, _context: any) => {
// Resolve the actual field schema from the scope
const fieldSchema = resolveSchemaFromScope(uischema?.scope, schema);
return !!(fieldSchema as any)?.['x-dynamicEnum'];
Expand All @@ -65,18 +75,22 @@
*/
function resolveTemplateParams(
params: Record<string, any>,
formData: Record<string, any>
formData: Record<string, any>,
): Record<string, any> {
const resolved: Record<string, any> = {};

for (const [key, value] of Object.entries(params)) {
if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) {
if (
typeof value === 'string' &&
value.startsWith('{{') &&
value.endsWith('}}')
) {
// Extract path: {{data.village}} -> data.village
const path = value.slice(2, -2).trim();

// Remove "data." prefix if present (form data is already the data object)
const dataPath = path.startsWith('data.') ? path.slice(5) : path;

// Get nested value
const pathParts = dataPath.split('.');
let resolvedValue: any = formData;
Expand All @@ -94,7 +108,7 @@
resolved[key] = value;
}
}

return resolved;
}

Expand All @@ -113,26 +127,28 @@
}) => {
const { functions } = useFormEvaluation();
const ctx = useJsonForms();

const [choices, setChoices] = useState<Array<{ const: any; title: string }>>([]);

const [choices, setChoices] = useState<Array<{ const: any; title: string }>>(
[],
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [localSchema, setLocalSchema] = useState(schema);

// Get x-dynamicEnum configuration first
const dynamicConfig = useMemo(() => {
return (schema as any)?.['x-dynamicEnum'] as DynamicEnumConfig | undefined;
}, [schema]);

// Get current form data for template parameter resolution
const currentFormData = ctx?.core?.data || {};

Check warning on line 144 in formulus-formplayer/src/DynamicEnumControl.tsx

View workflow job for this annotation

GitHub Actions / Formulus Formplayer (React Web)

The 'currentFormData' logical expression could make the dependencies of useCallback Hook (at line 245) change on every render. To fix this, wrap the initialization of 'currentFormData' in its own useMemo() Hook

// Handle value change - must be defined before any early returns
const handleValueChange = useCallback(
(_event: any, newValue: { const: any; title: string } | null) => {
handleChange(path, newValue ? newValue.const : '');
},
[handleChange, path]
[handleChange, path],
);

// Find selected option based on current data value - must be before early returns
Expand All @@ -142,9 +158,14 @@

// Get display label from schema or uischema - computed before early returns
const label = useMemo(() => {
return (uischema as any)?.label || schema.title || path.split('.').pop() || 'Field';
return (
(uischema as any)?.label ||
schema.title ||
path.split('.').pop() ||
'Field'
);
}, [uischema, schema, path]);

const description = schema.description;
const hasValidationErrors = errors && errors.length > 0;

Expand All @@ -167,7 +188,7 @@
if (!func) {
const availableFunctions = Array.from(functions.keys()).join(', ');
setError(
`Function "${functionName}" not found. Available: ${availableFunctions || 'none'}.`
`Function "${functionName}" not found. Available: ${availableFunctions || 'none'}.`,
);
return;
}
Expand All @@ -178,7 +199,10 @@
try {
// Resolve template parameters (if any - they will be ignored if unresolved)
const resolvedParams = dynamicConfig.params
? resolveTemplateParams(dynamicConfig.params, currentFormData as Record<string, any>)
? resolveTemplateParams(
dynamicConfig.params,
currentFormData as Record<string, any>,
)
: {};

// Add configuration for valueField, labelField, and distinct
Expand All @@ -193,14 +217,18 @@
};

// Call the function with correct signature: (queryName, params, formData)
const result = await func(dynamicConfig.query, paramsWithConfig, currentFormData);
const result = await func(
dynamicConfig.query,
paramsWithConfig,
currentFormData,
);

if (!Array.isArray(result)) {
throw new Error(`Function returned ${typeof result}, expected array`);
}

setChoices(result);

// Update local schema with dynamic enum
const updatedSchema = {
...localSchema,
Expand All @@ -226,10 +254,10 @@
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
dynamicConfig?.query,
JSON.stringify(dynamicConfig?.params),

Check warning on line 257 in formulus-formplayer/src/DynamicEnumControl.tsx

View workflow job for this annotation

GitHub Actions / Formulus Formplayer (React Web)

React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked
visible,
enabled,
JSON.stringify(currentFormData),

Check warning on line 260 in formulus-formplayer/src/DynamicEnumControl.tsx

View workflow job for this annotation

GitHub Actions / Formulus Formplayer (React Web)

React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked
]);

// Early returns after all hooks
Expand Down Expand Up @@ -260,14 +288,14 @@
{label}
{schema.required && <span style={{ color: 'red' }}> *</span>}
</Typography>

{/* Description */}
{description && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{description}
</Typography>
)}

{/* Validation Errors */}
{hasValidationErrors && (
<Alert severity="error" sx={{ mb: 1 }}>
Expand All @@ -291,8 +319,7 @@
variant="body2"
color="primary"
sx={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={loadChoices}
>
onClick={loadChoices}>
Retry
</Typography>
</Box>
Expand All @@ -305,15 +332,21 @@
value={selectedOption}
onChange={handleValueChange}
options={choices}
getOptionLabel={(option) => option.title || String(option.const)}
getOptionLabel={option => option.title || String(option.const)}
isOptionEqualToValue={(option, value) => option.const === value.const}
disabled={!enabled}
sx={{ mt: 1 }}
renderInput={(params) => (
renderInput={params => (
<TextField
{...params}
error={!!hasValidationErrors}
helperText={hasValidationErrors ? (Array.isArray(errors) ? errors.join(', ') : String(errors)) : ''}
helperText={
hasValidationErrors
? Array.isArray(errors)
? errors.join(', ')
: String(errors)
: ''
}
placeholder="Select an option..."
/>
)}
Expand Down
13 changes: 7 additions & 6 deletions formulus-formplayer/src/FormEvaluationContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* FormEvaluationContext.tsx
*
*
* Provides extension functions to form evaluation context.
* Allows renderers and other form components to access custom functions
* defined in ext.json files.
Expand All @@ -11,13 +11,15 @@ import React, { createContext, useContext, ReactNode } from 'react';
/**
* Context value for form evaluation
*/
export type ExtensionFunction = (...args: any[]) => any;

export interface FormEvaluationContextValue {
/**
* Map of loaded extension functions
* Key: function name (e.g., "getDynamicChoiceList")
* Value: the actual function
*/
functions: Map<string, Function>;
functions: Map<string, ExtensionFunction>;
}

/**
Expand All @@ -30,9 +32,8 @@ const defaultContextValue: FormEvaluationContextValue = {
/**
* Form evaluation context
*/
const FormEvaluationContext = createContext<FormEvaluationContextValue>(
defaultContextValue,
);
const FormEvaluationContext =
createContext<FormEvaluationContextValue>(defaultContextValue);

/**
* Hook to access form evaluation context
Expand All @@ -49,7 +50,7 @@ export interface FormEvaluationProviderProps {
/**
* Map of extension functions to provide
*/
functions: Map<string, Function>;
functions: Map<string, ExtensionFunction>;
/**
* Child components
*/
Expand Down
Loading
Loading