Skip to content
15 changes: 9 additions & 6 deletions src/app/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '../shared/index.js'
import { Layout } from './layout/index.js'
import LoadApp from './load-app.jsx'
import { LegacyDhis2BridgeProvider } from '../shared/legacy-dhis2-bridge/legacy-dhis2-bridge-provider'

const idSidebarMap = {
[contextualHelpSidebarId]: ContextualHelpSidebar,
Expand Down Expand Up @@ -44,12 +45,14 @@ const App = () => {

return (
<LoadApp>
<Layout
header={contextSelection}
main={dataWorkspace}
sidebar={<RightHandPanel idSidebarMap={idSidebarMap} />}
showSidebar={!!id}
/>
<LegacyDhis2BridgeProvider>
<Layout
header={contextSelection}
main={dataWorkspace}
sidebar={<RightHandPanel idSidebarMap={idSidebarMap} />}
showSidebar={!!id}
/>
</LegacyDhis2BridgeProvider>
</LoadApp>
)
}
Expand Down
18 changes: 11 additions & 7 deletions src/data-workspace/custom-form/custom-form.jsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import PropTypes from 'prop-types'
import React from 'react'
import React, { useRef } from 'react'
import useCustomForm from '../../custom-forms/use-custom-form.js'
import { useMetadata } from '../../shared/index.js'
import styles from './custom-form.module.css'
import { parseHtmlToReact } from './parse-html-to-react.jsx'
import { useRunInlineScripts } from "../../shared/legacy-dhis2-bridge/use-run-inline-scripts";

/**
* This implementation of custom forms only supports custom
* HTML and CSS. It does not support custom logic (JavaScript).
* For more info see ./docs/custom-froms.md
*/
export const CustomForm = ({ dataSet }) => {
const { data: customForm } = useCustomForm({
id: dataSet.dataEntryForm.id,
version: dataSet.version,
})
const { data: metadata } = useMetadata()

const containerRef = useRef(null)

useRunInlineScripts({
containerRef,
dataSetId: dataSet.id
}, [customForm?.htmlCode, dataSet.id])


return customForm ? (
<div className={styles.customForm}>
<div className={styles.customForm} ref={containerRef}>
{parseHtmlToReact(customForm.htmlCode, metadata)}
</div>
) : null
Expand Down
4 changes: 2 additions & 2 deletions src/data-workspace/custom-form/parse-html-to-react.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ export const parseHtmlToReact = (htmlCode, metadata) =>
case 'td':
return replaceTdNode(domNode, metadata)
case 'script':
// remove script tags
return <></>
// Always allow scripts to pass through, but execute them manually in CustomForm
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The comment says scripts 'pass through', but returning 'undefined' means the default behavior is used. Consider clarifying the comment to say 'Allow scripts to render normally so they can be manually executed in CustomForm' to better reflect what actually happens.

Suggested change
// Always allow scripts to pass through, but execute them manually in CustomForm
// Allow scripts to render normally so they can be manually executed in CustomForm

Copilot uses AI. Check for mistakes.
return undefined
default:
return undefined
}
Expand Down
1 change: 1 addition & 0 deletions src/data-workspace/inputs/generic-input.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export const GenericInput = ({

return (
<input
id={`${deId}-${cocId}`}
value={value ?? ''}
className={cx(styles.basicInput, {
[styles.alignToEnd]: NUMBER_TYPES.includes(valueType),
Expand Down
1 change: 1 addition & 0 deletions src/data-workspace/inputs/long-text.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const LongText = ({

return (
<textarea
id={`${deId}-${cocId}`}
className={styles.longText}
rows="4"
value={value ?? ''}
Expand Down
99 changes: 99 additions & 0 deletions src/shared/legacy-dhis2-bridge/legacy-dhis2-bridge-provider.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React, { createContext, useContext, useEffect } from 'react'
import { DE_EVENTS, FIELD_EVENTS } from './legacy-events.js'
import {
usePeriod,
useDataSetId,
useOrgUnitId,
usePeriodId,
} from '../index.js'
import {useCustomEvent, useEmitOnChange} from "./use-emit";

const LegacyDhis2BridgeContext = createContext(undefined)

export function LegacyDhis2BridgeProvider({ children }) {
const value = useLegacyDhis2Bridge()

const [periodId] = usePeriodId()
const [dataSetId] = useDataSetId()
const [orgUnitId] = useOrgUnitId()

const selectedPeriod = usePeriod(periodId)

useEmitOnChange(selectedPeriod, { eventName: FIELD_EVENTS.period })
useEmitOnChange(dataSetId, { eventName: FIELD_EVENTS.dataSet })
useEmitOnChange(orgUnitId, { eventName: FIELD_EVENTS.orgUnit })

useEffect(() => {
initializeDhis2Bridge()
}, [])

useEffect(() => {
if (typeof window === 'undefined' || !window.isLegacyDhis2Bridge) return
updateDhis2Bridge(orgUnitId, dataSetId, selectedPeriod)
}, [orgUnitId, dataSetId, selectedPeriod])

return (
<LegacyDhis2BridgeContext.Provider value={value}>
{children}
</LegacyDhis2BridgeContext.Provider>
)
}

export function useLegacyDhis2BridgeContext() {
const ctx = useContext(LegacyDhis2BridgeContext)
if (!ctx)
throw new Error(
'useLegacyDhis2BridgeContext must be used within a LegacyDhis2BridgeProvider'
)
return ctx
}

function useLegacyDhis2Bridge() {
const emit = useCustomEvent({
target: typeof window !== 'undefined' ? window : null,
});

return {
dhis2: typeof window !== 'undefined' ? window.dhis2 : undefined,
emit,
}
}

export function initializeDhis2Bridge() {
if (typeof window === 'undefined') return

// Only skip if bridge already initialized it
if (window.isLegacyDhis2Bridge && window.dhis2) return

window.dhis2 = {
util: {
on(type, handler) {
window.removeEventListener(type, handler)
window.addEventListener(type, handler)
},
off(type, handler) {
window.removeEventListener(type, handler)
},
},
de: {
event: { ...DE_EVENTS },
currentOrganisationUnitId: null,
currentDataSetId: null,
getSelectedPeriod() {
return null
},
},
}
window.isLegacyDhis2Bridge = true
console.info('Legacy DHIS2 bridge initialized')
}

function updateDhis2Bridge(orgUnitId, dataSetId, selectedPeriod) {
if (typeof window === 'undefined' || !window.isLegacyDhis2Bridge) return

if (window.dhis2?.de) {
window.dhis2.de.currentOrganisationUnitId = orgUnitId
window.dhis2.de.currentDataSetId = dataSetId
window.dhis2.de.getSelectedPeriod = () => selectedPeriod
}
}
18 changes: 18 additions & 0 deletions src/shared/legacy-dhis2-bridge/legacy-events.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//copied from window.dhis2.de.event in DHIS2 2.41
export const DE_EVENTS = {
completed: 'dhis2.de.event.completed',
dataValueSaved: 'dhis2.de.event.dataValueSaved',
dataValuesLoaded: 'dhis2.de.event.dataValuesLoaded',
formLoaded: 'dhis2.de.event.formLoaded',
formReady: 'dhis2.de.event.formReady',
uncompleted: 'dhis2.de.event.uncompleted',
validationError: 'dhis2.de.event.validationError',
validationSucces: 'dhis2.de.event.validationSuccess',
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected spelling of 'validationSucces' to 'validationSuccess'.

Suggested change
validationSucces: 'dhis2.de.event.validationSuccess',
validationSuccess: 'dhis2.de.event.validationSuccess',

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was copied from legacy bridge so I didn't want to apply the correction

}

//can replace form triggers
export const FIELD_EVENTS = {
period: 'dhis2.de.event.periodChanged',
orgUnit: 'dhis2.de.event.orgUnitChanged',
dataSet: 'dhis2.de.event.dataSetChanged',
}
44 changes: 44 additions & 0 deletions src/shared/legacy-dhis2-bridge/use-emit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useCallback, useEffect, useRef } from 'react';

export function useCustomEvent(opts) {
const { target = typeof window !== 'undefined' ? window : null } =
opts || {}

return useCallback(
(type, detail) => {
if (!target) return false
const evt = new CustomEvent(type, {
detail
})
return target.dispatchEvent(evt)
},
[target]
)
}

export function useEmitOnSet(setFn, { eventName, target, mapDetail }) {
const emit = useCustomEvent({ target });

return useCallback((value) => {
setFn(value);
const detail = typeof mapDetail === "function" ? mapDetail(value) : value;
emit(eventName, detail);
}, [setFn, emit, eventName, mapDetail]);
}

export function useEmitOnChange(value, { eventName, target, mapDetail, fireOnMount = false }) {
const emit = useCustomEvent({ target });
const isFirstRender = useRef(true);

useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
if (!fireOnMount) {
return;
}
}

const detail = typeof mapDetail === "function" ? mapDetail(value) : value;
emit(eventName, detail);
}, [value, emit, eventName, mapDetail, fireOnMount]);
}
46 changes: 46 additions & 0 deletions src/shared/legacy-dhis2-bridge/use-run-inline-scripts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useEffect } from 'react';
import { DE_EVENTS } from './legacy-events';
import { useCustomEvent } from './use-emit';

export function useRunInlineScripts(
{ containerRef, dataSetId }, deps
) {
const emit = useCustomEvent();

useEffect(() => {
const container = containerRef.current;
if (!container) return;

let cancelled = false;
(async () => {
await runInlineScripts(container);
if (!cancelled) emit(DE_EVENTS.formLoaded, dataSetId);
})();

return () => { cancelled = true; };
}, deps);
}

function runInlineScripts(container) {
const scripts = [...container.querySelectorAll('script:not([src])')];

return scripts.reduce((chain, script) => {
return chain.then(() => new Promise((resolve) => {
const injScript = document.createElement('script');
copyAttrs(script, injScript);

injScript.text = script.text || script.innerHTML || '';

if (script.parentNode) script.parentNode.replaceChild(injScript, script);

Promise.resolve().then(resolve)
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Promise created on line 28 never resolves properly. The resolve callback is called asynchronously via Promise.resolve().then(resolve), but this happens immediately without waiting for the injected script to execute. This defeats the purpose of the promise chain since scripts won't execute sequentially. Consider calling resolve() directly after line 34, or adding proper script load handling if synchronous execution is needed.

Suggested change
Promise.resolve().then(resolve)
resolve();

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Script runs synchronously her injScript.text = ...
Calling Promise.resolve().then(resolve) just ensures the scripts run one after another

}));
}, Promise.resolve());
}

function copyAttrs(src, dst) {
for (const { name, value } of Array.from(src.attributes)) {
if (name === 'type' || name === 'async' || name === 'defer') continue;
dst.setAttribute(name, value);
}
}