diff --git a/i18n/en.pot b/i18n/en.pot index ed20a6f7..5d9e2a69 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-03-27T15:30:46.362Z\n" -"PO-Revision-Date: 2026-03-27T15:30:46.362Z\n" +"POT-Creation-Date: 2026-04-15T02:18:47.807Z\n" +"PO-Revision-Date: 2026-04-15T02:18:47.807Z\n" msgid "Validating Project" msgstr "" @@ -1057,6 +1057,9 @@ msgstr "" msgid "Message" msgstr "" +msgid "Error generating custom form" +msgstr "" + msgid "Set Target Values for Project" msgstr "" diff --git a/jest.config.js b/jest.config.js index 273511fa..25c22017 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { collectCoverageFrom: ["src/**/*.js"], testPathIgnorePatterns: ["/node_modules/", "/cypress"], - transformIgnorePatterns: ["/node_modules/(?!@eyeseetea/d2-ui-components)"], + transformIgnorePatterns: ["node_modules/(?!@eyeseetea/d2-ui-components|@eyeseetea/d2-api|axios)"], modulePaths: ["src"], moduleDirectories: ["node_modules"], moduleNameMapper: { diff --git a/package.json b/package.json index 2111ca69..f6981dbd 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@dhis2/d2-ui-forms": "^6.5.3", "@dhis2/ui": "6.12.0", "@dhis2/ui-core": "^4.8.0", - "@eyeseetea/d2-api": "1.16.0-beta.13", + "@eyeseetea/d2-api": "1.21.0-beta.5", "@eyeseetea/d2-ui-components": "2.7.0", "@eyeseetea/feedback-component": "0.1.2", "@krakenjs/post-robot": "^11.0.0", diff --git a/src/components/app/App.tsx b/src/components/app/App.tsx index f47a052e..4e8c99ae 100644 --- a/src/components/app/App.tsx +++ b/src/components/app/App.tsx @@ -113,6 +113,8 @@ const App: React.FC = props => { return
Cannot load app: {loadError}
; } + const shouldRenderHeaderBar = window.self === window.top; + if (error) { return (

@@ -147,9 +149,11 @@ const App: React.FC = props => { title={disableLogoNav?.title} description={disableLogoNav?.description} /> -
- -
+ {shouldRenderHeaderBar && ( +
+ +
+ )}
diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx index e937d22f..55b2cde4 100644 --- a/src/components/dashboard/Dashboard.tsx +++ b/src/components/dashboard/Dashboard.tsx @@ -35,8 +35,8 @@ const Dashboard: React.FC = props => { const [state, setState] = React.useState({ type: "loading", height: 10000 }); const iframeRef: React.RefObject = React.createRef(); - const dashboardUrlBase = `${baseUrl}/dhis-web-dashboard`; - const dashboardUrl = dashboardUrlBase + `/#/${id}`; + const dashboardUrlBase = `${baseUrl}/apps/dashboard`; + const dashboardUrl = dashboardUrlBase + `#/${id}`; const translations = getTranslations(name); const appHistory = useAppHistory(backUrl); @@ -153,30 +153,11 @@ function waitforElementToLoad(iframeDocument: HTMLDocument, selector: string) { async function setDashboardStyling(iframe: HTMLIFrameElement) { if (!iframe.contentWindow) return; const iframeDocument = iframe.contentWindow.document; - - await waitforElementToLoad(iframeDocument, ".app-wrapper,.dashboard-scroll-container"); - const iFrameRoot = iframeDocument.querySelector("#root"); - const iFrameWrapper = iframeDocument.querySelector(".app-wrapper"); - const pageContainer = iframeDocument.querySelector(".page-container-top-margin"); - - if (iFrameWrapper?.children[0]) - (iFrameWrapper.children[0] as HTMLElement).style.display = "none"; - if (iFrameWrapper?.children[1]) - (iFrameWrapper.children[1] as HTMLElement).style.display = "none"; - - // 2.36 - iframeDocument.querySelectorAll("header").forEach(el => el.remove()); - iframeDocument.querySelectorAll("[data-test='dashboards-bar']").forEach(el => el.remove()); - - // Hide top bar actions - iframeDocument - .querySelectorAll( - ".dashboard-scroll-container > div > div[class*='ViewTitleBar_container']" - ) - .forEach(el => (el.style.display = "none")); - - if (pageContainer) pageContainer.style.marginTop = "0px"; - if (iFrameRoot) iFrameRoot.style.marginTop = "0px"; + await waitforElementToLoad(iframeDocument, "header"); + const iFrameHeader = iframeDocument.querySelector("header"); + if (iFrameHeader) { + iFrameHeader.style.display = "none"; + } } export default React.memo(Dashboard); diff --git a/src/components/data-entry/DataEntry.tsx b/src/components/data-entry/DataEntry.tsx index 3bacb64d..802919a2 100644 --- a/src/components/data-entry/DataEntry.tsx +++ b/src/components/data-entry/DataEntry.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from "react"; import moment from "moment"; -import _ from "lodash"; import Spinner from "../spinner/Spinner"; import Dropdown from "../../components/dropdown/Dropdown"; import Project, { DataSet, monthFormat, getPeriodsData, DataSetType } from "../../models/Project"; @@ -28,132 +27,37 @@ interface DataEntryProps { export type ValidateFn = { execute: () => Promise }; -function autoResizeIframeByContent(iframe: HTMLIFrameElement) { - const resize = () => { - if (iframe.contentWindow) { - const height = iframe.contentWindow.document.body.scrollHeight; - if (height > 0) iframe.height = height.toString(); - } - }; - window.setInterval(resize, 1000); -} +const hideHeaderFooterCss = "header, footer { display: none !important; }"; -function on(document: Document, selector: string, action: (el: T) => void) { - const el = document.querySelector(selector) as T; - if (el) action(el); +function injectHideStyles(doc: Document) { + if (doc.querySelector("style[data-dm-hide]")) return; + const style = doc.createElement("style"); + style.setAttribute("data-dm-hide", "true"); + style.textContent = hideHeaderFooterCss; + doc.head.appendChild(style); } function setEntryStyling(iframe: HTMLIFrameElement) { - if (!iframe.contentWindow) return; - const iframeDocument = iframe.contentWindow.document; - autoResizeIframeByContent(iframe); - - if (showControls) return; - - on(iframeDocument, "#currentSelection", el => el.remove()); - on(iframeDocument, "#header", el => el.remove()); - on(iframeDocument, "html", html => (html.style["overflowY"] = "hidden")); - on(iframeDocument, "#leftBar", el => (el.style.display = "none")); - on(iframeDocument, "#selectionBox", el => (el.style.display = "none")); - on(iframeDocument, "body", el => (el.style.marginTop = "-55px")); - on(iframeDocument, "#mainPage", el => (el.style.margin = "65px 10px 10px 10px")); - on(iframeDocument, "#completenessDiv", el => el.remove()); - on(iframeDocument, "#moduleHeader", el => el.remove()); -} - -export function wait(timeSecs: number) { - console.debug(`[data-entry] Wait ${timeSecs} seconds`); - return new Promise(resolve => setTimeout(resolve, 1000 * timeSecs)); -} - -function waitForOption(el: HTMLSelectElement, predicate: (option: HTMLOptionElement) => boolean) { - return new Promise(resolve => { - const check = () => { - const option = _.find(el.options, predicate); - if (option) { - resolve(undefined); - } else { - setTimeout(check, 10); - } - }; - check(); - }); -} + if (!iframe.contentWindow || showControls) return; -async function setDataset(iframe: HTMLIFrameElement, dataSet: DataSet, onDone: () => void) { - const contentWindow = iframe.contentWindow as (Window & DataEntryWindow) | null; - if (!contentWindow) return; + const applyAll = () => { + const outerDoc = iframe.contentWindow?.document; + if (!outerDoc) return; - const iframeDocument = contentWindow.document; - const dataSetSelector = iframeDocument.querySelector("#selectedDataSetId"); - if (!dataSetSelector) return; + injectHideStyles(outerDoc); - // Avoid database errors - try { - await contentWindow.dhis2.de.storageManager.formExists(dataSet.id); - } catch (err) { - console.log("[data-entry] error", err); - setTimeout(() => setDataset(iframe, dataSet, onDone), 500); - } - - await waitForOption( - dataSetSelector, - // data-multiorg is set when the country org unit is still selected - option => option.value === dataSet.id && !option.getAttribute("data-multiorg") - ); - await wait(1); - selectOption(dataSetSelector, dataSet.id); - - onDone(); -} - -const getDataEntryForm = async ( - iframe: HTMLIFrameElement, - project: Project, - dataSet: DataSet, - orgUnitId: string, - onDone: () => void -) => { - const contentWindow = iframe.contentWindow as (Window & DataEntryWindow) | null; - const iframeDocument = iframe.contentDocument; - const { parentOrgUnit } = project; - const iframeSelection = contentWindow ? contentWindow.selection : null; - if (!contentWindow || !iframeDocument || !iframeSelection || !parentOrgUnit) return; - const parentSelector = `#orgUnit${parentOrgUnit.id} .toggle`; - const ouSelector = `#orgUnit${orgUnitId} a`; - - const selectDataSet = async () => { - console.debug("[data-entry] Select project orgunit", orgUnitId); - const ouLink = iframeDocument.querySelector(ouSelector); - if (!ouLink) { - console.debug("[data-entry] Project orgunit not found, retry"); - selectOrgUnitAndOptions(); - } else { - ouLink.click(); - console.debug("[data-entry] Select options"); - setDataset(iframe, dataSet, onDone); - } - }; - - const selectOrgUnitAndOptions = async () => { - const ouEl = iframeDocument.querySelector(ouSelector); - if (ouEl) { - setTimeout(selectDataSet, 100); - } else { - const parentEl = iframeDocument.querySelector(parentSelector); - if (parentEl) { - console.debug("[data-entry] Click country", parentSelector); - parentEl.click(); - setTimeout(selectOrgUnitAndOptions, 100); - } else { - console.debug("[data-entry] Country orgunit not found, wait"); - setTimeout(selectOrgUnitAndOptions, 100); + outerDoc.querySelectorAll("iframe").forEach(innerIframe => { + if (innerIframe.contentDocument) { + injectHideStyles(innerIframe.contentDocument); } - } + }); }; - selectOrgUnitAndOptions(); -}; + applyAll(); + const intervalId = window.setInterval(applyAll, 500); + + return intervalId; +} const DataEntry = (props: DataEntryProps) => { const { goBack, orgUnitId, dataSet, attributes, dataSetType, onValidateFnChange } = props; @@ -164,7 +68,95 @@ const DataEntry = (props: DataEntryProps) => { const [disableValidation, setDisableValidation] = React.useState(false); const { periodIds, currentPeriodId } = React.useMemo(() => getPeriodsData(dataSet), [dataSet]); const iframeRef = React.useRef(null); - const iFrameSrc = `${baseUrl}/dhis-web-dataentry/index.action`; + const [pluginIframe, setPluginIframe] = React.useState(null); + const categoryId = config.categories.targetActual.id; + + React.useEffect(() => { + const outer = iframeRef.current; + if (!outer) return; + + const observers: MutationObserver[] = []; + const loadListeners: Array<{ el: HTMLIFrameElement; fn: () => void }> = []; + const tracked = new WeakSet(); + let cancelled = false; + let found: HTMLIFrameElement | null = null; + + const isLegacyCustomFormPlugin = (ifr: HTMLIFrameElement) => { + const doc = ifr.contentDocument; + if (!doc) return false; + return Boolean(doc.querySelector(".plugin-legacy-custom-forms-wrapper")); + }; + + const setFound = (ifr: HTMLIFrameElement) => { + if (found === ifr) return; + found = ifr; + console.debug("[data-entry] legacy custom form plugin iframe found:", ifr); + setPluginIframe(ifr); + }; + + const checkPluginCandidate = (ifr: HTMLIFrameElement) => { + if (found || cancelled) return; + if (!ifr.src.includes("plugin.html")) return; + if (isLegacyCustomFormPlugin(ifr)) setFound(ifr); + }; + + const trackIframe = (ifr: HTMLIFrameElement) => { + if (tracked.has(ifr)) return; + tracked.add(ifr); + + const onLoad = () => { + checkPluginCandidate(ifr); + + if (ifr.contentDocument) watch(ifr.contentDocument); + }; + + if (ifr.contentDocument && ifr.contentDocument.location.href !== "about:blank") { + onLoad(); + } + + ifr.addEventListener("load", onLoad); + loadListeners.push({ el: ifr, fn: onLoad }); + }; + + const watch = (doc: Document) => { + if (cancelled) return; + + doc.querySelectorAll("iframe").forEach(checkPluginCandidate); + + const obs = new MutationObserver(() => { + if (cancelled || found) return; + doc.querySelectorAll("iframe").forEach(ifr => { + checkPluginCandidate(ifr); + trackIframe(ifr); + }); + }); + obs.observe(doc, { childList: true, subtree: true }); + observers.push(obs); + + doc.querySelectorAll("iframe").forEach(trackIframe); + }; + + const start = () => { + const doc = outer.contentDocument; + if (doc) watch(doc); + }; + + outer.addEventListener("load", start); + start(); + + return () => { + cancelled = true; + observers.forEach(o => o.disconnect()); + loadListeners.forEach(({ el, fn }) => el.removeEventListener("load", fn)); + outer.removeEventListener("load", start); + setPluginIframe(null); + }; + }, [iframeKey]); + + const categoryOptionId = + props.dataSetType === "actual" + ? config.categoryOptions.actual.id + : config.categoryOptions.target.id; const [state, setState] = useState({ loading: false, @@ -172,6 +164,9 @@ const DataEntry = (props: DataEntryProps) => { dropdownValue: currentPeriodId, }); + const queryParams = `?attributeOptionComboSelection=${categoryId}-${categoryOptionId}&dataSetId=${dataSet.id}&orgUnitId=${orgUnitId}&periodId=${state.dropdownValue}`; + const iFrameSrc = `${baseUrl}/apps/aggregate-data-entry#/${queryParams}`; + function reloadIframe() { setState(state => ({ ...state, loading: true })); setIframeKey(new Date()); @@ -180,25 +175,48 @@ const DataEntry = (props: DataEntryProps) => { useEffect(() => { if (state.dropdownValue) { - setDataSetOpen(setSelectPeriod(iframeRef.current, state.dropdownValue, attributes)); + setDataSetOpen(true); } }, [state, project, iframeKey, attributes]); useEffect(() => { const iframe = iframeRef.current; + if (!iframe) return; - if (iframe) { - if (!showControls) iframe.style.display = "none"; - setState(prevState => ({ ...prevState, loading: true })); - iframe.addEventListener("load", () => { - setEntryStyling(iframe); - getDataEntryForm(iframe, project, dataSet, orgUnitId, () => - setState(prevState => ({ ...prevState, dropdownHasValues: true })) - ); - }); - } + const controller = new AbortController(); + + if (!showControls) iframe.style.display = "none"; + setState(prevState => ({ ...prevState, loading: true })); + + iframe.addEventListener( + "load", + () => { + setState(prevState => ({ ...prevState, dropdownHasValues: true })); + }, + { signal: controller.signal } + ); + + return () => controller.abort(); }, [iframeKey, dataSet, orgUnitId, project]); + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe || showControls) return; + + let intervalId: number | undefined; + + const onLoad = () => { + intervalId = setEntryStyling(iframe); + }; + + iframe.addEventListener("load", onLoad); + + return () => { + iframe.removeEventListener("load", onLoad); + window.clearInterval(intervalId); + }; + }, [iframeKey]); + const period = state.dropdownValue; const [dataSetInfo, setDataSetInfo] = React.useState(); @@ -214,7 +232,7 @@ const DataEntry = (props: DataEntryProps) => { Boolean(isDataSetOpen) && state.dropdownHasValues && Boolean(dataSetInfo?.isOpen); const validation = useValidation({ - iframeRef, + iframe: pluginIframe, project, dataSetType, period, @@ -266,7 +284,6 @@ const DataEntry = (props: DataEntryProps) => { onClose={validation.clear} /> )} - setDisableValidation(true)} @@ -278,7 +295,6 @@ const DataEntry = (props: DataEntryProps) => { } }} /> -
{!state.dropdownHasValues && } @@ -309,7 +325,6 @@ const DataEntry = (props: DataEntryProps) => {
)}
-