@epcare/esm-report-builder 0.1.1-pre.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/.eslintignore +5 -0
  2. package/.eslintrc.js +19 -0
  3. package/.prettierrc +8 -0
  4. package/LICENSE +401 -0
  5. package/README.md +317 -0
  6. package/dist/184.js +2 -0
  7. package/dist/184.js.LICENSE.txt +14 -0
  8. package/dist/184.js.map +1 -0
  9. package/dist/211.js +1 -0
  10. package/dist/211.js.map +1 -0
  11. package/dist/300.js +1 -0
  12. package/dist/311.js +1 -0
  13. package/dist/311.js.map +1 -0
  14. package/dist/44.js +2 -0
  15. package/dist/44.js.LICENSE.txt +30 -0
  16. package/dist/44.js.map +1 -0
  17. package/dist/478.js +2 -0
  18. package/dist/478.js.LICENSE.txt +9 -0
  19. package/dist/478.js.map +1 -0
  20. package/dist/535.js +1 -0
  21. package/dist/535.js.map +1 -0
  22. package/dist/540.js +2 -0
  23. package/dist/540.js.LICENSE.txt +9 -0
  24. package/dist/540.js.map +1 -0
  25. package/dist/710.js +1 -0
  26. package/dist/710.js.map +1 -0
  27. package/dist/729.js +1 -0
  28. package/dist/729.js.map +1 -0
  29. package/dist/872.js +1 -0
  30. package/dist/872.js.map +1 -0
  31. package/dist/917.js +1 -0
  32. package/dist/917.js.map +1 -0
  33. package/dist/961.js +2 -0
  34. package/dist/961.js.LICENSE.txt +19 -0
  35. package/dist/961.js.map +1 -0
  36. package/dist/972.js +2 -0
  37. package/dist/972.js.LICENSE.txt +21 -0
  38. package/dist/972.js.map +1 -0
  39. package/dist/main.js +1 -0
  40. package/dist/main.js.map +1 -0
  41. package/dist/openmrs-esm-report-builder.js +1 -0
  42. package/dist/openmrs-esm-report-builder.js.buildmanifest.json +482 -0
  43. package/dist/openmrs-esm-report-builder.js.map +1 -0
  44. package/dist/routes.json +1 -0
  45. package/e2e/core/env.ts +5 -0
  46. package/e2e/core/global-setup.ts +34 -0
  47. package/e2e/core/test.ts +21 -0
  48. package/e2e/pages/app-shell.page.ts +34 -0
  49. package/e2e/pages/etl-sources.page.ts +35 -0
  50. package/e2e/pages/report-sections.page.ts +20 -0
  51. package/e2e/pages/section-preview.modal.ts +29 -0
  52. package/e2e/specs/etl-sources.spec.ts +51 -0
  53. package/e2e/specs/navigation.spec.ts +13 -0
  54. package/e2e/specs/section-preview-validation.spec.ts +38 -0
  55. package/e2e/specs/section-preview.spec.ts +94 -0
  56. package/e2e/tsconfig.json +11 -0
  57. package/example.env +5 -0
  58. package/jest.config.js +28 -0
  59. package/package.json +102 -0
  60. package/playwright.config.ts +28 -0
  61. package/src/admin-card-link.extension.module.scss +55 -0
  62. package/src/admin-card-link.extension.tsx +24 -0
  63. package/src/components/admin/admin-page.component.tsx +31 -0
  64. package/src/components/admin/age-categories-page.component.tsx +109 -0
  65. package/src/components/admin/age-groups-page.component.tsx +146 -0
  66. package/src/components/admin/etl-sources-page.component.tsx +344 -0
  67. package/src/components/admin/report-categories-page.component.tsx +256 -0
  68. package/src/components/admin/report-library-page.component.tsx +540 -0
  69. package/src/components/ai-support/ai-assist-button.component.tsx +30 -0
  70. package/src/components/ai-support/ai-assist-modal.component.tsx +81 -0
  71. package/src/components/ai-support/ai.service.ts +21 -0
  72. package/src/components/ai-support/ai.types.ts +12 -0
  73. package/src/components/app-shell/report-builder-shell.component.tsx +120 -0
  74. package/src/components/app-shell/report-builder-shell.scss +15 -0
  75. package/src/components/data-themes/data-theme-form.component.tsx +59 -0
  76. package/src/components/data-themes/data-theme-modal.component.tsx +349 -0
  77. package/src/components/data-themes/data-themes-page.component.tsx +132 -0
  78. package/src/components/data-themes/data-themes-table.component.tsx +91 -0
  79. package/src/components/data-themes/icon-registry.ts +35 -0
  80. package/src/components/data-themes/sections/data-theme-basics.section.tsx +63 -0
  81. package/src/components/data-themes/sections/data-theme-condition-columns.section.tsx +397 -0
  82. package/src/components/data-themes/sections/data-theme-fields-editor.section.tsx +125 -0
  83. package/src/components/data-themes/sections/data-theme-metadata.section.tsx +245 -0
  84. package/src/components/data-themes/sections/data-theme-preview.section.tsx +115 -0
  85. package/src/components/data-themes/sections/data-theme-source.section.tsx +184 -0
  86. package/src/components/indicators/README.md +24 -0
  87. package/src/components/indicators/base-indicator-basic-fields.component.tsx +18 -0
  88. package/src/components/indicators/base-indicator-basics.section.tsx +54 -0
  89. package/src/components/indicators/base-indicator-countby.section.tsx +25 -0
  90. package/src/components/indicators/base-indicator-criteria.section.tsx +83 -0
  91. package/src/components/indicators/base-indicator-details-form.component.tsx +80 -0
  92. package/src/components/indicators/base-indicator-theme-unit-fields.component.tsx +39 -0
  93. package/src/components/indicators/create-base-indicator-modal.component.tsx +501 -0
  94. package/src/components/indicators/create-composite-base-indicator-modal.component.tsx +297 -0
  95. package/src/components/indicators/create-final-indicator-modal.component.tsx +260 -0
  96. package/src/components/indicators/diagnosis-filters-form.component.tsx +101 -0
  97. package/src/components/indicators/disaggregation-panel.component.tsx +93 -0
  98. package/src/components/indicators/handler/concept-search-multiselect.component.tsx +153 -0
  99. package/src/components/indicators/handler/question-answer-concept-search.component.tsx +336 -0
  100. package/src/components/indicators/indicator-status-tag.component.tsx +9 -0
  101. package/src/components/indicators/indicators-page.component.tsx +481 -0
  102. package/src/components/indicators/indicators-page.scss +121 -0
  103. package/src/components/indicators/indicators-table.component.tsx +166 -0
  104. package/src/components/indicators/preview-table.component.tsx +31 -0
  105. package/src/components/indicators/sections/composite-indicator-basics.section.tsx +36 -0
  106. package/src/components/indicators/sections/composite-indicator-picker.section.tsx +161 -0
  107. package/src/components/indicators/sections/composite-indicator-sql-preview.section.tsx +31 -0
  108. package/src/components/indicators/sections/final-indicator-basics.section.tsx +46 -0
  109. package/src/components/indicators/sections/final-indicator-disaggregation.section.tsx +47 -0
  110. package/src/components/indicators/sections/final-indicator-picker.section.tsx +92 -0
  111. package/src/components/indicators/sections/final-indicator-preview.section.tsx +20 -0
  112. package/src/components/indicators/sections/final-indicator-results-preview.section.tsx +65 -0
  113. package/src/components/indicators/sections/indicator-basics.section.tsx +40 -0
  114. package/src/components/indicators/sections/indicator-conditions.section.tsx +194 -0
  115. package/src/components/indicators/sections/indicator-sql-preview.section.tsx +26 -0
  116. package/src/components/indicators/sections/indicator-theme.section.tsx +48 -0
  117. package/src/components/indicators/sql-preview.component.tsx +19 -0
  118. package/src/components/indicators/types/base-indicator-form.types.ts +17 -0
  119. package/src/components/indicators/types/base-indicator-theme.constants.ts +8 -0
  120. package/src/components/indicators/types/composite-indicator.types.ts +21 -0
  121. package/src/components/indicators/types/condition-ui.types.ts +11 -0
  122. package/src/components/indicators/types/data-theme-config.types.ts +88 -0
  123. package/src/components/indicators/types/indicator-types.ts +56 -0
  124. package/src/components/indicators/types/sql-builders.ts +43 -0
  125. package/src/components/indicators/utils/composite-indicator-sql.utils.ts +206 -0
  126. package/src/components/indicators/utils/concept-preload.utils.ts +21 -0
  127. package/src/components/indicators/utils/final-indicator-sql.utils.ts +124 -0
  128. package/src/components/indicators/utils/indicator-conditions-hydration.utils.ts +254 -0
  129. package/src/components/indicators/utils/indicator-sql.utils.ts +185 -0
  130. package/src/components/landing/landing-action-item.component.tsx +50 -0
  131. package/src/components/landing/landing-action-list.component.tsx +12 -0
  132. package/src/components/landing/landing-activity-list.component.tsx +47 -0
  133. package/src/components/landing/landing-footer-grid.component.tsx +45 -0
  134. package/src/components/landing/landing-hero.component.tsx +18 -0
  135. package/src/components/landing/landing-section-card.component.tsx +33 -0
  136. package/src/components/landing/report-builder-landing-page.component.tsx +103 -0
  137. package/src/components/landing/report-builder-landing-page.scss +131 -0
  138. package/src/components/report/dashboard/reports-table.component.tsx +145 -0
  139. package/src/components/report/definition/panels/properties-panel.component.tsx +38 -0
  140. package/src/components/report/definition/panels/report-structure-panel.component.tsx +61 -0
  141. package/src/components/report/definition/panels/selection-panel.component.tsx +58 -0
  142. package/src/components/report/definition/report-definition-editor.component.tsx +99 -0
  143. package/src/components/report/definition/report-definition.types.ts +66 -0
  144. package/src/components/report/design/panels/IndicatorPropertiesPanel.scss +33 -0
  145. package/src/components/report/design/panels/IndicatorPropertiesPanel.tsx +389 -0
  146. package/src/components/report/design/panels/JsonPreviewPanel.scss +32 -0
  147. package/src/components/report/design/panels/JsonPreviewPanel.tsx +104 -0
  148. package/src/components/report/design/panels/mapping-preview-panel.component.tsx +82 -0
  149. package/src/components/report/design/panels/mapping-preview-panel.scss +55 -0
  150. package/src/components/report/design/panels/template-structure-panel.component.tsx +296 -0
  151. package/src/components/report/design/panels/template-structure-panel.scss +81 -0
  152. package/src/components/report/design/report-design-editor.component.tsx +894 -0
  153. package/src/components/report/design/report-design.types.ts +43 -0
  154. package/src/components/report/design/report-design.utils.ts +86 -0
  155. package/src/components/report/panels/report-detail.component.tsx +71 -0
  156. package/src/components/report/panels/report-panel-shell.component.tsx +60 -0
  157. package/src/components/report/report-dashboard.page.component.tsx +75 -0
  158. package/src/components/report/report-editor.page.component.tsx +466 -0
  159. package/src/components/report/report.routes.tsx +30 -0
  160. package/src/components/report-sections/create-report-section-modal.component.tsx +278 -0
  161. package/src/components/report-sections/hooks/useAgeCategories.ts +25 -0
  162. package/src/components/report-sections/hooks/useSectionEditorState.ts +306 -0
  163. package/src/components/report-sections/panels/Dhis2MappingPanel.tsx +157 -0
  164. package/src/components/report-sections/panels/DisaggregationPanel.tsx +97 -0
  165. package/src/components/report-sections/panels/IndicatorPickerPanel.tsx +143 -0
  166. package/src/components/report-sections/panels/SectionBasicsPanel.tsx +27 -0
  167. package/src/components/report-sections/report-section-preview-modal.component.tsx +418 -0
  168. package/src/components/report-sections/report-sections-page.component.tsx +313 -0
  169. package/src/components/report-sections/section-types.ts +55 -0
  170. package/src/components/report-sections/section-utils.ts +46 -0
  171. package/src/components/report-sections/sections-page.scss +79 -0
  172. package/src/components/run-reports/run-reports-page.component.tsx +23 -0
  173. package/src/components/shared/header/builder-header-actions.component.tsx +38 -0
  174. package/src/components/shared/header/header.component.tsx +85 -0
  175. package/src/components/shared/header/header.scss +150 -0
  176. package/src/components/shared/header/illustration.component.tsx +34 -0
  177. package/src/components/shared/indicator-properties-panel/IndicatorPropertiesPanel.scss +33 -0
  178. package/src/components/shared/indicator-properties-panel/IndicatorPropertiesPanel.tsx +389 -0
  179. package/src/components/shared/json-preview-panel/JsonPreviewPanel.scss +32 -0
  180. package/src/components/shared/json-preview-panel/JsonPreviewPanel.tsx +104 -0
  181. package/src/components/shared/mapping-preview-panel/mapping-preview-panel.component.tsx +82 -0
  182. package/src/components/shared/mapping-preview-panel/mapping-preview-panel.scss +55 -0
  183. package/src/components/shared/preview/query-results-preview.component.tsx +204 -0
  184. package/src/components/shared/template-structure-panel/template-structure-panel.component.tsx +296 -0
  185. package/src/components/shared/template-structure-panel/template-structure-panel.scss +81 -0
  186. package/src/config-schema.ts +23 -0
  187. package/src/globals.d.ts +14 -0
  188. package/src/hooks/theme/useDataThemes.ts +28 -0
  189. package/src/hooks/theme/useMambaTableMeta.ts +34 -0
  190. package/src/hooks/theme/useMambaTables.ts +63 -0
  191. package/src/index.ts +23 -0
  192. package/src/resources/agegroup/agegroups.api.ts +58 -0
  193. package/src/resources/concepts/concept-mapper.ts +21 -0
  194. package/src/resources/concepts/concept-types.ts +31 -0
  195. package/src/resources/concepts/concepts.resource.ts +44 -0
  196. package/src/resources/concepts/useConceptSearch.ts +41 -0
  197. package/src/resources/etl-source/etl-source.api.ts +65 -0
  198. package/src/resources/indicator/indicators.api.ts +125 -0
  199. package/src/resources/openmrs-api.ts +24 -0
  200. package/src/resources/preview/sql-preview.api.ts +54 -0
  201. package/src/resources/report/reports.api.ts +117 -0
  202. package/src/resources/report-category/report-category.api.ts +41 -0
  203. package/src/resources/report-library/report-library.api.ts +63 -0
  204. package/src/resources/report-section/report-sections.api.ts +73 -0
  205. package/src/resources/report-section/section-preview.api.ts +21 -0
  206. package/src/resources/theme/data-theme.api.ts +88 -0
  207. package/src/resources/theme/mamba-schema.api.ts +31 -0
  208. package/src/resources/theme/mamba-table-meta.api.ts +45 -0
  209. package/src/root.component.tsx +20 -0
  210. package/src/routes/report-builder.routes.tsx +66 -0
  211. package/src/routes/report-builder.scss +56 -0
  212. package/src/routes.json +21 -0
  213. package/src/sample-template.ts +121 -0
  214. package/src/template-utils.ts +110 -0
  215. package/src/types/carbon-react-inert.d.ts +13 -0
  216. package/src/types/condition-operators.ts +49 -0
  217. package/src/types/theme/data-theme.types.ts +88 -0
  218. package/src/utils/html-entities.utils.ts +23 -0
  219. package/tools/setup-tests.ts +1 -0
  220. package/translations/en.json +4 -0
  221. package/tsconfig.json +43 -0
  222. package/webpack.config.js +14 -0
