feat: Support for Custom Question Types#333
Open
Jexsie wants to merge 8 commits intoOpenDataEnsemble:devfrom
Open
feat: Support for Custom Question Types#333Jexsie wants to merge 8 commits intoOpenDataEnsemble:devfrom
Jexsie wants to merge 8 commits intoOpenDataEnsemble:devfrom
Conversation
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>
29f8c30 to
be93a91
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
formatfield in JSON Schema (nottype), 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):
Security Model
Custom renderers run through two layers of security:
CustomQuestionTypeScanner.tsfetch(),eval(),new Function(),XMLHttpRequest,WebSocket,localStorage,sessionStorage,indexedDB,document.cookie,navigator.sendBeacon,importScripts())CustomQuestionTypeLoader.tsnew Function()with onlymodule,exports,React, andMaterialUIin scope. No access tofetch,document,localStorage,window, or any other browser API.Files Changed
New Files
CustomQuestionTypeScanner.tsformulus/src/services/question_types/directory, reads renderer.js files, screens against security blocklistCustomQuestionTypeLoader.tsformulus-formplayer/src/services/new Function(), extracts React componentCustomQuestionTypeRegistry.tsformulus-formplayer/src/services/CustomQuestionTypeAdapter.tsxformulus-formplayer/src/renderers/ControlProps→ simplifiedCustomQuestionTypeProps; wraps in ErrorBoundary + QuestionShellCustomQuestionTypeContract.tsformulus-formplayer/src/types/Modified Files
FormplayerModal.tsxscanCustomQuestionTypes()when opening a form; passes result to Formplayer viaFormInitData.customQuestionTypesApp.tsx(formplayer)loadCustomQuestionTypes(); registers renderers and AJV formats; exposesReactandMaterialUItowindowfor sandbox accessFormulusInterfaceDefinition.tsFormInitDatawith optionalcustomQuestionTypesfieldHow it Works for Form Authors
Convention
Custom question types live in the app bundle at:
The folder name becomes the
formatvalue used in JSON Schema. The file must be namedrenderer.jsand must export a default React function component.Renderer Contract
Every custom renderer receives these props — no JSON Forms knowledge needed:
ReactandMaterialUI(the full@mui/materialpackage) 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" }"type": "array""format": "ranking"ranking/renderer.jscustom renderer"people": [...]config.people— the list of people to rank"items": { "type": "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" }"type": "string""format": "select-person"select-person/renderer.jscustom renderer"people": [...]config.people"showSearch": trueconfig.showSearch- enables the autocomplete search UI"showPhoto": falseconfig.showPhoto- whether to show photos in the listStored 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:config.*type,title,description,format,enum,const,default,required,properties,items,oneOf,anyOf,allOf,$ref,$schema,pattern,minLength,maxLength,minimum,maximum,minItems,maxItemspeople,showSearch,showPhoto,placeholder,promptText,maxStars, etc.Additionally,
x-configcan 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.jswith Backbone views and Handlebars templates. They were declared in the Excelprompt_typessheet and referenced directly in thetypecolumn (e.g.,type: select_person).ODE uses JSON Forms + JSON Schema, where
typeis restricted to standard types. The equivalent mechanism isformat:prompt_typessheetquestion_types/{name}/renderer.jsconventiontypecolumn in survey sheet"format"field in JSON Schemadisplay.ranking.age_min, etc.)configobjectWhat This PR Does NOT Cover (Future Work)
peoplearrays in the schema. Production forms will need people populated dynamically from the database (viaformParamsor the dynamic choice list system).p_ranking,p_ranking_male,p_ranking_female) still use the old$reftorankingResult. They will need to be updated to use"format": "ranking"with the custom type system.React.createElement()directly. A lightweight JSX transform could be added later for better DX.Related
ranking/renderer.js,select-person/renderer.js,test-simple/renderer.js) and the test form.Fixes #251
Fixes #252
Fixes #253
Testing Details:
Migration Notes:
module.exports = { default: Component }patternrenderer.js(notindex.js)question_types/ranking/→format: "ranking")Key Changes
Formulus RN Side
renderer.jsfiles, screens against blocklist, passes source strings in FormInitDataFormPlayer WebView Side
new Function()sandbox instead of dynamic importsSecurity Features
Video Demo: https://github.com/user-attachments/assets/db53a67b-04da-436c-b972-21573e73afa8