diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index c055a748f..28873ac5d 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -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'; @@ -281,6 +282,11 @@ function App() { const [extensionDefinitions, setExtensionDefinitions] = useState< Record >({}); + // Custom question type renderers (loaded from custom_app) + const [customTypeRenderers, setCustomTypeRenderers] = useState< + JsonFormsRendererRegistryEntry[] + >([]); + const [customTypeFormats, setCustomTypeFormats] = useState([]); // Reference to the FormulusClient instance and loading state const formulusClient = useRef(FormulusClient.getInstance()); @@ -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.', @@ -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 @@ -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 @@ -987,6 +1033,7 @@ function App() { ...shellMaterialRenderers, ...materialRenderers, ...customRenderers, + ...customTypeRenderers, // Custom question types from custom_app ...extensionRenderers, // Extension renderers (highest priority) ]} cells={materialCells} diff --git a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx new file mode 100644 index 000000000..4569953b0 --- /dev/null +++ b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx @@ -0,0 +1,125 @@ +/** + * 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 ( +
+ Custom question type "{this.props.formatName}" failed +
+ {this.state.error?.message} +
+ ); + } + 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, +): React.ComponentType { + const AdapterInner: React.FC = ({ + 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) ?? {}, + 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 ( + + + + + + ); + }; + + AdapterInner.displayName = `CustomQuestionType(${formatName})`; + + // Wrap with JSON Forms HOC + return withJsonFormsControlProps(AdapterInner); +} diff --git a/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts b/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts new file mode 100644 index 000000000..d89a2b305 --- /dev/null +++ b/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts @@ -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 { + 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 + >(); + + 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; +} diff --git a/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts b/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts new file mode 100644 index 000000000..a0eeda994 --- /dev/null +++ b/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts @@ -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)?.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 + */ +export function registerCustomQuestionTypes( + components: Map>, +): 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; +} diff --git a/formulus-formplayer/src/services/ExtensionsLoader.ts b/formulus-formplayer/src/services/ExtensionsLoader.ts index 14884ec14..4f73e0e0e 100644 --- a/formulus-formplayer/src/services/ExtensionsLoader.ts +++ b/formulus-formplayer/src/services/ExtensionsLoader.ts @@ -189,8 +189,7 @@ async function loadRenderer( async function loadFunction( metadata: ExtensionFunctionMetadata, basePath: string, - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -): Promise { +): Promise<((...args: any[]) => any) | null> { try { // Construct module path const modulePath = basePath diff --git a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts new file mode 100644 index 000000000..1d250bed4 --- /dev/null +++ b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts @@ -0,0 +1,76 @@ +/** + * CustomQuestionTypeContract.ts + * + * Defines the public interface that custom question type renderers must follow. + * Form authors create components that accept these props — no JSON Forms knowledge needed. + * + * Usage in JSON Schema: + * { + * "type": "string", + * "format": "select-person", + * "title": "Select the focal person", + * "showSearch": true, + * "people": [ { "id": "p1", "name": "John Doe" }, ... ] + * } + * + * Usage in custom_app: + * custom_app/question_types/select-person/index.js + * export default function SelectPerson({ value, config, onChange }) { + * const people = config.people; + * const showSearch = config.showSearch; + * ... + * } + */ + +/** + * Props that every custom question type renderer receives. + */ +export interface CustomQuestionTypeProps { + /** Current field value (type depends on the question's JSON schema type) */ + value: unknown; + + /** + * The full JSON Schema object for this field, exposed as `config`. + * Custom properties live directly on the schema — access them like + * `config.people`, `config.showSearch`, `config.query`, etc. + * Standard keys like `type`, `format`, `title` are also available. + */ + config: Record; + + /** Callback to update the field value. Call with the new value. */ + onChange: (newValue: unknown) => void; + + /** Current validation state for this field */ + validation: { + /** Whether the field currently has a validation error */ + error: boolean; + /** The validation error message (empty string if no error) */ + message: string; + }; + + /** Whether the field is currently enabled/editable */ + enabled: boolean; + + /** The field's unique path in the form data (e.g., "ranking_field") */ + fieldPath: string; + + /** Display label from the schema's `title` property */ + label: string; + + /** Optional description from the schema's `description` property */ + description?: string; +} + +/** + * Manifest passed from the native side describing available custom question types. + * Each entry maps a format string to the path of the module that renders it. + */ +export interface CustomQuestionTypeManifest { + custom_types: Record< + string, + { + /** Path to the JS module (e.g., "file:///path/to/question_types/ranking/index.js") */ + modulePath: string; + } + >; +} diff --git a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts index fef86f436..d63ad3cf3 100644 --- a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts +++ b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts @@ -46,6 +46,7 @@ export interface ExtensionMetadata { * @property {any} [formSchema] - JSON Schema for the form structure and validation (optional) * @property {any} [uiSchema] - UI Schema for form rendering layout (optional) * @property {ExtensionMetadata} [extensions] - Custom app extensions (optional) + * @property {object} [customQuestionTypes] - Custom question type manifest from custom_app (optional) */ export interface FormInitData { formType: string; @@ -56,6 +57,9 @@ export interface FormInitData { uiSchema?: unknown; operationId?: string; extensions?: ExtensionMetadata; + customQuestionTypes?: { + custom_types: Record; + }; } /** diff --git a/formulus/src/services/FormService.ts b/formulus/src/services/FormService.ts index 91c8fe0f4..917885684 100644 --- a/formulus/src/services/FormService.ts +++ b/formulus/src/services/FormService.ts @@ -115,12 +115,13 @@ export class FormService { } const formSpecFolders = await RNFS.readDir(formSpecsDir); - // Skip non-form directories (e.g. extensions/, .hidden) + // Skip non-form directories (e.g. extensions/, question_types/, .hidden) const formDirs = formSpecFolders.filter( f => f.isDirectory() && !f.name.startsWith('.') && - f.name !== 'extensions', + f.name !== 'extensions' && + f.name !== 'question_types', ); for (const formDir of formDirs) { diff --git a/formulus/src/webview/FormulusInterfaceDefinition.ts b/formulus/src/webview/FormulusInterfaceDefinition.ts index fef86f436..d63ad3cf3 100644 --- a/formulus/src/webview/FormulusInterfaceDefinition.ts +++ b/formulus/src/webview/FormulusInterfaceDefinition.ts @@ -46,6 +46,7 @@ export interface ExtensionMetadata { * @property {any} [formSchema] - JSON Schema for the form structure and validation (optional) * @property {any} [uiSchema] - UI Schema for form rendering layout (optional) * @property {ExtensionMetadata} [extensions] - Custom app extensions (optional) + * @property {object} [customQuestionTypes] - Custom question type manifest from custom_app (optional) */ export interface FormInitData { formType: string; @@ -56,6 +57,9 @@ export interface FormInitData { uiSchema?: unknown; operationId?: string; extensions?: ExtensionMetadata; + customQuestionTypes?: { + custom_types: Record; + }; } /**