From 000bbf01602017fd8fad5cea0e420ab5761ad0df Mon Sep 17 00:00:00 2001 From: Fernando Gomez Date: Mon, 1 Jun 2026 16:27:05 +0200 Subject: [PATCH 1/6] feat: DHIS2-21266 - Integrate react Markdown lib - Integrate react markdown library to support standard md elements - Apply corresponding legend sets for rule actions where apply - Create Markdown e2e testing for markdown integration --- cypress/e2e/Markdown/Markdown.feature | 32 + cypress/e2e/Markdown/Markdown.js | 79 ++ i18n/en.pot | 4 +- package.json | 2 + .../WidgetFeedback/WidgetFeedback.types.ts | 4 + .../WidgetFeedbackContent/MarkdownWrapper.tsx | 170 ++++ .../WidgetFeedbackContent.tsx | 74 +- .../helpers/rulesEffectsProcessor.ts | 4 + .../RuleEngine/types/ruleEngine.types.ts | 3 + yarn.lock | 834 +++++++++++++++++- 10 files changed, 1172 insertions(+), 34 deletions(-) create mode 100644 cypress/e2e/Markdown/Markdown.feature create mode 100644 cypress/e2e/Markdown/Markdown.js create mode 100644 src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper.tsx diff --git a/cypress/e2e/Markdown/Markdown.feature b/cypress/e2e/Markdown/Markdown.feature new file mode 100644 index 0000000000..cb2463e37e --- /dev/null +++ b/cypress/e2e/Markdown/Markdown.feature @@ -0,0 +1,32 @@ +Feature: Feedback widget renders markdown content from program rules + + Scenario: Feedback widget is visible on the enrollment dashboard + Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=hmX770XqCSS + Then the feedback widget should be visible + + Scenario: Feedback widget list contains 4 items + Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=hmX770XqCSS + Then the list should contain 4 items + + Scenario: First feedback item renders headers markdown correctly + Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=hmX770XqCSS + Then the first list item should contain p with text "Headers" + And the first list item should contain h1 with text "Header 1" + And the first list item should contain h2 with text "Header 2" + And the first list item should contain h3 with text "Header 3" + And the first list item should contain h4 with text "Header 4" + And the first list item should contain h5 with text "Header 5" + And the first list item should contain h6 with text "Header 6" + + Scenario: Second feedback item renders italic heading and table correctly + Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=hmX770XqCSS + Then the second list item h6 should contain italic text "Feedback" + And the second list item table should match: + | Column A | Column B | + | Content A | Content B | + + Scenario: Fourth feedback item renders score with color indicator correctly + Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=hmX770XqCSS + Then the fourth list item left section should have h1 with text "1. Score 1" + And the fourth list item right section color indicator should be "rgb(238, 71, 71)" + And the fourth list item right section should have p with text "66.67%" diff --git a/cypress/e2e/Markdown/Markdown.js b/cypress/e2e/Markdown/Markdown.js new file mode 100644 index 0000000000..0b2df3d362 --- /dev/null +++ b/cypress/e2e/Markdown/Markdown.js @@ -0,0 +1,79 @@ +import {Given, Then, defineStep as And} from '@badeball/cypress-cucumber-preprocessor'; + +const LONG_TIMEOUT = { timeout: 120000 }; + +const getFeedbackDiv = () => + cy.get('[data-test="feedback-widget"]', LONG_TIMEOUT); + +// SHARED + +Given(/^user lands on the enrollment edit event page by having typed (.*)$/, (url) => { + cy.visit(url, LONG_TIMEOUT); + getFeedbackDiv().should('exist'); +}); + +Then('the feedback widget should be visible', () => { + getFeedbackDiv().should('be.visible'); +}); + +Then('the list should contain {int} items', (expectedCount) => { + getFeedbackDiv() + .find('[data-test="widget-contents"]', LONG_TIMEOUT) + .find('ul') + .find('li') + .should('have.length', expectedCount); +}); + +const getListItem = (n) => + getFeedbackDiv() + .find('[data-test="widget-contents"]', LONG_TIMEOUT) + .find('ul li') + .eq(n - 1); + +// FIRST FEEDBACK ELEMENT + +And('the first list item should contain {word} with text {string}', (tag, text) => { + getListItem(1) + .find(tag) + .should('have.text', text); +}); + +// SECOND FEEDBACK ELEMENT + +Then('the second list item {word} should contain italic text {string}', (tag, text) => { + getListItem(2) + .find(tag) + .find('em') + .should('have.text', text); +}); + +And('the second list item table should match:', (dataTable) => { + const [headerRow, ...bodyRows] = dataTable.raw(); + + getListItem(2).find('table').within(() => { + cy.get('thead th').each(($th, i) => { + expect($th.text()).to.equal(headerRow[i]); + }); + + cy.get('tbody tr').each(($tr, rowIndex) => { + bodyRows[rowIndex].forEach((cell, colIndex) => { + expect($tr.find('td').eq(colIndex).text()).to.equal(cell); + }); + }); + }); +}); + + +// FOURTH FEEDBACK ELEMENT + +Then('the fourth list item left section should have {word} with text {string}', (tag, text) => { + getListItem(4).find('> div > div').first().find(tag).should('have.text', text); +}); + +And('the fourth list item right section color indicator should be {string}', (color) => { + getListItem(4).find('> div > div').last().find('span').should('have.css', 'background-color', color); +}); + +And('the fourth list item right section should have {word} with text {string}', (tag, text) => { + getListItem(4).find('> div > div').last().find(tag).should('have.text', text); +}); diff --git a/i18n/en.pot b/i18n/en.pot index 58ea419d37..81272a35b6 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-05-26T07:35:44.230Z\n" -"PO-Revision-Date: 2026-05-26T07:35:44.230Z\n" +"POT-Creation-Date: 2026-06-01T14:18:23.707Z\n" +"PO-Revision-Date: 2026-06-01T14:18:23.707Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/package.json b/package.json index 43c2e2b160..78579721a9 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "react-leaflet": "^2.8.0", "react-leaflet-draw": "0.19.0", "react-leaflet-search-unpolyfilled": "^0.1.0", + "react-markdown": "^10.1.0", "react-popper": "^2.2.4", "react-redux": "^9.2.0", "react-router-dom": "^5.3.0", @@ -57,6 +58,7 @@ "redux-logger": "^3.0.6", "redux-observable": "^2.0.0", "regenerator-runtime": "^0.14.1", + "remark-gfm": "^4.0.1", "reselect": "^4.1.7", "rxjs": "^7.8.2", "typeface-roboto": "^1.1.13", diff --git a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedback.types.ts b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedback.types.ts index 66b3803412..74b782beb5 100644 --- a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedback.types.ts +++ b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedback.types.ts @@ -2,6 +2,8 @@ export type FilteredFeedbackText = { id: string; message: string; color?: string; + priority?: number; + legendSetId?: string; } export type FilteredFeedbackKeyValue = { @@ -9,6 +11,8 @@ export type FilteredFeedbackKeyValue = { key: string; value: string; color?: string; + priority?: number; + legendSetId?: string; } export type FeedbackWidgetData = string | FilteredFeedbackText | FilteredFeedbackKeyValue; diff --git a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper.tsx b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper.tsx new file mode 100644 index 0000000000..b25bc8a5bb --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper.tsx @@ -0,0 +1,170 @@ +import React, { ComponentType } from 'react'; +import { spacersNum, colors } from '@dhis2/ui'; +import Markdown from 'react-markdown'; +import { withStyles, type WithStyles } from 'capture-core-utils/styles'; +import remarkGfm from 'remark-gfm'; + +export type MarkdownWrapperProps = { + title: string; + content?: string; + color?: string; +} + +const styles = { + container: { + width: '100%', + margin: '8px 0px', + display: 'flex', + gap: '1rem', + }, + title: { + flex: '1 0 50%', + minWidth: '50%', + }, + content: { + flex: '0 1 auto', + maxWidth: '50%', + minWidth: '0', + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + legend: { + height: '12px', + width: '12px', + minWidth: '12px', + borderRadius: '2px', + }, + markdown: { + fontSize: '14px', + lineHeight: 1.45, + color: colors.grey900, + '&:first-child': { + marginTop: 0, + }, + '& p': { + margin: `${spacersNum.dp8}px 0 0 0`, + display: 'inline-block', + }, + '& p:first-child': { + marginTop: 0, + }, + '& h1': { + fontSize: '18px', + fontWeight: 700, + lineHeight: 1.25, + margin: `${spacersNum.dp12}px 0 ${spacersNum.dp8}px 0`, + paddingBottom: spacersNum.dp4, + borderBottom: `2px solid ${colors.grey400}`, + color: colors.grey900, + }, + '& h2': { + fontSize: '16px', + fontWeight: 700, + margin: `${spacersNum.dp12}px 0 ${spacersNum.dp4}px 0`, + color: colors.grey900, + }, + '& h3': { + fontSize: '15px', + fontWeight: 600, + margin: `${spacersNum.dp8}px 0 ${spacersNum.dp4}px 0`, + color: colors.grey900, + }, + '& h4, & h5, & h6': { + fontSize: '14px', + fontWeight: 600, + margin: `${spacersNum.dp8}px 0 ${spacersNum.dp4}px 0`, + color: colors.grey900, + }, + '& strong': { + fontWeight: 700, + color: colors.grey900, + }, + '& em': { + fontStyle: 'italic', + color: colors.grey900, + }, + '& a': { + color: colors.blue800, + textDecoration: 'underline', + }, + '& ul, & ol': { + margin: `${spacersNum.dp8}px 0 0 0`, + paddingInlineStart: spacersNum.dp16, + }, + '& li': { + marginTop: spacersNum.dp4, + }, + '& code': { + fontFamily: 'Menlo, Monaco, Consolas, monospace', + fontSize: '12px', + background: colors.grey200, + padding: `1px ${spacersNum.dp4}px`, + borderRadius: '3px', + }, + '& pre': { + margin: `${spacersNum.dp8}px 0 0 0`, + padding: spacersNum.dp8, + background: colors.grey200, + borderRadius: '4px', + overflow: 'auto', + fontSize: '12px', + }, + '& pre code': { + background: 'transparent', + padding: 0, + }, + '& blockquote': { + margin: `${spacersNum.dp8}px 0 0 0`, + paddingInlineStart: spacersNum.dp12, + borderInlineStart: `4px solid ${colors.grey400}`, + color: colors.grey800, + fontStyle: 'italic', + }, + '& hr': { + margin: `${spacersNum.dp12}px 0`, + border: 'none', + borderTop: `1px solid ${colors.grey400}`, + }, + '& table': { + borderCollapse: 'collapse', + width: '100%', + margin: `${spacersNum.dp8}px 0`, + fontSize: '13px', + lineHeight: '18px', + }, + '& th, & td': { + padding: `${spacersNum.dp4}px ${spacersNum.dp8}px`, + border: `1px solid ${colors.grey300}`, + textAlign: 'left', + }, + '& th': { + fontWeight: 600, + color: colors.grey800, + whiteSpace: 'nowrap', + }, + '& td': { + color: colors.grey900, + }, + }, +}; + +type Props = MarkdownWrapperProps & WithStyles; +const MarkdownWrapperComponent = ({ title, content, color, classes }:Props) => ( +
+
+
{title}
+
+ + { content && +
+ { color && } +
+ {content} +
+
+ } +
+); + +export const MarkdownWrapper = withStyles(styles)(MarkdownWrapperComponent) as ComponentType; diff --git a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx index 9e11aa1185..2af0dffae9 100644 --- a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx +++ b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx @@ -1,6 +1,8 @@ -import React, { type ComponentType } from 'react'; +import React, { type ComponentType, useMemo } from 'react'; +import { useDataQuery } from '@dhis2/app-runtime'; import { spacersNum, colors } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; +import { MarkdownWrapper } from 'capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper'; import type { FilteredFeedbackKeyValue, FeedbackWidgetData, @@ -8,9 +10,13 @@ import type { FeedbackProps, } from '../WidgetFeedback.types'; + const styles = { container: { padding: `0px ${spacersNum.dp12}px`, + maxHeight: '70vh', + overflowY: 'auto', + scrollbarWidth: 'thin', }, unorderedList: { paddingInlineStart: spacersNum.dp16, @@ -33,9 +39,54 @@ const styles = { }, }; +type Legend = { startValue: number; endValue: number; color: string }; +type LegendSetApiResponse = { legendSets: Array<{ id: string; legends: Legend[] }> }; + +const legendSetQuery = { + legendSets: { + resource: 'legendSets', + params: ({ ids }: Record) => ({ + fields: 'id,legends[id,startValue,endValue,color]', + filter: `id:in:[${ids}]`, + paging: false, + }), + }, +}; + type Props = FeedbackProps & WithStyles; const WidgetFeedbackContentComponent = ({ feedback, feedbackEmptyText, classes }: Props) => { + const legendSetIds = useMemo(() => [...new Set( + (feedback ?? []) + .filter((rule): rule is FilteredFeedbackKeyValue => + typeof rule === 'object' && ('key' in rule || 'value' in rule)) + .map(rule => rule.legendSetId) + .filter((id): id is string => !!id), + )], [feedback]); + + const { data: legendSetData } = useDataQuery(legendSetQuery, { + variables: { ids: legendSetIds.join(',') }, + lazy: legendSetIds.length === 0, + }); + + const legendSetsById = useMemo>(() => { + const map: Record = {}; + const response = legendSetData?.legendSets as LegendSetApiResponse | undefined; + (response?.legendSets ?? []).forEach((ls) => { + map[ls.id] = ls.legends; + }); + return map; + }, [legendSetData]); + + const resolveColor = (legendSetId: string | undefined, value: string): string | undefined => { + if (!legendSetId) return undefined; + const legends = legendSetsById[legendSetId]; + if (!legends?.length) return undefined; + const numeric = parseFloat(value); + if (isNaN(numeric)) return undefined; + return legends.find(l => numeric >= l.startValue && numeric < l.endValue)?.color; + }; + if (!feedback?.length) { return (
- {item.message} + ); - const renderKeyValue = (item: FilteredFeedbackKeyValue) => ( -
  • - {item.key}{item.key && item.value ? ': ' : null}{item.value} -
  • - ); + const renderKeyValue = (item: FilteredFeedbackKeyValue) => { + const color = resolveColor(item.legendSetId, item.value); + return ( +
  • + +
  • + ); + }; const renderString = (item: string, index: number) => (
  • Date: Mon, 1 Jun 2026 16:44:55 +0200 Subject: [PATCH 2/6] fix: fix code analysis report for parseFloat and isNaN --- .../WidgetFeedbackContent/WidgetFeedbackContent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx index 2af0dffae9..1a710c5189 100644 --- a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx +++ b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx @@ -82,8 +82,8 @@ const WidgetFeedbackContentComponent = ({ feedback, feedbackEmptyText, classes } if (!legendSetId) return undefined; const legends = legendSetsById[legendSetId]; if (!legends?.length) return undefined; - const numeric = parseFloat(value); - if (isNaN(numeric)) return undefined; + const numeric = Number.parseFloat(value); + if (Number.isNaN(numeric)) return undefined; return legends.find(l => numeric >= l.startValue && numeric < l.endValue)?.color; }; From 5d85867bb6050fa9f3c33c19a5f6bb79957f4eb0 Mon Sep 17 00:00:00 2001 From: Simona Domnisoru Date: Tue, 2 Jun 2026 11:47:00 +0200 Subject: [PATCH 3/6] chore: dedup wrap-ansi to fix the CI run --- i18n/en.pot | 4 ++-- yarn.lock | 47 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 81272a35b6..7ffceca7cd 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-06-01T14:18:23.707Z\n" -"PO-Revision-Date: 2026-06-01T14:18:23.707Z\n" +"POT-Creation-Date: 2026-06-02T09:46:52.775Z\n" +"PO-Revision-Date: 2026-06-02T09:46:52.775Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/yarn.lock b/yarn.lock index 2c8fd16a1d..d484aaa691 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4073,6 +4073,13 @@ "@types/linkify-it" "^5" "@types/mdurl" "^2" +"@types/mdast@^4.0.0": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" + integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA== + dependencies: + "@types/unist" "*" + "@types/mdurl@^2": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" @@ -4083,6 +4090,11 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e" integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== +"@types/ms@*": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" + integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== + "@types/node@*", "@types/node@^25.6.2": version "25.8.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-25.8.0.tgz#d13033397d1c186876bed4c9b9d7f3f962097eb3" @@ -4172,6 +4184,16 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== +"@types/unist@*", "@types/unist@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" + integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== + +"@types/unist@^2.0.0": + version "2.0.11" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4" + integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA== + "@types/use-sync-external-store@^0.0.6": version "0.0.6" resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" @@ -13993,7 +14015,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -14019,6 +14041,15 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -14118,7 +14149,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15726,8 +15757,7 @@ workbox-window@7.3.0: "@types/trusted-types" "^2.0.2" workbox-core "7.3.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - name wrap-ansi-cjs +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -15754,6 +15784,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From f23e857e0a397958e230a33f798bfb999a4cd992 Mon Sep 17 00:00:00 2001 From: Fernando Gomez Date: Thu, 18 Jun 2026 00:59:55 +0200 Subject: [PATCH 4/6] fix: [DHIS2-21266] Apply changes from PR feedback * Folder structure * Update cypress tests --- cypress/e2e/Markdown/Markdown.feature | 14 ++--- i18n/en.pot | 4 +- .../{ => MarkdownWrapper}/MarkdownWrapper.tsx | 6 +- .../MarkdownWrapper/MarkdownWrapper.types.ts | 5 ++ .../MarkdownWrapper/index.ts | 1 + .../WidgetFeedbackContent.tsx | 50 ++------------- .../useLegendSetsById.ts | 62 +++++++++++++++++++ .../RuleEngine/types/ruleEngine.types.ts | 2 +- 8 files changed, 83 insertions(+), 61 deletions(-) rename src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/{ => MarkdownWrapper}/MarkdownWrapper.tsx (98%) create mode 100644 src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/MarkdownWrapper.types.ts create mode 100644 src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/index.ts create mode 100644 src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/useLegendSetsById.ts diff --git a/cypress/e2e/Markdown/Markdown.feature b/cypress/e2e/Markdown/Markdown.feature index cb2463e37e..13a04ebceb 100644 --- a/cypress/e2e/Markdown/Markdown.feature +++ b/cypress/e2e/Markdown/Markdown.feature @@ -1,15 +1,15 @@ Feature: Feedback widget renders markdown content from program rules Scenario: Feedback widget is visible on the enrollment dashboard - Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=hmX770XqCSS + Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=EKlWxFF9NPb Then the feedback widget should be visible Scenario: Feedback widget list contains 4 items - Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=hmX770XqCSS + Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=EKlWxFF9NPb Then the list should contain 4 items Scenario: First feedback item renders headers markdown correctly - Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=hmX770XqCSS + Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=EKlWxFF9NPb Then the first list item should contain p with text "Headers" And the first list item should contain h1 with text "Header 1" And the first list item should contain h2 with text "Header 2" @@ -19,14 +19,14 @@ Feature: Feedback widget renders markdown content from program rules And the first list item should contain h6 with text "Header 6" Scenario: Second feedback item renders italic heading and table correctly - Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=hmX770XqCSS + Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=EKlWxFF9NPb Then the second list item h6 should contain italic text "Feedback" And the second list item table should match: | Column A | Column B | | Content A | Content B | Scenario: Fourth feedback item renders score with color indicator correctly - Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=hmX770XqCSS + Given user lands on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=EKlWxFF9NPb Then the fourth list item left section should have h1 with text "1. Score 1" - And the fourth list item right section color indicator should be "rgb(238, 71, 71)" - And the fourth list item right section should have p with text "66.67%" + And the fourth list item right section color indicator should be "rgb(57, 166, 45)" + And the fourth list item right section should have p with text "90%" diff --git a/i18n/en.pot b/i18n/en.pot index 7ffceca7cd..054922a20e 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-06-02T09:46:52.775Z\n" -"PO-Revision-Date: 2026-06-02T09:46:52.775Z\n" +"POT-Creation-Date: 2026-06-17T22:59:57.403Z\n" +"PO-Revision-Date: 2026-06-17T22:59:57.404Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper.tsx b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/MarkdownWrapper.tsx similarity index 98% rename from src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper.tsx rename to src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/MarkdownWrapper.tsx index b25bc8a5bb..1b602aaa3a 100644 --- a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper.tsx +++ b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/MarkdownWrapper.tsx @@ -3,12 +3,8 @@ import { spacersNum, colors } from '@dhis2/ui'; import Markdown from 'react-markdown'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import remarkGfm from 'remark-gfm'; +import { MarkdownWrapperProps } from './MarkdownWrapper.types'; -export type MarkdownWrapperProps = { - title: string; - content?: string; - color?: string; -} const styles = { container: { diff --git a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/MarkdownWrapper.types.ts b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/MarkdownWrapper.types.ts new file mode 100644 index 0000000000..025524fd25 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/MarkdownWrapper.types.ts @@ -0,0 +1,5 @@ +export type MarkdownWrapperProps = { + title: string; + content?: string; + color?: string; +} diff --git a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/index.ts b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/index.ts new file mode 100644 index 0000000000..0ed475d71f --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/index.ts @@ -0,0 +1 @@ +export { MarkdownWrapper } from './MarkdownWrapper'; diff --git a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx index 1a710c5189..a5bcafa817 100644 --- a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx +++ b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx @@ -1,15 +1,14 @@ -import React, { type ComponentType, useMemo } from 'react'; -import { useDataQuery } from '@dhis2/app-runtime'; +import React, { type ComponentType } from 'react'; import { spacersNum, colors } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; -import { MarkdownWrapper } from 'capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper'; +import { MarkdownWrapper } from './MarkdownWrapper'; import type { FilteredFeedbackKeyValue, FeedbackWidgetData, FilteredFeedbackText, FeedbackProps, } from '../WidgetFeedback.types'; - +import { useLegendSetsById } from './useLegendSetsById'; const styles = { container: { @@ -39,53 +38,12 @@ const styles = { }, }; -type Legend = { startValue: number; endValue: number; color: string }; -type LegendSetApiResponse = { legendSets: Array<{ id: string; legends: Legend[] }> }; - -const legendSetQuery = { - legendSets: { - resource: 'legendSets', - params: ({ ids }: Record) => ({ - fields: 'id,legends[id,startValue,endValue,color]', - filter: `id:in:[${ids}]`, - paging: false, - }), - }, -}; type Props = FeedbackProps & WithStyles; const WidgetFeedbackContentComponent = ({ feedback, feedbackEmptyText, classes }: Props) => { - const legendSetIds = useMemo(() => [...new Set( - (feedback ?? []) - .filter((rule): rule is FilteredFeedbackKeyValue => - typeof rule === 'object' && ('key' in rule || 'value' in rule)) - .map(rule => rule.legendSetId) - .filter((id): id is string => !!id), - )], [feedback]); - const { data: legendSetData } = useDataQuery(legendSetQuery, { - variables: { ids: legendSetIds.join(',') }, - lazy: legendSetIds.length === 0, - }); - - const legendSetsById = useMemo>(() => { - const map: Record = {}; - const response = legendSetData?.legendSets as LegendSetApiResponse | undefined; - (response?.legendSets ?? []).forEach((ls) => { - map[ls.id] = ls.legends; - }); - return map; - }, [legendSetData]); - - const resolveColor = (legendSetId: string | undefined, value: string): string | undefined => { - if (!legendSetId) return undefined; - const legends = legendSetsById[legendSetId]; - if (!legends?.length) return undefined; - const numeric = Number.parseFloat(value); - if (Number.isNaN(numeric)) return undefined; - return legends.find(l => numeric >= l.startValue && numeric < l.endValue)?.color; - }; + const { resolveColor } = useLegendSetsById({ feedback }); if (!feedback?.length) { return ( diff --git a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/useLegendSetsById.ts b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/useLegendSetsById.ts new file mode 100644 index 0000000000..b89eb0c58e --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/useLegendSetsById.ts @@ -0,0 +1,62 @@ +import { useMemo } from 'react'; +import { useDataQuery } from '@dhis2/app-runtime'; + +import type { FilteredFeedbackKeyValue, FeedbackProps } from '../WidgetFeedback.types'; + +type Legend = { startValue: number; endValue: number; color: string }; +type LegendSetApiResponse = { legendSets: Array<{ id: string; legends: Legend[] }> }; + +const legendSetQuery = { + legendSets: { + resource: 'legendSets', + params: ({ ids }: Record) => ({ + fields: 'id,legends[id,startValue,endValue,color]', + filter: `id:in:[${ids}]`, + paging: false, + }), + }, +}; + +export const useLegendSetsById = ({feedback}: { feedback: FeedbackProps['feedback'] }) => { + const legendSetIds = useMemo( + () => [ + ...new Set( + (feedback ?? []) + .filter( + (rule): rule is FilteredFeedbackKeyValue => + typeof rule === 'object' && ('key' in rule || 'value' in rule), + ) + .map(rule => rule.legendSetId) + .filter((id): id is string => !!id), + ), + ], + [feedback], + ); + + const {data: legendSetData} = useDataQuery(legendSetQuery, { + variables: {ids: legendSetIds.join(',')}, + lazy: legendSetIds.length === 0, + }); + + const legendSetsById = useMemo>(() => { + const map: Record = {}; + const response = legendSetData?.legendSets as LegendSetApiResponse | undefined; + (response?.legendSets ?? []).forEach((ls) => { + map[ls.id] = ls.legends; + }); + return map; + }, [legendSetData]); + + const resolveColor = (legendSetId: string | undefined, value: string): string | undefined => { + if (!legendSetId) return undefined; + const legends = legendSetsById[legendSetId]; + if (!legends?.length) return undefined; + const numeric = Number.parseFloat(value); + if (Number.isNaN(numeric)) return undefined; + return legends.find(l => numeric >= l.startValue && numeric < l.endValue)?.color; + }; + + return { + resolveColor, + }; +}; diff --git a/src/core_modules/capture-core/rules/RuleEngine/types/ruleEngine.types.ts b/src/core_modules/capture-core/rules/RuleEngine/types/ruleEngine.types.ts index 8cbec49fc3..f00a86e4d9 100644 --- a/src/core_modules/capture-core/rules/RuleEngine/types/ruleEngine.types.ts +++ b/src/core_modules/capture-core/rules/RuleEngine/types/ruleEngine.types.ts @@ -176,7 +176,7 @@ export type ProgramRuleAction = { data: string | null, programRuleActionType: string, priority?: number, - legendSetId?: string, + legendSetId?: string | null, } & ProgramRuleData; export type ProgramRule = { From eeafde83b8b2c3a26d5d3026af6e9a238979f244 Mon Sep 17 00:00:00 2001 From: Fernando Gomez Date: Thu, 18 Jun 2026 01:03:11 +0200 Subject: [PATCH 5/6] fix: [DHIS2-21266] Code format fix --- .../WidgetFeedbackContent/WidgetFeedbackContent.tsx | 2 -- .../WidgetFeedbackContent/useLegendSetsById.ts | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx index a5bcafa817..be0b030453 100644 --- a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx +++ b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/WidgetFeedbackContent.tsx @@ -40,9 +40,7 @@ const styles = { type Props = FeedbackProps & WithStyles; - const WidgetFeedbackContentComponent = ({ feedback, feedbackEmptyText, classes }: Props) => { - const { resolveColor } = useLegendSetsById({ feedback }); if (!feedback?.length) { diff --git a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/useLegendSetsById.ts b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/useLegendSetsById.ts index b89eb0c58e..f7017ddaad 100644 --- a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/useLegendSetsById.ts +++ b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/useLegendSetsById.ts @@ -17,7 +17,7 @@ const legendSetQuery = { }, }; -export const useLegendSetsById = ({feedback}: { feedback: FeedbackProps['feedback'] }) => { +export const useLegendSetsById = ({ feedback }: { feedback: FeedbackProps['feedback'] }) => { const legendSetIds = useMemo( () => [ ...new Set( @@ -33,8 +33,8 @@ export const useLegendSetsById = ({feedback}: { feedback: FeedbackProps['feedbac [feedback], ); - const {data: legendSetData} = useDataQuery(legendSetQuery, { - variables: {ids: legendSetIds.join(',')}, + const { data: legendSetData } = useDataQuery(legendSetQuery, { + variables: { ids: legendSetIds.join(',') }, lazy: legendSetIds.length === 0, }); From a7e7214a11a695db05fa271fab775d2c5e7ef091 Mon Sep 17 00:00:00 2001 From: Fernando Gomez Date: Thu, 25 Jun 2026 18:45:54 -0600 Subject: [PATCH 6/6] fix: [DHIS2-21266] Update MarkdownWrapper & remove unused params - Wrap MarkdownWrapper component with React.memo - Remove unused priority parameters at rulesEffectsProcessor --- .../MarkdownWrapper/MarkdownWrapper.tsx | 13 ++++++++----- .../RuleEngine/helpers/rulesEffectsProcessor.ts | 2 -- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/MarkdownWrapper.tsx b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/MarkdownWrapper.tsx index 1b602aaa3a..ff2b11e5a7 100644 --- a/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/MarkdownWrapper.tsx +++ b/src/core_modules/capture-core/components/WidgetFeedback/WidgetFeedbackContent/MarkdownWrapper/MarkdownWrapper.tsx @@ -1,4 +1,4 @@ -import React, { ComponentType } from 'react'; +import React, { ComponentType, memo } from 'react'; import { spacersNum, colors } from '@dhis2/ui'; import Markdown from 'react-markdown'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; @@ -144,23 +144,26 @@ const styles = { }, }, }; +const REMARK_PLUGINS = [remarkGfm]; type Props = MarkdownWrapperProps & WithStyles; -const MarkdownWrapperComponent = ({ title, content, color, classes }:Props) => ( +const MarkdownWrapperComponent = memo(({ title, content, color, classes }:Props) => (
    -
    {title}
    +
    + {title} +
    { content &&
    { color && }
    - {content} + {content}
    }
    -); +)); export const MarkdownWrapper = withStyles(styles)(MarkdownWrapperComponent) as ComponentType; diff --git a/src/core_modules/capture-core/rules/RuleEngine/helpers/rulesEffectsProcessor.ts b/src/core_modules/capture-core/rules/RuleEngine/helpers/rulesEffectsProcessor.ts index 36162b02b9..b80b14a712 100644 --- a/src/core_modules/capture-core/rules/RuleEngine/helpers/rulesEffectsProcessor.ts +++ b/src/core_modules/capture-core/rules/RuleEngine/helpers/rulesEffectsProcessor.ts @@ -175,7 +175,6 @@ export function getRulesEffectsProcessor( displayText: { id: effect.id, message: `${message} ${sanitiseFalsy(effect.data)}`, - priority: effect.priority, legendSetId: effect.legendSetId, ...effect.style, }, @@ -192,7 +191,6 @@ export function getRulesEffectsProcessor( id: effect.id, key: effect.displayContent, value: typeof data === 'number' ? numberToString(data) : String(data), - priority: effect.priority, legendSetId: effect.legendSetId, ...effect.style, },