Skip to content

Comments

feat: Support for Custom Question Types#333

Open
Jexsie wants to merge 8 commits intoOpenDataEnsemble:devfrom
Jexsie:custom_question_types
Open

feat: Support for Custom Question Types#333
Jexsie wants to merge 8 commits intoOpenDataEnsemble:devfrom
Jexsie:custom_question_types

Conversation

@Jexsie
Copy link
Collaborator

@Jexsie Jexsie commented Feb 17, 2026

Custom Question Types - Plugin system for app-defined renderers

Summary

This PR introduces a custom question type system that allows custom apps (e.g., AnthroCollect) to define their own form field renderers as plain JavaScript files, which are dynamically loaded and sandboxed by the Formplayer at runtime.

Custom question types are identified by the format field in JSON Schema (not type), because JSON Schema strictly only allows standard types (string, number, integer, boolean, array, object). This is consistent with how all existing built-in question types (photo, GPS, QR code, signature, audio, video, adate) already work in ODE.

This is an MVP implementation to prove the pipeline works end-to-end. Two real-world custom question types - Ranking (pairwise ELO) and Select Person - are implemented in the AnthroCollect companion PR to validate the system.


Architecture

The pipeline has four stages across Formulus (React Native) and Formplayer (WebView):

Custom App Bundle
  └── question_types/
        └── {format-name}/renderer.js     ← Plain JS files authored by form developers

        ↓ Stage 1: Scan + Security Screen (React Native)

CustomQuestionTypeScanner.ts
        ↓ source strings in manifest

FormInitData.customQuestionTypes = {
  custom_types: {
    "ranking":       { source: "..." },
    "select-person": { source: "..." }
  }
}
        ↓ Stage 2: Sandboxed Evaluation (WebView)

CustomQuestionTypeLoader.ts   →   new Function('module','exports','React','MaterialUI', source)
        ↓ React components extracted

        ↓ Stage 3: JSON Forms Registration

CustomQuestionTypeRegistry.ts   →   tester: schema.format === formatName (priority 6)
        ↓ renderer entries created

        ↓ Stage 4: Props Adaptation + Error Isolation

CustomQuestionTypeAdapter.tsx   →   ControlProps → CustomQuestionTypeProps + ErrorBoundary
        ↓

JsonForms renders the custom component inside QuestionShell

Security Model

Custom renderers run through two layers of security:

Layer Where What it does
Static Blocklist Formulus (RN) - CustomQuestionTypeScanner.ts Rejects any source containing dangerous patterns (fetch(), eval(), new Function(), XMLHttpRequest, WebSocket, localStorage, sessionStorage, indexedDB, document.cookie, navigator.sendBeacon, importScripts())
Scoped Sandbox Formplayer (WebView) - CustomQuestionTypeLoader.ts Source is evaluated via new Function() with only module, exports, React, and MaterialUI in scope. No access to fetch, document, localStorage, window, or any other browser API.

Files Changed

New Files

File Location Purpose
CustomQuestionTypeScanner.ts formulus/src/services/ RN-side: scans question_types/ directory, reads renderer.js files, screens against security blocklist
CustomQuestionTypeLoader.ts formulus-formplayer/src/services/ WebView-side: evaluates source in sandboxed new Function(), extracts React component
CustomQuestionTypeRegistry.ts formulus-formplayer/src/services/ Creates JSON Forms renderer entries with format-matching testers (priority 6)
CustomQuestionTypeAdapter.tsx formulus-formplayer/src/renderers/ Bridges JSON Forms ControlProps → simplified CustomQuestionTypeProps; wraps in ErrorBoundary + QuestionShell
CustomQuestionTypeContract.ts formulus-formplayer/src/types/ Defines the public props interface that custom renderers receive

Modified Files

File What changed
FormplayerModal.tsx Calls scanCustomQuestionTypes() when opening a form; passes result to Formplayer via FormInitData.customQuestionTypes
App.tsx (formplayer) Loads custom QT manifest via loadCustomQuestionTypes(); registers renderers and AJV formats; exposes React and MaterialUI to window for sandbox access
FormulusInterfaceDefinition.ts Extended FormInitData with optional customQuestionTypes field

How it Works for Form Authors

Convention

Custom question types live in the app bundle at:

question_types/
  ├── ranking/
  │     └── renderer.js        ← folder name = format name used in schema
  ├── select-person/
  │     └── renderer.js
  └── my-custom-widget/
        └── renderer.js

