Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions EXTENSION_IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,76 @@ Corresponding UI schema:
}
```

## Ranking Renderer (AnthroCollect)

Custom renderers that need the Formulus API and form parameters (e.g., person scope, age filters) can use the `FormContext` provided by the formplayer.

### ext.json for ranking renderer

```json
{
"renderers": {
"ranking": {
"name": "RankingRenderer",
"format": "ranking",
"module": "extensions/renderers/RankingRenderer.jsx",
"tester": "rankingTester",
"renderer": "RankingRenderer",
"testerModule": "extensions/testers/rankingTester.js"
}
}
}
```

- **module**: Path to renderer (relative to app `forms/` directory)
- **testerModule**: Optional. Path to module containing the tester when it lives in a separate file. The tester matches when `uischema.options.renderer === 'ranking'`.

### FormContext API for custom renderers

Custom renderers receive JsonForms props (data, handleChange, path, uischema, schema, label, visible). For Formulus-specific data, use the FormContext exposed on `window.__formplayerFormContext`:

```tsx
import React from 'react';

function RankingRenderer(props) {
const FormContext = window.__formplayerFormContext;
const { formulusApi, formParams } = FormContext
? React.useContext(FormContext)
: { formulusApi: null, formParams: {} };
// formulusApi: FormulusInterface for anthroData.getPersonsByScopeAndFilter(formulusApi, ...)
// formParams: { p_id, scope, age_min, age_max, ... } from openFormplayer(formType, params, savedData)
}
```

- **formulusApi**: The Formulus API instance (e.g., for `getObservationsByQuery`, etc.). Available after the formplayer loads.
- **formParams**: Parameters passed when opening the form via `openFormplayer(formType, params, savedData)`. Typically includes `p_id`, `scope`, `age_min`, `age_max` for person filtering.

### UI schema for ranking control

```json
{
"type": "Control",
"scope": "#/properties/demo_ranking",
"options": {
"renderer": "ranking",
"sexFilter": "female"
}
}
```

### Form params when opening ranking forms

When opening `p_ranking_female` or `p_ranking_male`, pass params so the ranking renderer can filter persons:

```javascript
openFormplayer('p_ranking_female', {
p_id: 'person-uuid-or-focal',
scope: 'household',
age_min: 18,
age_max: 65
}, savedData);
```

## Assumptions

1. Extension modules use ES6 module syntax (`import`/`export`)
Expand Down
1 change: 1 addition & 0 deletions formulus-formplayer/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default defineConfig([
'**/coverage/**',
'**/__tests__/**',
'**/scripts/**',
'**/prettier.config.cjs',
]),
js.configs.recommended,
...tseslint.configs.recommended,
Expand Down
43 changes: 0 additions & 43 deletions formulus-formplayer/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
/**
* @see https://prettier.io/docs/configuration
* @type {import("prettier").Config}
* Uses .cjs so Prettier receives plain config (avoids ESM default-export wrapper warnings)
*/

const config = {
module.exports = {
arrowParens: 'avoid',
bracketSameLine: true,
bracketSpacing: true,
singleQuote: true,
trailingComma: 'all',
};

export default config;
89 changes: 73 additions & 16 deletions formulus-formplayer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ import addFormats from 'ajv-formats';

// Import the FormulusInterface client
import FormulusClient from './services/FormulusInterface';
import { FormInitData } from './types/FormulusInterfaceDefinition';
import {
FormInitData,
FormulusInterface,
} from './types/FormulusInterfaceDefinition';

import SwipeLayoutRenderer, {
swipeLayoutTester,
Expand Down Expand Up @@ -69,7 +72,10 @@ import { draftService } from './services/DraftService';
import DraftSelector from './components/DraftSelector';
import { loadExtensions } from './services/ExtensionsLoader';
import { getBuiltinExtensions } from './builtinExtensions';
import { FormEvaluationProvider } from './FormEvaluationContext';
import {
FormEvaluationProvider,
type ExtensionFunction,
} from './FormEvaluationContext';

// Import development dependencies (Vite will tree-shake these in production)
import { webViewMock } from './mocks/webview-mock';
Expand Down Expand Up @@ -194,17 +200,30 @@ const processUISchemaWithFinalize = (
// Interface for the data structure passed to window.onFormInit
// Removed local definition, importing from FormulusInterfaceDefinition.ts

// Create context for sharing form metadata with renderers
interface FormContextType {
// Create context for sharing form metadata and API with renderers
export interface FormContextType {
formInitData: FormInitData | null;
/** Formulus API for custom renderers (e.g. RankingRenderer) - available after formplayer loads */
formulusApi: FormulusInterface | null;
/** Form params passed when opening the form (p_id, scope, age_min, age_max, etc.) */
formParams: Record<string, unknown>;
}

export const FormContext = createContext<FormContextType>({
formInitData: null,
formulusApi: null,
formParams: {},
});

export const useFormContext = () => useContext(FormContext);

// Expose FormContext globally so extension renderers (loaded from app bundle) can access it
if (typeof window !== 'undefined') {
(
window as Window & { __formplayerFormContext?: typeof FormContext }
).__formplayerFormContext = FormContext;
}

export const customRenderers = [
{ tester: swipeLayoutTester, renderer: SwipeLayoutRenderer },
{ tester: groupAsSwipeLayoutTester, renderer: SwipeLayoutRenderer },
Expand Down Expand Up @@ -263,14 +282,15 @@ function App() {
JsonFormsRendererRegistryEntry[]
>([]);
// Store extension functions for potential future use (e.g., validation context injection)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [extensionFunctions, setExtensionFunctions] = useState<
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
Map<string, Function>
Map<string, ExtensionFunction>
>(new Map());
const [extensionDefinitions, setExtensionDefinitions] = useState<
Record<string, any>
>({});
const [formulusApi, setFormulusApi] = useState<FormulusInterface | null>(
null,
);

// Reference to the FormulusClient instance and loading state
const formulusClient = useRef<FormulusClient>(FormulusClient.getInstance());
Expand All @@ -295,7 +315,10 @@ function App() {
try {
const properties = (formSchema as any)?.properties || {};
const dynamicEnumFields = Object.entries(properties)
.filter(([, propSchema]: [string, any]) => !!propSchema?.['x-dynamicEnum'])
.filter(
([, propSchema]: [string, any]) =>
!!propSchema?.['x-dynamicEnum'],
)
.map(([key]) => key);

console.log('[Formplayer] Form init received', {
Expand All @@ -306,7 +329,10 @@ function App() {
dynamicEnumFields,
});
} catch (schemaLogError) {
console.warn('[Formplayer] Failed to log schema details', schemaLogError);
console.warn(
'[Formplayer] Failed to log schema details',
schemaLogError,
);
}

// Extract dark mode preference from params
Expand All @@ -320,17 +346,20 @@ function App() {
if (extensions) {
try {
const extensionResult = await loadExtensions(extensions);

// Merge loaded functions with built-ins (loaded functions take precedence)
extensionResult.functions.forEach((func, name) => {
allFunctions.set(name, func);
allFunctions.set(name, func as ExtensionFunction);
});

setExtensionRenderers(extensionResult.renderers);
setExtensionFunctions(allFunctions);
setExtensionDefinitions(extensionResult.definitions);

console.log('[Formplayer] Final extension functions:', Array.from(allFunctions.keys()));
console.log(
'[Formplayer] Final extension functions:',
Array.from(allFunctions.keys()),
);

// Log errors but don't fail form initialization
if (extensionResult.errors.length > 0) {
Expand Down Expand Up @@ -504,6 +533,24 @@ function App() {
[initializeForm],
);

// Effect to fetch formulusApi for custom renderers (RankingRenderer, etc.)
useEffect(() => {
const loadFormulusApi = async () => {
const win = window as Window & {
getFormulus?: () => Promise<FormulusInterface>;
};
if (typeof win.getFormulus === 'function') {
try {
const api = await win.getFormulus();
setFormulusApi(api);
} catch (err) {
console.warn('[Formplayer] Failed to load formulusApi:', err);
}
}
};
loadFormulusApi();
}, []);

// Effect for initializing form via window.onFormInit
useEffect(() => {
// Ensure we only register onFormInit and signal readiness once per WebView lifecycle
Expand Down Expand Up @@ -836,10 +883,15 @@ function App() {
p: 3,
backgroundColor: 'background.paper',
}}>
<Typography variant="h6" color="error" sx={{ mb: 2, textAlign: 'center' }}>
<Typography
variant="h6"
color="error"
sx={{ mb: 2, textAlign: 'center' }}>
Error Loading Form
</Typography>
<Typography variant="body2" sx={{ textAlign: 'center', color: 'text.secondary' }}>
<Typography
variant="body2"
sx={{ textAlign: 'center', color: 'text.secondary' }}>
{loadError}
</Typography>
</Box>
Expand Down Expand Up @@ -880,7 +932,12 @@ function App() {

return (
<ThemeProvider theme={currentTheme}>
<FormContext.Provider value={{ formInitData }}>
<FormContext.Provider
value={{
formInitData,
formulusApi,
formParams: formInitData?.params ?? {},
}}>
<div
className="App"
style={{
Expand Down
Loading
Loading