diff --git a/formulus-formplayer/index.html b/formulus-formplayer/index.html
index 7d2bafd72..00b3b1095 100644
--- a/formulus-formplayer/index.html
+++ b/formulus-formplayer/index.html
@@ -10,6 +10,7 @@
Formulus Form Player
+
diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx
index ee2459381..8ab18b5c0 100644
--- a/formulus-formplayer/src/App.tsx
+++ b/formulus-formplayer/src/App.tsx
@@ -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) {
@@ -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(
@@ -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 () => {
@@ -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`,
);
}
diff --git a/formulus-formplayer/src/index.tsx b/formulus-formplayer/src/index.tsx
index f64075fec..fa36a8731 100644
--- a/formulus-formplayer/src/index.tsx
+++ b/formulus-formplayer/src/index.tsx
@@ -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(
diff --git a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx
index 8d5e94133..a1ce6002c 100644
--- a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx
+++ b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx
@@ -49,15 +49,50 @@ class CustomQuestionErrorBoundary extends Component<
return (
-
Custom question type "{this.props.formatName}" failed
-
-
{this.state.error?.message}
+
+ ⚠️ Custom Question Type Error
+
+
+ The custom question type "{this.props.formatName}"{' '}
+ encountered an error and could not be rendered.
+
+
+
+ Error Details (click to expand)
+
+
+ {this.state.error?.message || 'Unknown error'}
+ {this.state.error?.stack && (
+ <>
+ {'\n\n'}
+ {this.state.error.stack}
+ >
+ )}
+
+
+
+ The form will continue to function, but this field cannot be edited.
+
);
}
@@ -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;
+
const RESERVED_PROPERTIES = new Set([
'type',
'title',
@@ -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
+ | 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,
diff --git a/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts b/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts
index a258a9e4b..2c32e90ad 100644
--- a/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts
+++ b/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts
@@ -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';
@@ -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(
@@ -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 });
@@ -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(', '),
);
}
diff --git a/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts b/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts
index a0eeda994..23350abc8 100644
--- a/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts
+++ b/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts
@@ -29,7 +29,8 @@ function createFormatTester(formatName: string): RankedTester {
return rankWith(
6,
schemaMatches(schema => {
- return (schema as Record)?.format === formatName;
+ const schemaObj = schema as Record;
+ return schemaObj?.format === formatName;
}),
);
}
diff --git a/formulus-formplayer/src/services/ExtensionsLoader.ts b/formulus-formplayer/src/services/ExtensionsLoader.ts
index 4f73e0e0e..ef3291969 100644
--- a/formulus-formplayer/src/services/ExtensionsLoader.ts
+++ b/formulus-formplayer/src/services/ExtensionsLoader.ts
@@ -44,6 +44,7 @@ export interface LoadedRenderer {
*/
export interface ExtensionLoadResult {
renderers: JsonFormsRendererRegistryEntry[];
+ // Functions with explicit signature for type safety
functions: Map any>;
definitions: Record;
errors: Array<{ type: string; message: string; details?: any }>;
diff --git a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts
index 34c7a9a3d..68a08f10b 100644
--- a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts
+++ b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts
@@ -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 }) { ... }
*/
/**
@@ -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;
@@ -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 */
@@ -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;
}
>;
diff --git a/formulus-formplayer/tsconfig.json b/formulus-formplayer/tsconfig.json
index f3859f36f..3ae6deed4 100644
--- a/formulus-formplayer/tsconfig.json
+++ b/formulus-formplayer/tsconfig.json
@@ -1,22 +1,23 @@
-{
- "compilerOptions": {
- "target": "ES2020",
- "useDefineForClassFields": true,
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
- "allowJs": true,
- "checkJs": false,
- "skipLibCheck": true,
- "esModuleInterop": true,
- "allowSyntheticDefaultImports": true,
- "strict": true,
- "forceConsistentCasingInFileNames": true,
- "noFallthroughCasesInSwitch": true,
- "module": "esnext",
- "moduleResolution": "node",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true,
- "jsx": "react-jsx"
- },
- "include": ["src"]
-}
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "allowJs": true,
+ "checkJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src"],
+ "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"]
+}
diff --git a/formulus-formplayer/vite.config.ts b/formulus-formplayer/vite.config.ts
index 785e5e251..5e9010355 100644
--- a/formulus-formplayer/vite.config.ts
+++ b/formulus-formplayer/vite.config.ts
@@ -38,7 +38,8 @@ export default defineConfig({
onwarn(warning, warn) {
if (
warning.code === 'EVAL' ||
- (warning.message && warning.message.includes('ExtensionsLoader'))
+ (warning.message && warning.message.includes('ExtensionsLoader')) ||
+ (warning.code === 'UNUSED_EXTERNAL_IMPORT' && warning.source?.includes('formulus-load.js'))
) {
return;
}
diff --git a/formulus/android/app/src/main/assets/formplayer_dist/index.html b/formulus/android/app/src/main/assets/formplayer_dist/index.html
deleted file mode 100644
index 309ee212a..000000000
--- a/formulus/android/app/src/main/assets/formplayer_dist/index.html
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Formulus Form Player
-
-
-
-
-
-
- You need to enable JavaScript to run this app.
-
-
-
-
diff --git a/formulus/metro.config.js b/formulus/metro.config.js
index 1e6aae786..785e7804d 100644
--- a/formulus/metro.config.js
+++ b/formulus/metro.config.js
@@ -29,6 +29,18 @@ const extraModules = {
projectRoot,
'node_modules/react-native-svg',
),
+ '@ode/components/react-native': path.resolve(
+ monorepoRoot,
+ 'packages/components/src/react-native/index.ts',
+ ),
+ '@ode/components/react-web': path.resolve(
+ monorepoRoot,
+ 'packages/components/src/react-web/index.ts',
+ ),
+ '@ode/tokens/dist/react-native/tokens-resolved': path.resolve(
+ monorepoRoot,
+ 'packages/tokens/dist/react-native/tokens-resolved.js',
+ ),
};
/**
@@ -44,12 +56,31 @@ const config = {
unstable_enablePackageExports: true,
extraNodeModules: extraModules,
resolveRequest(context, moduleName, platform) {
+ // Handle forced modules (react, react-native)
if (forcedModules[moduleName]) {
return {
type: 'sourceFile',
filePath: path.join(forcedModules[moduleName], 'index.js'),
};
}
+ // Handle react-native-svg - resolve to its actual entry point
+ if (moduleName === 'react-native-svg') {
+ const svgPath = path.resolve(
+ projectRoot,
+ 'node_modules/react-native-svg/lib/commonjs/index.js',
+ );
+ return {
+ type: 'sourceFile',
+ filePath: svgPath,
+ };
+ }
+ // Handle @ode/components subpath exports
+ if (extraModules[moduleName]) {
+ return {
+ type: 'sourceFile',
+ filePath: extraModules[moduleName],
+ };
+ }
return context.resolveRequest(context, moduleName, platform);
},
},
diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx
index fee8965b9..559ae1052 100644
--- a/formulus/src/components/FormplayerModal.tsx
+++ b/formulus/src/components/FormplayerModal.tsx
@@ -316,32 +316,24 @@ const FormplayerModal = forwardRef(
}
// Scan custom question types and read their source code
- // Check both root forms/ and app/forms/ paths (same dual-path as FormService)
+ // Check app/question_types (bundle root) and app/forms/question_types (legacy)
let customQuestionTypes = undefined;
try {
const qtDirs = [
- RNFS.DocumentDirectoryPath + '/forms/question_types',
+ `${customAppPath}/question_types`,
`${customAppPath}/forms/question_types`,
+ RNFS.DocumentDirectoryPath + '/forms/question_types',
];
- console.log(
- `🔍🔍🔍 [FormplayerModal] Scanning custom question types in: ${qtDirs.join(', ')}`,
- );
const custom_types: Record = {};
for (const qtDir of qtDirs) {
const qtDirExists = await RNFS.exists(qtDir);
if (!qtDirExists) {
- console.log(
- `🔍 [FormplayerModal] Path not found, skipping: ${qtDir}`,
- );
continue;
}
const folders = await RNFS.readDir(qtDir);
- console.log(
- `🔍 [FormplayerModal] Found ${folders.length} items in ${qtDir}: ${folders.map(f => f.name).join(', ')}`,
- );
for (const folder of folders) {
if (folder.isDirectory() && !custom_types[folder.name]) {
@@ -365,7 +357,7 @@ const FormplayerModal = forwardRef(
);
} else {
console.warn(
- `⚠️ [FormplayerModal] Skipping "${folder.name}": no renderer.js or index.js found`,
+ `[FormplayerModal] Skipping "${folder.name}": no renderer.js or index.js found`,
);
}
}
@@ -374,12 +366,9 @@ const FormplayerModal = forwardRef(
if (Object.keys(custom_types).length > 0) {
customQuestionTypes = { custom_types };
- console.log(
- `📦📦📦 [FormplayerModal] Custom question types manifest: ${JSON.stringify(Object.keys(custom_types))}`,
- );
} else {
console.warn(
- '⚠️ [FormplayerModal] No custom question types found in any path',
+ '[FormplayerModal] No custom question types found in any path',
);
}
} catch (error) {
diff --git a/packages/package-lock.json b/packages/package-lock.json
new file mode 100644
index 000000000..f20691ff2
--- /dev/null
+++ b/packages/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "packages",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}
diff --git a/packages/tokens/package.json b/packages/tokens/package.json
index eff10608f..2f41597b6 100644
--- a/packages/tokens/package.json
+++ b/packages/tokens/package.json
@@ -4,6 +4,17 @@
"description": "ODE Design System - Unified design tokens for React Native, React Web, and all ODE applications",
"main": "dist/js/tokens.js",
"types": "dist/js/tokens.d.ts",
+ "exports": {
+ ".": {
+ "react-native": "./dist/react-native/tokens-resolved.js",
+ "default": "./dist/js/tokens.js"
+ },
+ "./dist/react-native/tokens-resolved": "./dist/react-native/tokens-resolved.js",
+ "./dist/react-native/tokens-resolved.js": "./dist/react-native/tokens-resolved.js",
+ "./dist/js/tokens": "./dist/js/tokens.js",
+ "./dist/css/tokens.css": "./dist/css/tokens.css",
+ "./dist/json/tokens.json": "./dist/json/tokens.json"
+ },
"files": [
"dist",
"README.md"
diff --git a/synkronus-portal/src/pages/Dashboard.tsx b/synkronus-portal/src/pages/Dashboard.tsx
index 0ad736d9f..020a54f69 100644
--- a/synkronus-portal/src/pages/Dashboard.tsx
+++ b/synkronus-portal/src/pages/Dashboard.tsx
@@ -43,7 +43,7 @@ import {
HiChevronDown,
HiCircleStack,
} from 'react-icons/hi2';
-import { ColorBrandPrimary500 } from '@ode/tokens/dist/js/tokens';
+import { ColorBrandPrimary500 } from '@ode/tokens';
import odeLogo from '../assets/ode_logo.png';
import dashboardBackgroundDark from '../assets/dashboard-background.png';
import dashboardBackgroundLight from '../assets/dashboard-background-light.png';
diff --git a/synkronus/cmd/genhash/main.go b/synkronus/cmd/genhash/main.go
index 5a32cc03b..fd80dca36 100644
--- a/synkronus/cmd/genhash/main.go
+++ b/synkronus/cmd/genhash/main.go
@@ -3,12 +3,17 @@ package main
import (
"fmt"
"log"
+ "os"
"golang.org/x/crypto/bcrypt"
)
func main() {
+ // If password provided as argument, use it; otherwise use defaults
passwords := []string{"admin", "password123"}
+ if len(os.Args) > 1 {
+ passwords = os.Args[1:]
+ }
for _, password := range passwords {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)