Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b50bd27
fix lint errors in the formplyer
Jexsie Feb 11, 2026
ef92279
feat: secure custom question type loading via source extraction
Jexsie Feb 17, 2026
ea89e8f
feat: enhance module resolution and improve custom question type hand…
Mishael-2584 Feb 19, 2026
be93a91
added package changes
Mishael-2584 Feb 19, 2026
25a737e
chore: apply prettier formatting fixes
Mishael-2584 Feb 19, 2026
a77df63
Update md file
Mishael-2584 Feb 19, 2026
9597c2c
Merge branch 'dev' into custom_question_types
Bahati308 Feb 20, 2026
265116a
fix: resolve @ode/tokens import path for Dashboard build
najuna-brian Feb 20, 2026
1477367
Merge branch 'dev' into recovery/custom_question_types-20260224
najuna-brian Feb 25, 2026
07c9988
feat: clean up
Mishael-2584 Feb 26, 2026
d8f227c
fix: resolve linting errors in synkronus index.ts
Mishael-2584 Feb 26, 2026
11f89af
feat: secure custom question type loading via source extraction
Jexsie Feb 17, 2026
0c1dc32
clean up
Jexsie Feb 23, 2026
5575c11
remove security scanning of renderers
Jexsie Feb 24, 2026
a11ec3c
scan question_types folder for custom question types and create a man…
Jexsie Feb 26, 2026
705b427
use sandboxing since renderers are in commonjs and the formulus is in…
Jexsie Feb 26, 2026
7cda150
fix build errors
Jexsie Feb 26, 2026
a74f868
pass the jsonform context to the renderers
Jexsie Feb 26, 2026
1a373e4
feat: secure custom question type loading via source extraction
Jexsie Feb 17, 2026
7f5fcc9
feat: enhance module resolution and improve custom question type hand…
Mishael-2584 Feb 19, 2026
b9e3f8b
chore: apply prettier formatting fixes
Mishael-2584 Feb 19, 2026
1166f6d
feat: clean up
Mishael-2584 Feb 26, 2026
cbec829
feat: expose formulus API to custom question type renderers
Mishael-2584 Feb 26, 2026
851e32c
fix: correct custom question type scanning path and clean up
Mishael-2584 Mar 2, 2026
54215e0
chore: remove build artifact from tracking
Mishael-2584 Mar 2, 2026
b8dc428
Merge origin/dev into recovery/custom_question_types-20260224
Mishael-2584 Mar 2, 2026
8e8823e
fix: add react-native-svg explicit resolution in metro config
Mishael-2584 Mar 2, 2026
d2e16bd
fix: apply prettier formatting to formulus-formplayer
Mishael-2584 Mar 2, 2026
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
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
69 changes: 46 additions & 23 deletions formulus-formplayer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,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 @@ -407,7 +410,7 @@ function App() {
setCustomTypeRenderers(customQTResult.renderers);
setCustomTypeFormats(customQTResult.formats);
console.log(
`[Formplayer] Loaded ${customQTResult.renderers.length} custom question type(s)`,
`[Formplayer] Loaded ${customQTResult.renderers.length} custom question type(s): ${customQTResult.formats.join(', ')}`,
);
if (customQTResult.errors.length > 0) {
console.warn(
Expand Down Expand Up @@ -648,29 +651,46 @@ function App() {
}

// Timeout logic: if onFormInit is not called by native side
// Note: This timeout often fires as a false positive when the form is actually loading successfully.
// We use a longer timeout (20s) and only show error after additional delay to reduce false positives.
const initTimeout = setTimeout(() => {
if (isLoadingRef.current) {
// Check ref to see if still loading
console.warn('onFormInit was not called within timeout period (10s).');
setLoadError(
'Failed to initialize form: No data received from native host. Please try again.',
);
setIsLoading(false);
isLoadingRef.current = false;
if (
window.ReactNativeWebView &&
window.ReactNativeWebView.postMessage
) {
window.ReactNativeWebView.postMessage(
JSON.stringify({
type: 'error',
message:
'Initialization timeout in WebView: onFormInit not called.',
}),
// Only log a debug message - don't show warning to user yet
// The form may still be loading successfully
if (process.env.NODE_ENV === 'development') {
console.debug(
'[Formplayer] onFormInit not yet received (timeout: 20s). Still waiting...',
);
}
// Only show error if we're still loading after an additional delay
// This prevents false positives when form loads successfully but slightly delayed
setTimeout(() => {
if (isLoadingRef.current) {
// Only now show error - form truly failed to load
console.warn(
'[Formplayer] onFormInit timeout: Form failed to initialize after extended wait.',
);
setLoadError(
'Failed to initialize form: No data received from native host. Please try again.',
);
setIsLoading(false);
isLoadingRef.current = false;
if (
window.ReactNativeWebView &&
window.ReactNativeWebView.postMessage
) {
window.ReactNativeWebView.postMessage(
JSON.stringify({
type: 'error',
message:
'Initialization timeout in WebView: onFormInit not called.',
}),
);
}
}
}, 5000); // Additional 5 seconds before showing actual error
}
}, 10000); // 10 second timeout
}, 20000); // Increased to 20 seconds to reduce false positives

// Cleanup function when component unmounts
return () => {
Expand Down Expand Up @@ -848,12 +868,15 @@ function App() {
});

// 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(fmt => {
instance.addFormat(fmt, () => true);
customTypeFormats.forEach(formatName => {
// Register as format so AJV accepts "format": "formatName" in schemas
instance.addFormat(formatName, () => true);
});
console.log(
`[Formplayer] Registered ${customTypeFormats.length} custom format(s) with AJV`,
`[Formplayer] Registered ${customTypeFormats.length} custom question type format(s) with AJV`,
);
}

Expand Down
9 changes: 6 additions & 3 deletions formulus-formplayer/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ import App from './App';
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',
);
// Only log in development mode
if (import.meta.env.DEV || process.env.NODE_ENV === 'development') {
console.log(
'[index] Exposed React and MaterialUI to global scope for custom renderers',
);
}
}