The folder name becomes the format value used in JSON Schema. The file must be named renderer.js and must export a default React function component.

Renderer Contract

Every custom renderer receives these props — no JSON Forms knowledge needed:

function MyRenderer({
  value,        // Current field value
  config,       // All non-reserved schema properties (e.g., people, showSearch, maxStars)
  onChange,      // Call with new value: onChange(newValue)
  validation,   // { error: boolean, message: string }
  enabled,      // Whether the field is editable
  label,        // From schema.title
  description,  // From schema.description
}) {
  // Return React elements using React.createElement() and MaterialUI components
}

module.exports = { default: MyRenderer };

React and MaterialUI (the full @mui/material package) are injected into scope automatically — no imports needed.


Writing the Ranking Question Type

schema.json:

{
  "ranking_field": {
    "type": "array",
    "format": "ranking",
    "title": "Rank these people by influence",
    "description": "Click on the person you prefer in each pair",
    "items": { "type": "string" },
    "people": [
      { "id": "person1", "name": "John Doe", "age": 35, "sex": "male", "clan": "Alpha" },
      { "id": "person2", "name": "Jane Smith", "age": 28, "sex": "female", "clan": "Beta" },
      { "id": "person3", "name": "Peter Jones", "age": 42, "sex": "male", "clan": "Gamma" },
      { "id": "person4", "name": "Alice Brown", "age": 30, "sex": "female", "clan": "Delta" }
    ]
  }
}

ui.json:

{
  "type": "Control",
  "scope": "#/properties/ranking_field",
  "label": "Rank these people by influence"
}
Schema field Purpose
"type": "array" Standard JSON Schema type — the result is an array of person IDs in ranked order
"format": "ranking" Tells the Formplayer to use the ranking/renderer.js custom renderer
"people": [...] Passed to the renderer as config.people — the list of people to rank
"items": { "type": "string" } Describes array items (each is a person ID string)

Stored value: ["person3", "person1", "person4", "person2"] — an array of person IDs in ranked order (highest first).


Writing the Select Person Question Type

schema.json:

{
  "selected_person": {
    "type": "string",
    "format": "select-person",
    "title": "Select the focal person",
    "description": "Choose a person from the list",
    "showSearch": true,
    "showPhoto": false,
    "people": [
      { "id": "person1", "name": "John Doe", "age": 35, "sex": "male", "clan": "Alpha" },
      { "id": "person2", "name": "Jane Smith", "age": 28, "sex": "female", "clan": "Beta" },
      { "id": "person3", "name": "Peter Jones", "age": 42, "sex": "male", "clan": "Gamma" }
    ]
  }
}

ui.json:

{
  "type": "Control",
  "scope": "#/properties/selected_person",
  "label": "Select the focal person"
}
Schema field Purpose
"type": "string" Standard JSON Schema type - the result is a person ID string
"format": "select-person" Tells the Formplayer to use the select-person/renderer.js custom renderer
"people": [...] Passed to the renderer as config.people
"showSearch": true Passed as config.showSearch - enables the autocomplete search UI
"showPhoto": false Passed as config.showPhoto - whether to show photos in the list

Stored value: "person2" — the ID of the selected person.


How Config Passthrough Works

Any property in the schema that is not a reserved JSON Schema keyword is automatically extracted and passed to the renderer as config:

Reserved (NOT passed) Passed as config.*
type, title, description, format, enum, const, default, required, properties, items, oneOf, anyOf, allOf, $ref, $schema, pattern, minLength, maxLength, minimum, maximum, minItems, maxItems Everything else: people, showSearch, showPhoto, placeholder, promptText, maxStars, etc.

Additionally, x-config can be used for explicit configuration and takes precedence over inline properties.


How This Relates to the Old System (ODK-X / OMO)

In ODK-X, custom question types used customPromptTypes.js with Backbone views and Handlebars templates. They were declared in the Excel prompt_types sheet and referenced directly in the type column (e.g., type: select_person).

ODE uses JSON Forms + JSON Schema, where type is restricted to standard types. The equivalent mechanism is format:

ODK-X (OMO) ODE
Declaration Excel prompt_types sheet question_types/{name}/renderer.js convention
Identifier type column in survey sheet "format" field in JSON Schema
Renderer Backbone view + Handlebars template React function component
Config Excel columns (display.ranking.age_min, etc.) Schema properties → config object
Security None (runs in ODK-X Android app) Two-layer: static blocklist + sandboxed eval
Crash isolation None React ErrorBoundary per custom type

