Custom Extensions
Create custom question types and extend the Formulus Formplayer with specialized input fields.
The ODE extension system allows developers to package custom question types that work seamlessly with the form system. Extensions are deployed via app bundles and automatically available to all users.
Overview
The extension system enables you to:
- Custom Question Types - Add specialized input components beyond built-in types
- Business Logic - Implement domain-specific validation and processing
- Reusable Components - Package extensions for distribution to other implementations
- Automatic Distribution - Deploy via app bundles; users get updates automatically
Quick Start
Creating a Custom Question Type
A custom question type consists of:
- TypeScript/JavaScript Component - React component for rendering
- Type Definition - JSON schema for form configuration
- Registration - Entry in the extension registry
Example: Custom Phone Number Input
// phone-number-type.tsx
import React from 'react';
import { Control } from 'react-hook-form';
interface PhoneNumberProps {
value: string;
onChange: (value: string) => void;
format: 'intl' | 'local'; // From UI Schema
country?: string; // From UI Schema
required?: boolean;
error?: string;
}
export const PhoneNumberControl: React.FC<PhoneNumberProps> = ({
value,
onChange,
format,
country,
error,
required
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target.value;
// Custom formatting logic
const cleaned = input.replace(/\D/g, '');
const formatted = formatPhoneNumber(cleaned, format, country);
onChange(formatted);
};
return (
<div>
<input
type="tel"
value={value}
onChange={handleChange}
placeholder={getPlaceholder(format, country)}
required={required}
aria-invalid={!!error}
/>
{error && <span className="error">{error}</span>}
</div>
);
};
// Helper functions
function formatPhoneNumber(
digits: string,
format: 'intl' | 'local',
country?: string
): string {
if (format === 'intl') {
return `+${digits}`; // International format
}
// Local format based on country
if (country === 'UG') {
return digits.replace(/(\d{3})(\d{2})(\d{6})/, '+256$2 $3');
}
return digits;
}
function getPlaceholder(format: 'intl' | 'local', country?: string): string {
if (format === 'intl') return '+256 701 234567';
if (country === 'UG') return '0701 234567';
return '(Enter phone number)';
}
Defining in Schema
Define the custom type in your form schema:
{
"schema": {
"type": "object",
"properties": {
"phone": {
"type": "string",
"title": "Phone Number",
"x-custom": {
"type": "phone-number",
"format": "intl",
"country": "UG"
}
}
}
},
"uischema": {
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/phone"
}
]
}
}
Registering the Extension
Extensions are registered when the app initializes using the extension system:
// In Formulus initialization or custom app setup
import { registerExtension } from '@ode/formulus-extensions';
import { PhoneNumberControl } from './phone-number-type';
registerExtension({
name: 'phone-number',
renderer: PhoneNumberControl,
schema: {
type: 'string',
x-custom: {
type: 'phone-number'
}
},
validation: {
pattern: '^\\+?\\d{6,15}$',
minLength: 6,
maxLength: 15
},
metadata: {
displayName: 'Phone Number',
description: 'International or local phone number input',
version: '1.0.0'
}
});
Extension Types
1. Custom Question Type
New input component for collecting specific data types:
interface QuestionTypeExtension {
name: string; // Unique type ID
renderer: React.ComponentType; // React component
supportedFormats?: string[]; // Optional format variants
validation?: ValidationSchema; // Validation rules
metadata: ExtensionMetadata;
}
Examples:
- Phone number with formatting
- GPS coordinate input with map
- Color picker
- Time range selector
- Signature capture with pressure
2. Business Logic Extension
Custom functions for validation, calculation, or data processing:
interface BusinessLogicExtension {
name: string;
description: string;
functions: {
[key: string]: (params: any) => any;
};
metadata: ExtensionMetadata;
}
// Example: Custom validation function
registerLogicExtension({
name: 'advanced-validations',
functions: {
validateHouseholdStructure: (household) => {
// Validate household relationships
const adults = household.members.filter(m => m.age >= 18);
return adults.length > 0;
},
calculateHouseholdSize: (household) => {
return household.members.length;
}
}
});
// Use in form
{
"type": "object",
"properties": {
"members": {
"type": "array",
"x-validation": {
"function": "validateHouseholdStructure"
}
}
}
}
3. Data Enhancement Extension
Augment observations with computed or retrieved data:
interface DataEnhancementExtension {
name: string;
enhancers: {
[key: string]: (obs: Observation) => Promise<any>;
};
}
// Example: Fetch location name from coordinates
registerDataEnhancer({
name: 'location-enrichment',
enhancers: {
reverseGeocode: async (observation) => {
const { lat, lng } = observation.data.location;
const response = await fetch(
`https://api.example.com/reverse?lat=${lat}&lng=${lng}`
);
return {
location_name: response.locality,
location_admin: response.admin2,
location_country: response.country
};
}
}
});
Packaging Extensions
App Bundle Structure
Extensions are distributed as part of the app bundle:
app-bundle.zip
├── forms/
│ └── *.json # Form definitions
├── question_types/
│ ├── custom-phone-number.js
│ ├── custom-map-field.js
│ └── custom-signature.js
├── logic/
│ ├── household-validations.js
│ └── calculations.js
├── styles/
│ └── extensions.css # Custom CSS for extensions
└── metadata.json
Metadata File
Define extension metadata in metadata.json:
{
"version": "1.2.0",
"description": "Custom question types for household surveys",
"forms": ["household", "hh_person", "hh_follow_up"],
"extensions": [
{
"name": "phone-number",
"type": "question-type",
"description": "International phone number input",
"version": "1.0.0"
},
{
"name": "location-detail",
"type": "question-type",
"description": "GPS with map preview",
"version": "1.1.0"
},
{
"name": "household-validations",
"type": "business-logic",
"description": "Validation rules for household data",
"version": "1.0.0"
}
],
"dependencies": [
"formulus >= 1.0.0",
"formplayer >= 1.0.0"
],
"author": "Your Organization",
"license": "MIT"
}
Deployment
Upload App Bundle
Use the Synkronus CLI or API to deploy:
synk app-bundle upload path/to/bundle.zip
Or API:
curl -X PUT https://synkronus.example.com/api/v1/app-bundle \
-H "Authorization: Bearer $TOKEN" \
-F "[email protected]"
Versioning
Maintain multiple versions:
# List versions
synk app-bundle list
# Activate specific version
synk app-bundle activate 1.2.0
# Previous version remains available for older clients
Safe Rollback
If issues occur:
# Immediately activate previous version
synk app-bundle activate 1.1.0
# Clients will pull updated bundle on next sync
Best Practices
Design
✅ Do:
- Keep extensions focused and single-purpose
- Follow component composition patterns
- Reuse core Formulus components where possible
- Implement accessibility (ARIA labels, keyboard navigation)
- Support theme customization
- Validate input on every change
❌ Don't:
- Create monolithic extensions doing too much
- Rely on external APIs without fallbacks
- Hard-code strings (use i18n)
- Break from standard form patterns
- Ignore edge cases
- Store sensitive data locally
Performance
- Minimize bundle size (extensions increase app size)
- Lazy load if possible
- Cache expensive computations
- Avoid blocking operations
- Test on slow networks and low-end devices
Compatibility
- Test across devices (Android 8+, iOS 13+)
- Support multiple screen sizes
- Handle orientation changes
- Verify offline functionality
- Test with real field data
Example Implementation
Complete Custom Time Range Picker
// time-range-picker.tsx
import React, { useState } from 'react';
interface TimeRange {
start: string; // HH:MM
end: string; // HH:MM
}
interface TimeRangePickerProps {
value: TimeRange;
onChange: (value: TimeRange) => void;
label?: string;
}
export const TimeRangePicker: React.FC<TimeRangePickerProps> = ({
value,
onChange,
label
}) => {
const [startTime, setStartTime] = useState(value?.start || '');
const [endTime, setEndTime] = useState(value?.end || '');
const handleChange = (newStart: string, newEnd: string) => {
setStartTime(newStart);
setEndTime(newEnd);
onChange({ start: newStart, end: newEnd });
};
const isValidRange = !startTime || !endTime || startTime <= endTime;
return (
<div className="time-range-picker">
{label && <label>{label}</label>}
<div className="time-inputs">
<div className="time-field">
<label htmlFor="start">Start Time</label>
<input
id="start"
type="time"
value={startTime}
onChange={(e) => handleChange(e.target.value, endTime)}
/>
</div>
<div className="separator">to</div>
<div className="time-field">
<label htmlFor="end">End Time</label>
<input
id="end"
type="time"
value={endTime}
onChange={(e) => handleChange(startTime, e.target.value)}
/>
</div>
</div>
{!isValidRange && (
<div className="error">End time must be after start time</div>
)}
</div>
);
};
Register it:
registerExtension({
name: 'time-range',
renderer: TimeRangePicker,
metadata: {
displayName: 'Time Range',
description: 'Select start and end times',
version: '1.0.0'
}
});
Use in form:
{
"working_hours": {
"type": "object",
"x-custom": {
"type": "time-range"
},
"properties": {
"start": { "type": "string" },
"end": { "type": "string" }
}
}
}
Development Workflow
- Setup - Create React project for extension
- Develop - Build and test component locally
- Test - Verify in Formulus with test forms
- Package - Bundle into app-bundle.zip
- Deploy - Upload via CLI or API
- Verify - Check deployment and monitor usage
- Update - Continue improving based on feedback
Testing Extensions
Unit Tests
import { render, screen } from '@testing-library/react';
import { TimeRangePicker } from './time-range-picker';
describe('TimeRangePicker', () => {
it('should accept time range', () => {
const onChange = jest.fn();
render(
<TimeRangePicker
value={{ start: '09:00', end: '17:00' }}
onChange={onChange}
label="Working Hours"
/>
);
const inputs = screen.getAllByRole('textbox');
expect(inputs[0]).toHaveValue('09:00');
expect(inputs[1]).toHaveValue('17:00');
});
it('should validate time range', () => {
const { getByText } = render(
<TimeRangePicker
value={{ start: '17:00', end: '09:00' }}
onChange={() => {}}
/>
);
expect(getByText(/End time must be after start time/)).toBeInTheDocument();
});
});
Integration Tests
Test with actual forms and the Formulus environment.
Related Content
- Form Design - Learn about form structure and types
- Formplayer Reference - Built-in question types
- App Bundle Format - Full bundle specification
- Deployment - Deploy to production