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 - - - - - - - -
- - - 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)