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
49 changes: 48 additions & 1 deletion formulus-formplayer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,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 @@ -281,6 +282,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 @@ -381,6 +387,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)`,
);
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 @@ -800,6 +836,16 @@ function App() {
return typeof data === 'string' && dateRegex.test(data);
});

// Register custom question type formats with AJV
if (customTypeFormats.length > 0) {
customTypeFormats.forEach(fmt => {
instance.addFormat(fmt, () => true);
});
console.log(
`[Formplayer] Registered ${customTypeFormats.length} custom 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 @@ -809,7 +855,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 @@ -987,6 +1033,7 @@ function App() {
...shellMaterialRenderers,
...materialRenderers,
...customRenderers,
...customTypeRenderers, // Custom question types from custom_app
...extensionRenderers, // Extension renderers (highest priority)
]}
cells={materialCells}
Expand Down
129 changes: 129 additions & 0 deletions formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* 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: '12px',
border: '1px solid #f44336',
borderRadius: '4px',
backgroundColor: '#fce4ec',
color: '#c62828',
}}>
<strong>Custom question type "{this.props.formatName}" failed</strong>
<br />
<small>{this.state.error?.message}</small>
</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 customProps: CustomQuestionTypeProps = {
value: data,
config:
((schema as Record<string, unknown>)?.['x-config'] as Record<
string,
unknown
>) ?? {},
onChange: (newValue: unknown) => handleChange(path, newValue),
validation: {
error: Boolean(errors && errors.length > 0),
message: errors ?? '',
},
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);
}
116 changes: 116 additions & 0 deletions formulus-formplayer/src/services/CustomQuestionTypeLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* CustomQuestionTypeLoader.ts
*
* Loads custom question type modules from the custom_app archive.
* The native Formulus RN side scans `custom_app/question_types/` and
* provides a manifest mapping format names to module paths.
*
* This loader:
* 1. Iterates over the manifest
* 2. Dynamically imports each module
* 3. Validates the default export is a function (React component)
* 4. Passes all loaded components to the registry
* 5. Returns renderer entries + format strings for AJV registration
*/

import type { JsonFormsRendererRegistryEntry } from '@jsonforms/core';
import type {
CustomQuestionTypeManifest,
CustomQuestionTypeProps,
} from '../types/CustomQuestionTypeContract';
import { registerCustomQuestionTypes } from './CustomQuestionTypeRegistry';
import type React from 'react';

export interface CustomQuestionTypeLoadResult {
/** JSON Forms renderer entries ready to be merged into the renderers array */
renderers: JsonFormsRendererRegistryEntry[];
/** Format strings that need to be registered with AJV */
formats: string[];
/** Any errors that occurred during loading */
errors: Array<{ format: string; error: string }>;
}

/**
* Load custom question types from a manifest.
*
* @param manifest - The manifest describing available custom question types
* @returns Loaded renderers, format strings, and any errors
*/
export async function loadCustomQuestionTypes(
manifest: CustomQuestionTypeManifest,
): Promise<CustomQuestionTypeLoadResult> {
const result: CustomQuestionTypeLoadResult = {
renderers: [],
formats: [],
errors: [],
};

if (
!manifest?.custom_types ||
Object.keys(manifest.custom_types).length === 0
) {
console.log(
'[CustomQuestionTypeLoader] No custom question types in manifest',
);
return result;
}

const loadedComponents = new Map<
string,
React.ComponentType<CustomQuestionTypeProps>
>();

for (const [formatName, meta] of Object.entries(manifest.custom_types)) {
try {
console.log(
`[CustomQuestionTypeLoader] Loading "${formatName}" from ${meta.modulePath}`,
);

// Dynamic import of the module
const module = await import(/* @vite-ignore */ meta.modulePath);

// Get the default export
const component = module.default ?? module;

// Validate that the export is a function (React component)
if (typeof component !== 'function') {
throw new Error(
`Module does not export a valid React component. ` +
`Expected a function, got ${typeof component}. ` +
`Make sure your module has a default export.`,
);
}

loadedComponents.set(formatName, component);
result.formats.push(formatName);

console.log(
`[CustomQuestionTypeLoader] Successfully loaded "${formatName}"`,
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error(
`[CustomQuestionTypeLoader] Failed to load "${formatName}":`,
errorMessage,
);
result.errors.push({ format: formatName, error: errorMessage });
}
}

// Register all successfully loaded components
if (loadedComponents.size > 0) {
result.renderers = registerCustomQuestionTypes(loadedComponents);
console.log(
`[CustomQuestionTypeLoader] Registered ${loadedComponents.size} custom question type(s)`,
);
}

if (result.errors.length > 0) {
console.warn(
`[CustomQuestionTypeLoader] ${result.errors.length} type(s) failed to load:`,
result.errors.map(e => e.format).join(', '),
);
}

return result;
}
60 changes: 60 additions & 0 deletions formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* CustomQuestionTypeRegistry.ts
*
* Converts a map of { formatName → React component } into JSON Forms
* RendererRegistryEntries. Each entry gets an auto-generated tester that
* matches on the schema's `format` field.
*
* Usage:
* const renderers = registerCustomQuestionTypes(componentsMap);
* // renderers can then be spread into the JsonForms renderers array
*/

import type {
JsonFormsRendererRegistryEntry,
RankedTester,
} from '@jsonforms/core';
import { rankWith, schemaMatches } from '@jsonforms/core';
import type { CustomQuestionTypeProps } from '../types/CustomQuestionTypeContract';
import { createCustomQuestionTypeRenderer } from '../renderers/CustomQuestionTypeAdapter';
import type React from 'react';

/**
* Creates a ranked tester for a custom question type based on its schema format.
*
* Uses priority 6 which is higher than default Material renderers (priority 3-5)
* but lower than specialized built-in question types (priority 10+).
*/
function createFormatTester(formatName: string): RankedTester {
return rankWith(
6,
schemaMatches(schema => {
return (schema as Record<string, unknown>)?.format === formatName;
}),
);
}

/**
* Registers custom question types by creating JSON Forms renderer entries.
*
* @param components - Map of format name → React component
* @returns Array of JsonFormsRendererRegistryEntry ready to be used with <JsonForms>
*/
export function registerCustomQuestionTypes(
components: Map<string, React.ComponentType<CustomQuestionTypeProps>>,
): JsonFormsRendererRegistryEntry[] {
const entries: JsonFormsRendererRegistryEntry[] = [];

for (const [formatName, component] of components) {
const tester = createFormatTester(formatName);
const renderer = createCustomQuestionTypeRenderer(formatName, component);

entries.push({ tester, renderer });

console.log(
`[CustomQuestionTypeRegistry] Registered renderer for format "${formatName}"`,
);
}

return entries;
}
Loading
Loading