@@ -0,0 +1,120 @@
1
+ import React from 'react';
2
+ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
3
+ import {
4
+ SideNav,
5
+ SideNavItems,
6
+ SideNavLink,
7
+ SideNavMenu,
8
+ SideNavMenuItem,
9
+ } from '@carbon/react';
10
+ import {
11
+ Home,
12
+ Report,
13
+ ChartColumn,
14
+ Layers,
15
+ Play,
16
+ Settings,
17
+ } from '@carbon/icons-react';
18
+
19
+ import styles from './report-builder-shell.scss';
20
+
21
+ const ReportBuilderShell: React.FC = () => {
22
+ const navigate = useNavigate();
23
+ const location = useLocation();
24
+
25
+ const isActive = (path: string) =>
26
+ location.pathname === path || location.pathname.startsWith(`${path}/`);
27
+
28
+ return (
29
+ <div className={styles.shell}>
30
+ <aside className={styles.leftNav}>
31
+ <SideNav expanded isPersistent={false} aria-label="Report Builder navigation">
32
+ <SideNavItems>
33
+ <SideNavLink
34
+ renderIcon={Home}
35
+ isActive={location.pathname === '/'}
36
+ onClick={() => navigate('/')}
37
+ >
38
+ Home
39
+ </SideNavLink>
40
+
41
+ <SideNavLink
42
+ renderIcon={Report}
43
+ isActive={isActive('/reports') || isActive('/new') || isActive('/edit')}
44
+ onClick={() => navigate('/reports')}
45
+ >
46
+ Reports
47
+ </SideNavLink>
48
+
49
+ <SideNavLink
50
+ renderIcon={ChartColumn}
51
+ isActive={isActive('/indicators')}
52
+ onClick={() => navigate('/indicators')}
53
+ >
54
+ Indicators
55
+ </SideNavLink>
56
+
57
+ <SideNavLink
58
+ renderIcon={Layers}
59
+ isActive={isActive('/sections')}
60
+ onClick={() => navigate('/sections')}
61
+ >
62
+ Sections
63
+ </SideNavLink>
64
+
65
+ <SideNavLink
66
+ renderIcon={Play}
67
+ isActive={isActive('/run')}
68
+ onClick={() => navigate('/run')}
69
+ >
70
+ Run Reports
71
+ </SideNavLink>
72
+
73
+ <SideNavMenu
74
+ renderIcon={Settings}
75
+ title="Admin"
76
+ isActive={isActive('/admin')}
77
+ defaultExpanded={false}
78
+ >
79
+ <SideNavMenuItem
80
+ isActive={isActive('/admin/report-categories')}
81
+ onClick={() => navigate('/admin/report-categories')}
82
+ >
83
+ Report Categories
84
+ </SideNavMenuItem>
85
+
86
+ <SideNavMenuItem
87
+ isActive={isActive('/admin/age-categories')}
88
+ onClick={() => navigate('/admin/age-categories')}
89
+ >
90
+ Age Categories
91
+ </SideNavMenuItem>
92
+
93
+ <SideNavMenuItem
94
+ isActive={isActive('/admin/age-groups')}
95
+ onClick={() => navigate('/admin/age-groups')}
96
+ >
97
+ Age Groups
98
+ </SideNavMenuItem>
99
+ <SideNavMenuItem isActive={isActive('/admin/report-library')} onClick={() => navigate('/admin/report-library')}>
100
+ Report Library
101
+ </SideNavMenuItem>
102
+ <SideNavMenuItem isActive={isActive('/admin/themes')} onClick={() => navigate('/admin/themes')}>
103
+ Data Themes
104
+ </SideNavMenuItem>
105
+ <SideNavMenuItem isActive={isActive('/admin/etl-sources')} onClick={() => navigate('/admin/etl-sources')}>
106
+ ETL Sources
107
+ </SideNavMenuItem>
108
+ </SideNavMenu>
109
+ </SideNavItems>
110
+ </SideNav>
111
+ </aside>
112
+
113
+ <main className={styles.main}>
114
+ <Outlet />
115
+ </main>
116
+ </div>
117
+ );
118
+ };
119
+
120
+ export default ReportBuilderShell;
@@ -0,0 +1,15 @@
1
+ .shell {
2
+ display: grid;
3
+ grid-template-columns: 280px 1fr;
4
+ height: 100%;
5
+ min-height: calc(100vh - 3rem); // keeps it inside O3 header area
6
+ }
7
+
8
+ .leftNav {
9
+ border-right: 1px solid var(--cds-border-subtle, #e0e0e0);
10
+ background: var(--cds-layer-01, #f4f4f4);
11
+ }
12
+
13
+ .main {
14
+ overflow: auto;
15
+ }
@@ -0,0 +1,59 @@
1
+ import React from 'react';
2
+ import { TextInput, TextArea, Select, SelectItem, Stack } from '@carbon/react';
3
+ import type { DataTheme, MainDataDomain } from '../../types/theme/data-theme.types';
4
+
5
+ type Props = {
6
+ value: DataTheme;
7
+ onChange: (next: DataTheme) => void;
8
+ };
9
+
10
+ const DOMAIN_OPTIONS: Array<{ value: MainDataDomain; label: string }> = [
11
+ { value: 'OBSERVATIONS', label: 'Observations' },
12
+ { value: 'TEST_ORDERS', label: 'Test Orders' },
13
+ { value: 'MEDICATION_ORDERS', label: 'Medication Orders' },
14
+ { value: 'APPOINTMENTS', label: 'Appointments' },
15
+ { value: 'MEDICATION_DISPENSE', label: 'Medication Dispense' },
16
+ ];
17
+
18
+ export default function DataThemeForm({ value, onChange }: Props) {
19
+ return (
20
+ <Stack gap={4}>
21
+ <TextInput
22
+ id="theme-name"
23
+ labelText="Theme Name"
24
+ value={value.name}
25
+ onChange={(e) => onChange({ ...value, name: (e.target as HTMLInputElement).value })}
26
+ />
27
+
28
+ <TextInput
29
+ id="theme-code"
30
+ labelText="Code"
31
+ value={value.code}
32
+ onChange={(e) => onChange({ ...value, code: (e.target as HTMLInputElement).value })}
33
+ />
34
+
35
+ <TextArea
36
+ id="theme-description"
37
+ labelText="Description"
38
+ value={value.description || ''}
39
+ onChange={(e) => onChange({ ...value, description: (e.target as HTMLTextAreaElement).value })}
40
+ />
41
+
42
+ <Select
43
+ id="theme-domain"
44
+ labelText="Main Data Domain"
45
+ value={value.domain}
46
+ onChange={(e) =>
47
+ onChange({
48
+ ...value,
49
+ domain: (e.target as HTMLSelectElement).value as MainDataDomain,
50
+ })
51
+ }
52
+ >
53
+ {DOMAIN_OPTIONS.map((x) => (
54
+ <SelectItem key={x.value} value={x.value} text={x.label} />
55
+ ))}
56
+ </Select>
57
+ </Stack>
58
+ );
59
+ }
@@ -0,0 +1,349 @@
1
+ import React from 'react';
2
+ import { Modal, Stack, InlineLoading, SideNav, SideNavItems, SideNavLink, Content } from '@carbon/react';
3
+
4
+ import DataThemeBasicsSection from './sections/data-theme-basics.section';
5
+ import DataThemeSourceSection from './sections/data-theme-source.section';
6
+ import DataThemeFieldsEditorSection from './sections/data-theme-fields-editor.section';
7
+ import DataThemeConditionsSection from './sections/data-theme-condition-columns.section';
8
+ import DataThemeMetadataSection, { type DataThemeMeta } from './sections/data-theme-metadata.section';
9
+ import DataThemePreviewSection from './sections/data-theme-preview.section';
10
+
11
+ import type { DataTheme, DataThemeConfig } from '../../types/theme/data-theme.types';
12
+ import { getSchemaTables, type SchemaTable } from '../../resources/theme/mamba-schema.api';
13
+ import { getMambaTableMeta, type TableColumn } from '../../resources/theme/mamba-table-meta.api';
14
+
15
+ type Props = {
16
+ open: boolean;
17
+ mode: 'create' | 'edit';
18
+ initial?: DataTheme | null;
19
+ onClose: () => void;
20
+ onSave: (payload: DataTheme) => void;
21
+ };
22
+
23
+ const defaultConfig: DataThemeConfig = {
24
+ sourceTable: '',
25
+ patientIdColumn: '',
26
+ dateColumn: '',
27
+ locationColumn: '',
28
+ fields: [],
29
+ conditions: [],
30
+ };
31
+
32
+ const defaultMeta: DataThemeMeta = {
33
+ icon: '',
34
+ color: '#0f62fe',
35
+ category: '',
36
+ order: 0,
37
+ beta: false,
38
+ descriptionShort: '',
39
+ allowedIndicatorKinds: ['BASE', 'FINAL'],
40
+ };
41
+
42
+ type PanelKey = 'basics' | 'source' | 'fields' | 'conditions' | 'metadata' | 'preview';
43
+
44
+ function toCode(name: string) {
45
+ return (name ?? '')
46
+ .toUpperCase()
47
+ .replace(/[^A-Z0-9]+/g, '_')
48
+ .replace(/^_+|_+$/g, '')
49
+ .slice(0, 30);
50
+ }
51
+
52
+ function safeParseJson<T>(raw: string | undefined | null, fallback: T): T {
53
+ try {
54
+ if (!raw) return fallback;
55
+ const parsed = JSON.parse(raw);
56
+ return (parsed ?? fallback) as T;
57
+ } catch {
58
+ return fallback;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Supports either:
64
+ * 1) configJson string parses to { ...configFields }
65
+ * 2) configJson string parses to { configJson: { ...configFields } }
66
+ *
67
+ * Also migrates legacy `conditionColumns` -> `conditions[]` when present AND conditions[] missing/empty.
68
+ */
69
+ function parseConfig(raw: string | undefined | null): DataThemeConfig {
70
+ const parsed = safeParseJson<any>(raw, defaultConfig);
71
+
72
+ const base =
73
+ parsed && typeof parsed === 'object' && parsed.configJson && typeof parsed.configJson === 'object'
74
+ ? parsed.configJson
75
+ : parsed;
76
+
77
+ const cfg: any = { ...defaultConfig, ...(base ?? {}) };
78
+
79
+ // normalize arrays defensively
80
+ if (!Array.isArray(cfg.fields)) cfg.fields = [];
81
+ if (!Array.isArray(cfg.conditions)) cfg.conditions = [];
82
+
83
+ // MIGRATION: old themes might have conditionColumns map
84
+ // Example: { "concept_id": "diagnosis_coded" }
85
+ // Only migrate if conditions[] is missing/empty (do NOT overwrite real conditions).
86
+ const hasLegacyMap = cfg.conditionColumns && typeof cfg.conditionColumns === 'object';
87
+ const hasNoConditions = !Array.isArray(cfg.conditions) || cfg.conditions.length === 0;
88
+
89
+ if (hasLegacyMap && hasNoConditions) {
90
+ const entries = Object.entries(cfg.conditionColumns as Record<string, string>);
91
+ cfg.conditions = entries.map(([key, column]) => {
92
+ const looksLikeConcept = key.toLowerCase().includes('concept');
93
+ return {
94
+ key,
95
+ label: key.replace(/_/g, ' '),
96
+ handler: looksLikeConcept ? 'CONCEPT_SEARCH' : 'TEXT',
97
+ column,
98
+ operator: looksLikeConcept ? 'IN' : 'EQUALS',
99
+ valueType: looksLikeConcept ? 'conceptId' : 'string',
100
+ };
101
+ });
102
+ }
103
+
104
+ // final guard
105
+ if (!Array.isArray(cfg.conditions)) cfg.conditions = [];
106
+ if (!Array.isArray(cfg.fields)) cfg.fields = [];
107
+
108
+ return cfg as DataThemeConfig;
109
+ }
110
+
111
+ /**
112
+ * Supports either:
113
+ * 1) metaJson = { "metaJson": { ...fields } }
114
+ * 2) metaJson = { ...fields }
115
+ */
116
+ function parseMeta(raw: string | undefined | null): DataThemeMeta {
117
+ const parsed = safeParseJson<any>(raw, defaultMeta);
118
+ if (parsed && typeof parsed === 'object' && parsed.metaJson && typeof parsed.metaJson === 'object') {
119
+ return { ...defaultMeta, ...parsed.metaJson };
120
+ }
121
+ return { ...defaultMeta, ...parsed };
122
+ }
123
+
124
+ function wrapMeta(meta: DataThemeMeta) {
125
+ return JSON.stringify({ metaJson: meta }, null, 2);
126
+ }
127
+
128
+ export default function DataThemeModal({ open, mode, initial, onClose, onSave }: Props) {
129
+ const [active, setActive] = React.useState<PanelKey>('basics');
130
+
131
+ const [name, setName] = React.useState('');
132
+ const [code, setCode] = React.useState('');
133
+ const [domain, setDomain] = React.useState<DataTheme['domain']>('OBSERVATIONS');
134
+ const [description, setDescription] = React.useState('');
135
+
136
+ const [config, setConfig] = React.useState<DataThemeConfig>(defaultConfig);
137
+ const [configJson, setConfigJson] = React.useState<string>(JSON.stringify(defaultConfig, null, 2));
138
+
139
+ const [meta, setMeta] = React.useState<DataThemeMeta>(defaultMeta);
140
+ const [metaJson, setMetaJson] = React.useState<string>(wrapMeta(defaultMeta));
141
+
142
+ // schema tables
143
+ const [, setTables] = React.useState<SchemaTable[]>([]);
144
+ const [loadingTables, setLoadingTables] = React.useState(false);
145
+ const [tablesError, setTablesError] = React.useState<string | null>(null);
146
+
147
+ // table meta
148
+ const [columns, setColumns] = React.useState<TableColumn[]>([]);
149
+ const [loadingCols, setLoadingCols] = React.useState(false);
150
+ const [colsError, setColsError] = React.useState<string | null>(null);
151
+
152
+ // init when open (ensures edit pre-populates EVERYTHING)
153
+ React.useEffect(() => {
154
+ if (!open) return;
155
+
156
+ setActive('basics');
157
+
158
+ if (initial) {
159
+ setName(initial.name ?? '');
160
+ setCode(initial.code ?? '');
161
+ setDomain(initial.domain ?? 'OBSERVATIONS');
162
+ setDescription(initial.description ?? '');
163
+
164
+ const parsedConfig = parseConfig(initial.configJson);
165
+ setConfig(parsedConfig);
166
+ setConfigJson(JSON.stringify(parsedConfig, null, 2));
167
+
168
+ const parsedMeta = parseMeta((initial as any).metaJson);
169
+ setMeta(parsedMeta);
170
+ setMetaJson(wrapMeta(parsedMeta));
171
+ } else {
172
+ setName('');
173
+ setCode('');
174
+ setDomain('OBSERVATIONS');
175
+ setDescription('');
176
+
177
+ setConfig(defaultConfig);
178
+ setConfigJson(JSON.stringify(defaultConfig, null, 2));
179
+
180
+ setMeta(defaultMeta);
181
+ setMetaJson(wrapMeta(defaultMeta));
182
+ }
183
+ }, [open, initial]);
184
+
185
+ // load tables when modal opens
186
+ React.useEffect(() => {
187
+ if (!open) return;
188
+
189
+ const ac = new AbortController();
190
+ setLoadingTables(true);
191
+ setTablesError(null);
192
+ setTables([]);
193
+
194
+ getSchemaTables(ac.signal)
195
+ .then((data) => setTables(data ?? []))
196
+ .catch((e) => {
197
+ if (e?.name !== 'AbortError') setTablesError(e?.message ?? 'Failed to load schema tables');
198
+ })
199
+ .finally(() => setLoadingTables(false));
200
+
201
+ return () => ac.abort();
202
+ }, [open]);
203
+
204
+ // load columns when table changes
205
+ React.useEffect(() => {
206
+ if (!open) return;
207
+
208
+ const table = config?.sourceTable;
209
+ if (!table) {
210
+ setColumns([]);
211
+ setColsError(null);
212
+ setLoadingCols(false);
213
+ return;
214
+ }
215
+
216
+ const ac = new AbortController();
217
+ setLoadingCols(true);
218
+ setColsError(null);
219
+ setColumns([]);
220
+
221
+ getMambaTableMeta(table, ac.signal)
222
+ .then((data) => setColumns(data ?? []))
223
+ .catch((e) => {
224
+ if (e?.name !== 'AbortError') setColsError(e?.message ?? 'Failed to load table columns');
225
+ })
226
+ .finally(() => setLoadingCols(false));
227
+
228
+ return () => ac.abort();
229
+ }, [open, config?.sourceTable]);
230
+
231
+ // keep configJson synced when user edits config
232
+ React.useEffect(() => {
233
+ setConfigJson(JSON.stringify(config ?? defaultConfig, null, 2));
234
+ }, [config]);
235
+
236
+ // keep metaJson synced when user edits meta
237
+ React.useEffect(() => {
238
+ setMetaJson(wrapMeta(meta ?? defaultMeta));
239
+ }, [meta]);
240
+
241
+ const schemaReady = open && !loadingTables && !tablesError;
242
+
243
+ const canSave =
244
+ schemaReady &&
245
+ Boolean(name.trim()) &&
246
+ Boolean((code.trim() || toCode(name)).trim()) &&
247
+ Boolean(domain) &&
248
+ Boolean(config?.sourceTable) &&
249
+ Boolean(config?.patientIdColumn) &&
250
+ Boolean(config?.dateColumn);
251
+
252
+ const save = () => {
253
+ if (!canSave) return;
254
+
255
+ const payload: DataTheme = {
256
+ ...(initial?.uuid ? { uuid: initial.uuid } : {}),
257
+ name: name.trim(),
258
+ code: (code.trim() || toCode(name)).trim(),
259
+ domain,
260
+ description: description.trim(),
261
+ configJson,
262
+ metaJson,
263
+ } as any;
264
+
265
+ onSave(payload);
266
+ };
267
+
268
+ if (!open) return null;
269
+
270
+ const NavLink = ({ id, label }: { id: PanelKey; label: string }) => (
271
+ <SideNavLink isActive={active === id} onClick={() => setActive(id)}>
272
+ {label}
273
+ </SideNavLink>
274
+ );
275
+
276
+ return (
277
+ <Modal
278
+ open={open}
279
+ modalHeading={mode === 'create' ? 'Create Data Theme' : 'Edit Data Theme'}
280
+ primaryButtonText={mode === 'create' ? 'Create' : 'Save'}
281
+ secondaryButtonText="Cancel"
282
+ onRequestClose={onClose}
283
+ onRequestSubmit={save}
284
+ primaryButtonDisabled={!canSave}
285
+ size="lg"
286
+ >
287
+ <Stack gap={6}>
288
+ {loadingTables ? <InlineLoading description="Loading schema tables…" /> : null}
289
+ {!loadingTables && tablesError ? (
290
+ <div style={{ color: 'var(--cds-text-error, #da1e28)' }}>
291
+ Failed to load schema tables: {tablesError}
292
+ </div>
293
+ ) : null}
294
+
295
+ {schemaReady ? (
296
+ <div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: '1rem' }}>
297
+ <div>
298
+ <SideNav expanded isPersistent={false} aria-label="Theme editor sections">
299
+ <SideNavItems>
300
+ <NavLink id="basics" label="Basics" />
301
+ <NavLink id="source" label="Source" />
302
+ <NavLink id="fields" label="Fields" />
303
+ <NavLink id="conditions" label="Conditions" />
304
+ <NavLink id="metadata" label="Metadata" />
305
+ <NavLink id="preview" label="Preview" />
306
+ </SideNavItems>
307
+ </SideNav>
308
+ </div>
309
+
310
+ <Content style={{ padding: 0 }}>
311
+ {active === 'basics' ? (
312
+ <DataThemeBasicsSection
313
+ value={{ name, code, domain, description }}
314
+ onChange={(next) => {
315
+ setName(next.name);
316
+ setCode(next.code);
317
+ setDomain(next.domain);
318
+ setDescription(next.description ?? '');
319
+ }}
320
+ />
321
+ ) : null}
322
+
323
+ {active === 'source' ? (
324
+ <>
325
+ <DataThemeSourceSection open={open} config={config} onChange={setConfig} />
326
+ {loadingCols ? <InlineLoading description="Loading columns…" /> : null}
327
+ {!loadingCols && colsError ? (
328
+ <div style={{ color: 'var(--cds-text-error, #da1e28)', marginTop: '0.5rem' }}>
329
+ Failed to load table columns: {colsError}
330
+ </div>
331
+ ) : null}
332
+ </>
333
+ ) : null}
334
+
335
+ {active === 'fields' ? (<DataThemeFieldsEditorSection config={config} onChange={setConfig} open={open} />) : null}
336
+
337
+ {active === 'conditions' ? (<DataThemeConditionsSection open={open} config={config} onChange={setConfig} columns={columns} loadingCols={loadingCols}/>) : null}
338
+
339
+ {active === 'metadata' ? (<DataThemeMetadataSection value={meta} onChange={setMeta} open={open} />) : null}
340
+
341
+ {active === 'preview' ? (<DataThemePreviewSection config={config} metaJson={metaJson} onConfigJson={setConfigJson}/>
342
+ ) : null}
343
+ </Content>
344
+ </div>
345
+ ) : null}
346
+ </Stack>
347
+ </Modal>
348
+ );
349
+ }
@@ -0,0 +1,132 @@
1
+ import React from 'react';
2
+ import { Button, Search, Stack } from '@carbon/react';
3
+ import { Add } from '@carbon/icons-react';
4
+ import { useTranslation } from 'react-i18next';
5
+
6
+ import DataThemesTable from './data-themes-table.component';
7
+ import DataThemeModal from './data-theme-modal.component';
8
+ import Header from '../shared/header/header.component';
9
+
10
+ import type { DataTheme, DataThemeRow } from '../../types/theme/data-theme.types';
11
+ import { listThemes, createTheme, updateTheme, deleteTheme, getTheme } from '../../resources/theme/data-theme.api';
12
+
13
+ export default function DataThemesPage() {
14
+ const { t } = useTranslation();
15
+ const [q, setQ] = React.useState('');
16
+ const [rows, setRows] = React.useState<DataThemeRow[]>([]);
17
+ const [loading, setLoading] = React.useState(false);
18
+ const [error, setError] = React.useState<string | null>(null);
19
+
20
+ const [open, setOpen] = React.useState(false);
21
+ const [mode, setMode] = React.useState<'create' | 'edit'>('create');
22
+ const [editing, setEditing] = React.useState<DataTheme | null>(null);
23
+
24
+ const load = React.useCallback(
25
+ (signal?: AbortSignal) => {
26
+ setLoading(true);
27
+ setError(null);
28
+
29
+ return listThemes(q, signal)
30
+ .then((data) => {
31
+ const next: DataThemeRow[] = (data ?? []).map((t) => ({
32
+ uuid: t.uuid ?? '',
33
+ name: t.name ?? '',
34
+ code: t.code ?? '',
35
+ domain: t.domain,
36
+ description: t.description,
37
+ retired: t.retired,
38
+ }));
39
+ setRows(next.filter((r) => Boolean(r.uuid)));
40
+ })
41
+ .catch((e) => setError(e?.message ?? 'Failed to load themes'))
42
+ .finally(() => setLoading(false));
43
+ },
44
+ [q],
45
+ );
46
+
47
+ React.useEffect(() => {
48
+ const ac = new AbortController();
49
+ load(ac.signal);
50
+ return () => ac.abort();
51
+ }, [load]);
52
+
53
+ const onCreate = () => {
54
+ setMode('create');
55
+ setEditing(null);
56
+ setOpen(true);
57
+ };
58
+
59
+ const onEdit = async (uuid: string) => {
60
+ try {
61
+ setMode('edit');
62
+ setError(null);
63
+ setLoading(true);
64
+
65
+ // ✅ fetch full record so modal can pre-populate Source/Fields/Metadata
66
+ const full = await getTheme(uuid);
67
+ setEditing(full);
68
+ setOpen(true);
69
+ } catch (e: any) {
70
+ setError(e?.message ?? 'Failed to load theme for editing');
71
+ } finally {
72
+ setLoading(false);
73
+ }
74
+ };
75
+
76
+ const onDelete = async (uuid: string) => {
77
+ await deleteTheme(uuid, false);
78
+ const ac = new AbortController();
79
+ await load(ac.signal);
80
+ };
81
+
82
+ const onSave = async (payload: DataTheme) => {
83
+ if (mode === 'create') {
84
+ await createTheme(payload);
85
+ } else {
86
+ if (!payload.uuid) throw new Error('Missing uuid for update');
87
+ await updateTheme(payload.uuid, payload);
88
+ }
89
+
90
+ setOpen(false);
91
+ const ac = new AbortController();
92
+ await load(ac.signal);
93
+ };
94
+
95
+ return (
96
+ <Stack gap={5}>
97
+ <Header
98
+ title={t('dataTheme', 'Data Themes')}
99
+ subtitle={t('dataThemeSubTitle', ' Define reusable data sources (mamba_* tables/views) for indicators and reporting.')}
100
+ />
101
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'end', gap: '1rem' }}>
102
+ <div>
103
+ </div>
104
+
105
+ <Button size="sm" kind="primary" renderIcon={Add} onClick={onCreate}>
106
+ Create Theme
107
+ </Button>
108
+ </div>
109
+
110
+ <Search
111
+ size="lg"
112
+ labelText="Search"
113
+ placeholder="Search themes…"
114
+ value={q}
115
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
116
+ />
117
+
118
+ {loading ? <div>Loading…</div> : null}
119
+ {!loading && error ? <div style={{ color: 'var(--cds-text-error, #da1e28)' }}>{error}</div> : null}
120
+
121
+ <DataThemesTable rows={rows} onEdit={onEdit} onDelete={onDelete} />
122
+
123
+ <DataThemeModal
124
+ open={open}
125
+ mode={mode}
126
+ initial={editing}
127
+ onClose={() => setOpen(false)}
128
+ onSave={onSave}
129
+ />
130
+ </Stack>
131
+ );
132
+ }