Skip to content
Open
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
602 changes: 602 additions & 0 deletions formulus-formplayer/docs/custom-question-types-architecture.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions formulus-formplayer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<link rel="manifest" href="/manifest.json" />
<title>Formulus Form Player</title>
<!-- Include the required Formulus load script -->
<!-- Note: This is a standalone script, not a module, so it's loaded at runtime from public/ -->
<script src="./formulus-load.js"></script>
</head>
<body>
Expand Down
68 changes: 66 additions & 2 deletions formulus-formplayer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { tokens } from './theme/tokens-adapter';
import Ajv from 'ajv';
import addErrors from 'ajv-errors';
import addFormats from 'ajv-formats';
import * as MUI from '@mui/material';

// Import the FormulusInterface client
import FormulusClient from './services/FormulusInterface';
Expand Down Expand Up @@ -75,6 +76,7 @@ import DraftSelector from './components/DraftSelector';
import { loadExtensions } from './services/ExtensionsLoader';
import { getBuiltinExtensions } from './builtinExtensions';
import { FormEvaluationProvider } from './FormEvaluationContext';
import { loadCustomQuestionTypes } from './services/CustomQuestionTypeLoader';

// Import development dependencies (Vite will tree-shake these in production)
import { webViewMock } from './mocks/webview-mock';
Expand Down Expand Up @@ -229,6 +231,16 @@ export const customRenderers = [
numberStepperRenderer,
];

// Expose React and MaterialUI to global scope for custom question type renderers
// This must be done synchronously at module load time so renderers can access them
if (typeof window !== 'undefined') {
(window as any).React = React;
(window as any).MaterialUI = MUI;
console.log(
'[App] Exposed React and MaterialUI to global scope for custom renderers',
);
}

function App() {
// Initialize WebView mock ONLY in development mode and ONLY if ReactNativeWebView doesn't exist
if (
Expand Down Expand Up @@ -281,6 +293,11 @@ function App() {
const [extensionDefinitions, setExtensionDefinitions] = useState<
Record<string, any>
>({});
// Custom question type renderers (loaded from custom_app)
const [customTypeRenderers, setCustomTypeRenderers] = useState<
JsonFormsRendererRegistryEntry[]
>([]);
const [customTypeFormats, setCustomTypeFormats] = useState<string[]>([]);

// Reference to the FormulusClient instance and loading state
const formulusClient = useRef<FormulusClient>(FormulusClient.getInstance());
Expand Down Expand Up @@ -341,7 +358,10 @@ function App() {
}

// Start with built-in extensions (always available)
const allFunctions = getBuiltinExtensions();
const allFunctions = getBuiltinExtensions() as Map<
string,
(...args: any[]) => any
>;

// Load extensions if provided
if (extensions) {
Expand Down Expand Up @@ -381,6 +401,36 @@ function App() {
console.log('[Formplayer] Using only built-in extensions');
}

// Load custom question types if provided
const customQTManifest = initData.customQuestionTypes;
if (customQTManifest) {
try {
const customQTResult =
await loadCustomQuestionTypes(customQTManifest);
setCustomTypeRenderers(customQTResult.renderers);
setCustomTypeFormats(customQTResult.formats);
console.log(
`[Formplayer] Loaded ${customQTResult.renderers.length} custom question type(s): ${customQTResult.formats.join(', ')}`,
);
if (customQTResult.errors.length > 0) {
console.warn(
'[Formplayer] Custom question type loading errors:',
customQTResult.errors,
);
}
} catch (error) {
console.error(
'[Formplayer] Failed to load custom question types:',
error,
);
setCustomTypeRenderers([]);
setCustomTypeFormats([]);
}
} else {
setCustomTypeRenderers([]);
setCustomTypeFormats([]);
}

if (!formSchema) {
console.warn(
'formSchema was not provided. Form rendering might fail or be incomplete.',
Expand Down Expand Up @@ -799,6 +849,19 @@ function App() {
return typeof data === 'string' && dateRegex.test(data);
});

// Register custom question type formats with AJV
// Custom question types use "format": "formatName" in schemas (not "type")
// This is required because JSON Schema only allows standard types in the "type" field
if (customTypeFormats.length > 0) {
customTypeFormats.forEach(formatName => {
// Register as format so AJV accepts "format": "formatName" in schemas
instance.addFormat(formatName, () => true);
});
console.log(
`[Formplayer] Registered ${customTypeFormats.length} custom question type format(s) with AJV`,
);
}

// Add extension definitions to AJV for $ref support
if (Object.keys(extensionDefinitions).length > 0) {
// Add each definition individually so $ref can reference them
Expand All @@ -808,7 +871,7 @@ function App() {
}

return instance;
}, [extensionDefinitions]);
}, [extensionDefinitions, customTypeFormats]);

// Create dynamic theme based on dark mode preference and custom app colors.
// When a custom app provides themeColors, they override the default palette
Expand Down Expand Up @@ -986,6 +1049,7 @@ function App() {
...shellMaterialRenderers,
...materialRenderers,
...customRenderers,
...customTypeRenderers, // Custom question types from custom_app
...extensionRenderers, // Extension renderers (highest priority)
]}
cells={materialCells}
Expand Down
12 changes: 12 additions & 0 deletions formulus-formplayer/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import * as MUI from '@mui/material';
import './index.css';
import App from './App';

// Expose React and MaterialUI to global scope for custom question type renderers
// This MUST happen at the entry point before any other code runs
if (typeof window !== 'undefined') {
(window as any).React = React;
(window as any).MaterialUI = MUI;
console.log(
'[index] Exposed React and MaterialUI to global scope for custom renderers',
);
}

const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement,
);
Expand Down
213 changes: 213 additions & 0 deletions formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/**
* CustomQuestionTypeAdapter.tsx
*
* Bridges JSON Forms ControlProps → CustomQuestionTypeProps.
* Wraps every custom question type in QuestionShell + ErrorBoundary
* so that form authors get consistent styling and crash isolation.
*/

