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
34 changes: 26 additions & 8 deletions formulus-formplayer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ import GPSQuestionRenderer, {
import VideoQuestionRenderer, {
videoQuestionTester,
} from './renderers/VideoQuestionRenderer';
import QrcodeQuestionRenderer, {
qrcodeQuestionTester,
} from './renderers/QrcodeQuestionRenderer';
import HtmlLabelRenderer, {
htmlLabelTester,
} from './renderers/HtmlLabelRenderer';
Expand Down Expand Up @@ -215,6 +218,7 @@ export const customRenderers = [
{ tester: audioQuestionTester, renderer: AudioQuestionRenderer },
{ tester: gpsQuestionTester, renderer: GPSQuestionRenderer },
{ tester: videoQuestionTester, renderer: VideoQuestionRenderer },
{ tester: qrcodeQuestionTester, renderer: QrcodeQuestionRenderer },
{ tester: htmlLabelTester, renderer: HtmlLabelRenderer },
{ tester: adateQuestionTester, renderer: AdateQuestionRenderer },
// Dynamic choice list renderer for x-dynamicEnum fields
Expand Down Expand Up @@ -263,7 +267,7 @@ function App() {
JsonFormsRendererRegistryEntry[]
>([]);
// Store extension functions for potential future use (e.g., validation context injection)
// eslint-disable-next-line @typescript-eslint/no-unused-vars

const [extensionFunctions, setExtensionFunctions] = useState<
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
Map<string, Function>
Expand Down Expand Up @@ -295,7 +299,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 +313,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 +330,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 @@ -836,10 +849,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
9 changes: 5 additions & 4 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 @@ -17,6 +17,7 @@ export interface FormEvaluationContextValue {
* Key: function name (e.g., "getDynamicChoiceList")
* Value: the actual function
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
functions: Map<string, Function>;
}

Expand All @@ -30,9 +31,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,6 +49,7 @@ export interface FormEvaluationProviderProps {
/**
* Map of extension functions to provide
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
functions: Map<string, Function>;
/**
* Child components
Expand Down
Loading
Loading