const root = ReactDOM.createRoot(
Expand Down
74 changes: 69 additions & 5 deletions formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,50 @@ class CustomQuestionErrorBoundary extends Component<
return (
<div
style={{
padding: '12px',
padding: '16px',
border: '1px solid #f44336',
borderRadius: '4px',
backgroundColor: '#fce4ec',
backgroundColor: '#ffebee',
color: '#c62828',
margin: '8px 0',
}}>
<strong>Custom question type "{this.props.formatName}" failed</strong>
<br />
<small>{this.state.error?.message}</small>
<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>
);
}
Expand Down Expand Up @@ -102,6 +137,7 @@ export function createCustomQuestionTypeRenderer(
// 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',
Expand Down Expand Up @@ -138,6 +174,34 @@ export function createCustomQuestionTypeRenderer(

const jsonFormsContext = useJsonForms();

// For ranking format: if people not in field schema, try to get from root schema
if (
schemaObj.format === 'ranking' &&
!config.people &&
jsonFormsContext?.core?.schema
) {
const rootSchema = jsonFormsContext.core.schema as Record<
string,
unknown
>;
const rootProperties = rootSchema.properties as
| Record<string, unknown>
| undefined;
if (rootProperties && path) {
// Extract field name from path (e.g., "#/properties/ranking_field" -> "ranking_field")
const fieldName = path.split('/').pop();
if (fieldName && rootProperties[fieldName]) {
const fieldSchema = rootProperties[fieldName] as Record<
string,
unknown
>;
if (fieldSchema.people) {
config.people = fieldSchema.people;
}
}
}
}

const customProps: CustomQuestionTypeProps = {
value: data,
config,
Expand Down
14 changes: 8 additions & 6 deletions formulus-formplayer/src/services/CustomQuestionTypeLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* 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
*
* Custom question types use "format": "formatName" in schemas (not "type").
*/

import type { JsonFormsRendererRegistryEntry } from '@jsonforms/core';
Expand All @@ -32,9 +34,9 @@ export interface CustomQuestionTypeLoadResult {
}

/**
* Load custom question types from a manifest.
* Load custom question types from a manifest containing source strings.
*
* @param manifest - The manifest describing available custom question types
* @param manifest - The manifest describing available custom question types (with source code)
* @returns Loaded renderers, format strings, and any errors
*/
export async function loadCustomQuestionTypes(
Expand Down Expand Up @@ -109,12 +111,12 @@ export async function loadCustomQuestionTypes(
result.formats.push(formatName);

console.log(
`[CustomQuestionTypeLoader] Successfully loaded "${formatName}"`,
`[CustomQuestionTypeLoader] Successfully loaded "${formatName}"`,
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error(
`[CustomQuestionTypeLoader] Failed to load "${formatName}":`,
`[CustomQuestionTypeLoader] Failed to load "${formatName}":`,
errorMessage,
);
result.errors.push({ format: formatName, error: errorMessage });
Expand All @@ -125,13 +127,13 @@ export async function loadCustomQuestionTypes(
if (loadedComponents.size > 0) {
result.renderers = registerCustomQuestionTypes(loadedComponents);
console.log(
`[CustomQuestionTypeLoader] 📦 Registered ${loadedComponents.size} custom question type(s)`,
`[CustomQuestionTypeLoader] Registered ${loadedComponents.size} custom question type(s)`,
);
}

if (result.errors.length > 0) {
console.warn(
`[CustomQuestionTypeLoader] ${result.errors.length} type(s) failed to load:`,
`[CustomQuestionTypeLoader] ${result.errors.length} format(s) failed to load:`,
result.errors.map(e => e.format).join(', '),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ function createFormatTester(formatName: string): RankedTester {
return rankWith(
6,
schemaMatches(schema => {
return (schema as Record<string, unknown>)?.format === formatName;
const schemaObj = schema as Record<string, unknown>;
return schemaObj?.format === formatName;
}),
);
}
Expand Down
1 change: 1 addition & 0 deletions formulus-formplayer/src/services/ExtensionsLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface LoadedRenderer {
*/
export interface ExtensionLoadResult {
renderers: JsonFormsRendererRegistryEntry[];
// Functions with explicit signature for type safety
functions: Map<string, (...args: any[]) => any>;
definitions: Record<string, any>;
errors: Array<{ type: string; message: string; details?: any }>;
Expand Down
24 changes: 10 additions & 14 deletions formulus-formplayer/src/types/CustomQuestionTypeContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,11 @@
* Form authors create components that accept these props — no JSON Forms knowledge needed.
*
* Usage in JSON Schema:
* { "type": "string", "format": "select-person", "showSearch": true, "people": [...] }
* { "type": "string", "format": "rating-stars", "maxStars": 5 }
*
* Usage in custom_app:
* custom_app/question_types/select-person/index.js
* export default function SelectPerson({ value, config, onChange, validation }) {
* const people = config.people; // custom property
* const showSearch = config.showSearch; // custom property
* ...
* }
* custom_app/question_types/rating-stars/renderer.js
* export default function RatingStars({ value, config, onChange, validation }) { ... }
*/

/**
Expand All @@ -24,10 +20,9 @@ export interface CustomQuestionTypeProps {
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.
* Configuration extracted from schema properties.
* Includes all properties alongside "format" (except reserved ones like type, title, etc.).
* For example, if schema has `"format": "rating", "maxStars": 5`, then `config.maxStars === 5`.
*/
config: Record<string, unknown>;

Expand All @@ -45,7 +40,7 @@ export interface CustomQuestionTypeProps {
/** Whether the field is currently enabled/editable */
enabled: boolean;

/** The field's unique path in the form data (e.g., "ranking_field") */
/** The field's unique path in the form data (e.g., "satisfaction") */
fieldPath: string;

/** Display label from the schema's `title` property */
Expand All @@ -63,13 +58,14 @@ export interface CustomQuestionTypeProps {

/**
* 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.
* Each entry maps a format string to the source code of the module that renders it.
* The RN side reads the JS file and passes the source string here for sandboxed evaluation.
*/
export interface CustomQuestionTypeManifest {
custom_types: Record<
string,
{
/** The JavaScript source code of the renderer module */
/** The JS source code of the module (read by RN via RNFS.readFile) */
source: string;
}
>;
Expand Down
Loading
Loading