What This PR Does NOT Cover (Future Work)

  • Dynamic people data - The MVP uses hardcoded people arrays in the schema. Production forms will need people populated dynamically from the database (via formParams or the dynamic choice list system).
  • Migration of existing ranking/select-person forms - The production forms (p_ranking, p_ranking_male, p_ranking_female) still use the old $ref to rankingResult. They will need to be updated to use "format": "ranking" with the custom type system.
  • JSX support - Renderers currently use React.createElement() directly. A lightweight JSX transform could be added later for better DX.

Related

Fixes #251
Fixes #252
Fixes #253

Testing Details:

  • Tested with AnthroCollect custom question types (ranking, select-person, test-simple)
  • Verified sandbox isolation (custom code cannot access fetch, localStorage, etc.)
  • Confirmed React and MaterialUI are accessible from global scope
  • Verified config extraction from schema properties and x-config

Migration Notes:

  • Custom question type modules must export components using module.exports = { default: Component } pattern
  • Files must be named renderer.js (not index.js)
  • Format names match directory names (e.g., question_types/ranking/format: "ranking")

Key Changes

Formulus RN Side

  • CustomQuestionTypeScanner.ts: Reads renderer.js files, screens against blocklist, passes source strings in FormInitData
  • FormplayerModal.tsx: Calls scanner and includes custom question types in FormInitData

FormPlayer WebView Side

  • CustomQuestionTypeLoader.ts: Rewritten to use new Function() sandbox instead of dynamic imports
    • Accesses React and MaterialUI from global scope (window, globalThis, self)
    • Validates component exports (supports default and named exports)
    • Comprehensive error handling
  • CustomQuestionTypeRegistry.ts: Auto-generates testers with priority 6, creates renderer entries
  • CustomQuestionTypeAdapter.tsx: Maps ControlProps → CustomQuestionTypeProps, wraps in ErrorBoundary
    • Extracts config from schema properties (excluding reserved ones)
    • Merges with x-config (x-config takes precedence)
  • App.tsx: Orchestrates loading, registers formats with AJV, merges with built-in renderers

Security Features

  • Static blocklist screening (RN-side): Rejects fetch, XMLHttpRequest, eval, localStorage, etc.
  • Scoped sandbox (WebView-side): Only React, MaterialUI, module, and exports are accessible
  • Error boundaries: Isolates crashes in custom components
  • Source strings only: No file paths passed to WebView

Video Demo: https://github.com/user-attachments/assets/db53a67b-04da-436c-b972-21573e73afa8

Jexsie and others added 4 commits February 19, 2026 14:44
Signed-off-by: Jessie Ssebuliba <jessiessebuliba@gmail.com>
Replace dynamic import() of file:// URIs with a sandboxed evaluation
approach for custom question type modules.

Security:
- Add CustomQuestionTypeScanner (RN side) that reads index.js files as
  strings and screens them against a blocklist (fetch, XMLHttpRequest,
  eval, document.cookie, localStorage, etc.)
- Rewrite CustomQuestionTypeLoader (WebView side) to evaluate source
  in a scoped sandbox via new Function(), exposing only React and MUI
- Manifest shape changed from { modulePath: string } to { source: string }

New files:
- formulus/src/services/CustomQuestionTypeScanner.ts
- formulus-formplayer/src/services/CustomQuestionTypeLoader.ts (rewritten)
- formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts
- formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx
- formulus-formplayer/src/types/CustomQuestionTypeContract.ts
- formulus-formplayer/docs/custom-question-types-architecture.md

Modified files:
- formulus/src/components/FormplayerModal.tsx (calls scanner)
- FormulusInterfaceDefinition.ts (both projects, modulePath → source)
- formulus-formplayer/src/App.tsx (orchestration)

Signed-off-by: Jessie Ssebuliba <jessiessebuliba@gmail.com>
@najuna-brian najuna-brian changed the title Custom question types feat: Support for Custom Question Types Feb 19, 2026
@Mishael-2584 Mishael-2584 marked this pull request as ready for review February 19, 2026 13:22
@najuna-brian najuna-brian requested a review from r0ssing February 19, 2026 13:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement "select person" widget Implement ranking widget for forms Widget Implementation

4 participants