import React, { Component, type ErrorInfo, type ReactNode } from 'react';
import { withJsonFormsControlProps } from '@jsonforms/react';
import type { ControlProps } from '@jsonforms/core';
import QuestionShell from '../components/QuestionShell';
import type { CustomQuestionTypeProps } from '../types/CustomQuestionTypeContract';

// ---------------------------------------------------------------------------
// Error Boundary — catches crashes in custom components
// ---------------------------------------------------------------------------

interface ErrorBoundaryProps {
formatName: string;
children: ReactNode;
}

interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}

class CustomQuestionErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { hasError: false, error: null };

static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}

componentDidCatch(error: Error, info: ErrorInfo): void {
console.error(
`[CustomQuestionType] "${this.props.formatName}" crashed:`,
error,
info.componentStack,
);
}

render() {
if (this.state.hasError) {
return (
<div
style={{
padding: '16px',
border: '1px solid #f44336',
borderRadius: '4px',
backgroundColor: '#ffebee',
color: '#c62828',
margin: '8px 0',
}}>
<strong style={{ display: 'block', marginBottom: '8px' }}>
⚠️ Custom Question Type Error
</strong>
<div style={{ fontSize: '0.9em', marginBottom: '8px' }}>
The custom question type <code>"{this.props.formatName}"</code>{' '}
encountered an error and could not be rendered.
</div>
<details style={{ fontSize: '0.85em', marginTop: '8px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 'bold' }}>
Error Details (click to expand)
</summary>
<pre
style={{
marginTop: '8px',
padding: '8px',
backgroundColor: '#fff',
borderRadius: '4px',
overflow: 'auto',
fontSize: '0.8em',
}}>
{this.state.error?.message || 'Unknown error'}
{this.state.error?.stack && (
<>
{'\n\n'}
{this.state.error.stack}
</>
)}
</pre>
</details>
<div
style={{
fontSize: '0.85em',
marginTop: '8px',
fontStyle: 'italic',
}}>
The form will continue to function, but this field cannot be edited.
</div>
</div>
);
}
return this.props.children;
}
}

// ---------------------------------------------------------------------------
// Adapter — maps ControlProps → CustomQuestionTypeProps
// ---------------------------------------------------------------------------

/**
* Creates a JSON Forms renderer component for a given custom question type.
*
* @param formatName - The format string (e.g., "x-rating-stars")
* @param CustomComponent - The author's React component
*/
export function createCustomQuestionTypeRenderer(
formatName: string,
CustomComponent: React.ComponentType<CustomQuestionTypeProps>,
): React.ComponentType {
const AdapterInner: React.FC<ControlProps> = ({
data,
handleChange,
path,
schema,
errors,
enabled,
label,
description,
required,
}) => {
// Build the simplified props for the custom component
const hasErrors =
errors && (Array.isArray(errors) ? errors.length > 0 : true);
const errorMessage = hasErrors
? Array.isArray(errors)
? errors.map((e: any) => e.message || String(e)).join(', ')
: String(errors)
: '';

// Extract all schema properties (except reserved ones) as config
// This allows parameters alongside "format" to be passed to the renderer
const schemaObj = schema as Record<string, unknown>;
const RESERVED_PROPERTIES = new Set([
'type',
'title',
'description',
'format',
'enum',
'const',
'default',
'required',
'properties',
'items',
'oneOf',
'anyOf',
'allOf',
'$ref',
'$schema',
'additionalProperties',
'pattern',
'minLength',
'maxLength',
'minimum',
'maximum',
'minItems',
'maxItems',
]);

// Extract all non-reserved properties as config
const config: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schemaObj)) {
if (!RESERVED_PROPERTIES.has(key) && !key.startsWith('$')) {
config[key] = value;
}
}

// Merge with x-config (x-config takes precedence for explicit configuration)
const xConfig = schemaObj['x-config'] as
| Record<string, unknown>
| undefined;
if (xConfig) {
Object.assign(config, xConfig);
}

const customProps: CustomQuestionTypeProps = {
value: data,
config,
onChange: (newValue: unknown) => handleChange(path, newValue),
validation: {
error: Boolean(hasErrors),
message: errorMessage,
},
enabled: enabled ?? true,
fieldPath: path,
label: label ?? '',
description: description,
};

return (
<QuestionShell
title={label}
description={description}
required={required}
error={errors}>
<CustomQuestionErrorBoundary formatName={formatName}>
<CustomComponent {...customProps} />
</CustomQuestionErrorBoundary>
</QuestionShell>
);
};

AdapterInner.displayName = `CustomQuestionType(${formatName})`;

// Wrap with JSON Forms HOC
return withJsonFormsControlProps(AdapterInner);
}
Loading
Loading