From 4e0cff1d613d3a300c31046fde3c550375806bd3 Mon Sep 17 00:00:00 2001 From: gqcorneby <185756521+gqcorneby@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:12:42 +0800 Subject: [PATCH 01/13] allow and run script tags --- .../custom-form/custom-form.jsx | 27 ++++++++++++++----- .../custom-form/parse-html-to-react.jsx | 5 ++-- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/data-workspace/custom-form/custom-form.jsx b/src/data-workspace/custom-form/custom-form.jsx index afe96b0306..287890cf49 100644 --- a/src/data-workspace/custom-form/custom-form.jsx +++ b/src/data-workspace/custom-form/custom-form.jsx @@ -1,15 +1,10 @@ import PropTypes from 'prop-types' -import React from 'react' +import React, { useEffect, 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' -/** - * 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, @@ -17,8 +12,26 @@ export const CustomForm = ({ dataSet }) => { }) const { data: metadata } = useMetadata() + const containerRef = useRef(null) + + useEffect(() => { + if (containerRef.current) { + const scripts = containerRef.current.querySelectorAll('script') + + scripts.forEach((oldScript) => { + const newScript = document.createElement('script') + + for (const attr of oldScript.attributes) { + newScript.setAttribute(attr.name, attr.value) + } + newScript.text = oldScript.innerHTML + oldScript.parentNode.replaceChild(newScript, oldScript) + }) + } + }, [customForm]) + return customForm ? ( -
+
{parseHtmlToReact(customForm.htmlCode, metadata)}
) : null diff --git a/src/data-workspace/custom-form/parse-html-to-react.jsx b/src/data-workspace/custom-form/parse-html-to-react.jsx index f8fcff4fc4..b6141fdc66 100644 --- a/src/data-workspace/custom-form/parse-html-to-react.jsx +++ b/src/data-workspace/custom-form/parse-html-to-react.jsx @@ -12,8 +12,9 @@ export const parseHtmlToReact = (htmlCode, metadata) => case 'td': return replaceTdNode(domNode, metadata) case 'script': - // remove script tags - return <> + // Always allow scripts to pass through, but don't let html-react-parser + // handle them inconsistently - we'll execute them manually in CustomForm + return undefined default: return undefined } From e53883be7fa59f6aca22dd62f3e42d42d5945f9e Mon Sep 17 00:00:00 2001 From: gqcorneby <185756521+gqcorneby@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:27:27 +0800 Subject: [PATCH 02/13] add field id in generic input --- src/data-workspace/inputs/generic-input.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/data-workspace/inputs/generic-input.jsx b/src/data-workspace/inputs/generic-input.jsx index 29935d4588..e7e2aa79d8 100644 --- a/src/data-workspace/inputs/generic-input.jsx +++ b/src/data-workspace/inputs/generic-input.jsx @@ -114,6 +114,7 @@ export const GenericInput = ({ return ( Date: Tue, 14 Oct 2025 20:04:42 +0800 Subject: [PATCH 03/13] create and use legacy dhis2 bridge --- src/app/app.jsx | 15 ++-- .../LegacyDhis2BridgeProvider.jsx | 71 +++++++++++++++++++ .../legacy-dhis2-bridge/legacyEvents.js | 18 +++++ .../legacy-dhis2-bridge/useCustomEvent.js | 20 ++++++ src/shared/legacy-dhis2-bridge/useEmit.js | 28 ++++++++ src/shared/legacy-dhis2-bridge/utils.js | 44 ++++++++++++ 6 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 src/shared/legacy-dhis2-bridge/LegacyDhis2BridgeProvider.jsx create mode 100644 src/shared/legacy-dhis2-bridge/legacyEvents.js create mode 100644 src/shared/legacy-dhis2-bridge/useCustomEvent.js create mode 100644 src/shared/legacy-dhis2-bridge/useEmit.js create mode 100644 src/shared/legacy-dhis2-bridge/utils.js diff --git a/src/app/app.jsx b/src/app/app.jsx index d77c52b033..823f4c32da 100644 --- a/src/app/app.jsx +++ b/src/app/app.jsx @@ -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/LegacyDhis2BridgeProvider' const idSidebarMap = { [contextualHelpSidebarId]: ContextualHelpSidebar, @@ -44,12 +45,14 @@ const App = () => { return ( - } - showSidebar={!!id} - /> + + } + showSidebar={!!id} + /> + ) } diff --git a/src/shared/legacy-dhis2-bridge/LegacyDhis2BridgeProvider.jsx b/src/shared/legacy-dhis2-bridge/LegacyDhis2BridgeProvider.jsx new file mode 100644 index 0000000000..84e0d2b915 --- /dev/null +++ b/src/shared/legacy-dhis2-bridge/LegacyDhis2BridgeProvider.jsx @@ -0,0 +1,71 @@ +import React, { createContext, useContext, useEffect, useMemo } from 'react' +import { updateDhis2Bridge, initializeDhis2Bridge } from './utils.js' +import { useEmitOnChange } from './useEmit.js' +import { DE_EVENTS, FIELD_EVENTS } from './legacyEvents.js' +import { + selectors, + useMetadata, + usePeriod, + useDataSetId, + useOrgUnitId, + usePeriodId, +} from '../index.js' + +const LegacyDhis2BridgeContext = createContext(undefined) + +export function LegacyDhis2BridgeProvider({ children }) { + const value = useLegacyDhis2Bridge() + + const [periodId] = usePeriodId() + const [dataSetId] = useDataSetId() + const [orgUnitId] = useOrgUnitId() + + const selectedPeriod = usePeriod(periodId) + + const { data: metadata } = useMetadata() + + useEmitOnChange(selectedPeriod, { eventName: FIELD_EVENTS.period }) + useEmitOnChange(dataSetId, { eventName: FIELD_EVENTS.dataSet }) + useEmitOnChange(orgUnitId, { eventName: FIELD_EVENTS.orgUnit }) + + useEffect(() => { + initializeDhis2Bridge() + }, []) + + useEffect(() => { + if (!window.isLegacyDhis2Bridge) return + updateDhis2Bridge(orgUnitId, dataSetId, selectedPeriod) + }, [orgUnitId, dataSetId, selectedPeriod]) + + return ( + + {children} + + ) +} + +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 = useMemo(() => { + return (type, detail) => { + if (typeof window === 'undefined') { + return false + } + const event = new CustomEvent(type, { detail }) + return window.dispatchEvent(event) + } + }, []) + + return { + dhis2: typeof window !== 'undefined' ? window.dhis2 : undefined, + emit, + } +} diff --git a/src/shared/legacy-dhis2-bridge/legacyEvents.js b/src/shared/legacy-dhis2-bridge/legacyEvents.js new file mode 100644 index 0000000000..443d0f2480 --- /dev/null +++ b/src/shared/legacy-dhis2-bridge/legacyEvents.js @@ -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', +} + +//can replace form triggers +export const FIELD_EVENTS = { + period: 'dhis2.de.event.periodChanged', + orgUnit: 'dhis2.de.event.orgUnitChanged', + dataSet: 'dhis2.de.event.dataSetChanged', +} diff --git a/src/shared/legacy-dhis2-bridge/useCustomEvent.js b/src/shared/legacy-dhis2-bridge/useCustomEvent.js new file mode 100644 index 0000000000..5d49da59b8 --- /dev/null +++ b/src/shared/legacy-dhis2-bridge/useCustomEvent.js @@ -0,0 +1,20 @@ +import { useCallback } from 'react' + +export function useCustomEvent(opts) { + const { target = typeof window !== 'undefined' ? window : null } = + opts || {} + + return useCallback( + (type, detail, options) => { + if (!target) return false + const evt = new CustomEvent(type, { + detail, + bubbles: options?.bubbles ?? false, + cancelable: options?.cancelable ?? false, + composed: options?.composed ?? false, + }) + return target.dispatchEvent(evt) + }, + [target] + ) +} diff --git a/src/shared/legacy-dhis2-bridge/useEmit.js b/src/shared/legacy-dhis2-bridge/useEmit.js new file mode 100644 index 0000000000..df57f9f18e --- /dev/null +++ b/src/shared/legacy-dhis2-bridge/useEmit.js @@ -0,0 +1,28 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useCustomEvent } from "./useCustomEvent"; + +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 first = useRef(true); + + useEffect(() => { + if (!fireOnMount && first.current) { + first.current = false; + return; + } + first.current = false; + + const detail = typeof mapDetail === "function" ? mapDetail(value) : value; + emit(eventName, detail); + }, [value, emit, eventName, mapDetail, fireOnMount]); +} diff --git a/src/shared/legacy-dhis2-bridge/utils.js b/src/shared/legacy-dhis2-bridge/utils.js new file mode 100644 index 0000000000..5b45e66965 --- /dev/null +++ b/src/shared/legacy-dhis2-bridge/utils.js @@ -0,0 +1,44 @@ +import { DE_EVENTS } from './legacyEvents' + +export function initializeDhis2Bridge() { + if (typeof window === 'undefined') return + + // Only skip if our 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') +} + +export 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 + } +} + +if (typeof window !== 'undefined') { + initializeDhis2Bridge() +} From 6a669df025e160d110004bea1b73b9ddb3419f6d Mon Sep 17 00:00:00 2001 From: gqcorneby <185756521+gqcorneby@users.noreply.github.com> Date: Tue, 21 Oct 2025 18:27:15 +0800 Subject: [PATCH 04/13] emit formLoaded --- src/data-workspace/custom-form/custom-form.jsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/data-workspace/custom-form/custom-form.jsx b/src/data-workspace/custom-form/custom-form.jsx index 287890cf49..cc54a94b8b 100644 --- a/src/data-workspace/custom-form/custom-form.jsx +++ b/src/data-workspace/custom-form/custom-form.jsx @@ -4,6 +4,8 @@ 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 { useLegacyDhis2BridgeContext } from '../../shared/legacy-dhis2-bridge/LegacyDhis2BridgeProvider' +import { DE_EVENTS } from '../../shared/legacy-dhis2-bridge/legacyEvents' export const CustomForm = ({ dataSet }) => { const { data: customForm } = useCustomForm({ @@ -11,6 +13,7 @@ export const CustomForm = ({ dataSet }) => { version: dataSet.version, }) const { data: metadata } = useMetadata() + const { emit } = useLegacyDhis2BridgeContext() const containerRef = useRef(null) @@ -27,8 +30,15 @@ export const CustomForm = ({ dataSet }) => { newScript.text = oldScript.innerHTML oldScript.parentNode.replaceChild(newScript, oldScript) }) + + // Emit formLoaded after scripts have been injected and executed + // Use a short delay to allow async script initialization (dependency loading) + // assuming only custom forms use the legacy bridge now + setTimeout(() => { + emit(DE_EVENTS.formLoaded, dataSet.id) + }, 0) } - }, [customForm]) + }, [customForm, dataSet.id, emit]) return customForm ? (
From 73e1eced1f69ed979b0422d8810e52236e307d0b Mon Sep 17 00:00:00 2001 From: gqcorneby <185756521+gqcorneby@users.noreply.github.com> Date: Tue, 21 Oct 2025 18:29:04 +0800 Subject: [PATCH 05/13] update comment --- src/data-workspace/custom-form/parse-html-to-react.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/data-workspace/custom-form/parse-html-to-react.jsx b/src/data-workspace/custom-form/parse-html-to-react.jsx index b6141fdc66..4fa48306d4 100644 --- a/src/data-workspace/custom-form/parse-html-to-react.jsx +++ b/src/data-workspace/custom-form/parse-html-to-react.jsx @@ -12,8 +12,7 @@ export const parseHtmlToReact = (htmlCode, metadata) => case 'td': return replaceTdNode(domNode, metadata) case 'script': - // Always allow scripts to pass through, but don't let html-react-parser - // handle them inconsistently - we'll execute them manually in CustomForm + // Always allow scripts to pass through, but execute them manually in CustomForm return undefined default: return undefined From 328f4edbbcbda0a1aad9e149a527e82cde271cf6 Mon Sep 17 00:00:00 2001 From: gqcorneby <185756521+gqcorneby@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:09:57 +0800 Subject: [PATCH 06/13] add id to textarea --- src/data-workspace/inputs/long-text.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/data-workspace/inputs/long-text.jsx b/src/data-workspace/inputs/long-text.jsx index 8d6be58f16..7065d6927f 100644 --- a/src/data-workspace/inputs/long-text.jsx +++ b/src/data-workspace/inputs/long-text.jsx @@ -47,6 +47,7 @@ export const LongText = ({ return (