@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,91 @@
1
+ import React from 'react';
2
+ import {
3
+ DataTable,
4
+ Table,
5
+ TableHead,
6
+ TableRow,
7
+ TableHeader,
8
+ TableBody,
9
+ TableCell,
10
+ TableContainer,
11
+ TableToolbar,
12
+ TableToolbarContent,
13
+ OverflowMenu,
14
+ OverflowMenuItem,
15
+ } from '@carbon/react';
16
+
17
+ import type { DataThemeRow } from '../../types/theme/data-theme.types';
18
+
19
+ type Props = {
20
+ rows: DataThemeRow[];
21
+ onEdit: (uuid: string) => void;
22
+ onDelete: (uuid: string) => Promise<void> | void;
23
+ };
24
+
25
+ const headers = [
26
+ { key: 'name', header: 'Name' },
27
+ { key: 'code', header: 'Code' },
28
+ { key: 'domain', header: 'Domain' },
29
+ { key: 'description', header: 'Description' },
30
+ { key: 'actions', header: '' },
31
+ ];
32
+
33
+ export default function DataThemesTable({ rows, onEdit, onDelete }: Props) {
34
+ const tableRows = React.useMemo(
35
+ () =>
36
+ (rows ?? []).map((r) => ({
37
+ id: r.uuid,
38
+ name: r.name,
39
+ code: r.code,
40
+ domain: r.domain,
41
+ description: r.description ?? '',
42
+ actions: r.uuid,
43
+ })),
44
+ [rows],
45
+ );
46
+
47
+ return (
48
+ <DataTable rows={tableRows} headers={headers}>
49
+ {({ rows, headers, getHeaderProps, getRowProps, getTableProps, getTableContainerProps }) => (
50
+ <TableContainer title="Data Themes" description="Manage derived data themes used by the indicator builder." {...getTableContainerProps()}>
51
+ <TableToolbar>
52
+ <TableToolbarContent />
53
+ </TableToolbar>
54
+
55
+ <Table {...getTableProps()}>
56
+ <TableHead>
57
+ <TableRow>
58
+ {headers.map((h) => (
59
+ <TableHeader key={h.key} {...getHeaderProps({ header: h })}>
60
+ {h.header}
61
+ </TableHeader>
62
+ ))}
63
+ </TableRow>
64
+ </TableHead>
65
+
66
+ <TableBody>
67
+ {rows.map((row) => (
68
+ <TableRow key={row.id} {...getRowProps({ row })}>
69
+ {row.cells.map((cell) => {
70
+ if (cell.info.header === 'actions') {
71
+ return (
72
+ <TableCell key={cell.id}>
73
+ <OverflowMenu size="sm" ariaLabel="Theme actions">
74
+ <OverflowMenuItem itemText="Edit" onClick={() => onEdit(row.id)} />
75
+ <OverflowMenuItem hasDivider isDelete itemText="Delete" onClick={() => onDelete(row.id)} />
76
+ </OverflowMenu>
77
+ </TableCell>
78
+ );
79
+ }
80
+
81
+ return <TableCell key={cell.id}>{cell.value}</TableCell>;
82
+ })}
83
+ </TableRow>
84
+ ))}
85
+ </TableBody>
86
+ </Table>
87
+ </TableContainer>
88
+ )}
89
+ </DataTable>
90
+ );
91
+ }
@@ -0,0 +1,35 @@
1
+ import {
2
+ Report,
3
+ ChartColumn,
4
+ Medication,
5
+ Calendar,
6
+ Hospital,
7
+ Document,
8
+ Search,
9
+ Information,
10
+ Warning,
11
+ CheckmarkFilled,
12
+ List,
13
+ Tag,
14
+ } from '@carbon/icons-react';
15
+
16
+ export const themeIconOptions = [
17
+ { key: 'Report', label: 'Report', Icon: Report },
18
+ { key: 'ChartColumn', label: 'Chart', Icon: ChartColumn },
19
+ { key: 'Medication', label: 'Medication', Icon: Medication },
20
+ { key: 'Calendar', label: 'Appointments', Icon: Calendar },
21
+ { key: 'Hospital', label: 'Facility', Icon: Hospital },
22
+ { key: 'Document', label: 'Document', Icon: Document },
23
+ { key: 'Search', label: 'Search', Icon: Search },
24
+ { key: 'Information', label: 'Info', Icon: Information },
25
+ { key: 'Warning', label: 'Warning', Icon: Warning },
26
+ { key: 'CheckmarkFilled', label: 'Check', Icon: CheckmarkFilled },
27
+ { key: 'List', label: 'List', Icon: List },
28
+ { key: 'Tag', label: 'Tag', Icon: Tag },
29
+ ] as const;
30
+
31
+ export type ThemeIconKey = (typeof themeIconOptions)[number]['key'];
32
+
33
+ export function getThemeIcon(key?: string) {
34
+ return themeIconOptions.find((x) => x.key === key)?.Icon;
35
+ }
@@ -0,0 +1,63 @@
1
+ import React from 'react';
2
+ import { TextInput, TextArea, Select, SelectItem } from '@carbon/react';
3
+ import type { DataTheme, MainDataDomain } from '../../../types/theme/data-theme.types';
4
+
5
+ const DOMAINS: Array<{ value: MainDataDomain; text: string }> = [
6
+ { value: 'DIAGNOSIS', text: 'Diagnosis' },
7
+ { value: 'OBSERVATIONS', text: 'Observations' },
8
+ { value: 'TEST_ORDERS', text: 'Test Orders' },
9
+ { value: 'MEDICATION_ORDERS', text: 'Medication Orders' },
10
+ { value: 'APPOINTMENTS', text: 'Appointments' },
11
+ { value: 'MEDICATION_DISPENSE', text: 'Medication Dispense' },
12
+ ];
13
+
14
+ type Props = {
15
+ value: Pick<DataTheme, 'name' | 'description' | 'code' | 'domain'>;
16
+ onChange: (next: Props['value']) => void;
17
+ };
18
+
19
+ export default function DataThemeBasicsSection({ value, onChange }: Props) {
20
+ return (
21
+ <div>
22
+ <div style={{ fontWeight: 600, marginBottom: '0.75rem' }}>Basics</div>
23
+
24
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
25
+ <TextInput
26
+ id="theme-name"
27
+ labelText="Name"
28
+ value={value.name}
29
+ onChange={(e) => onChange({ ...value, name: (e.target as HTMLInputElement).value })}
30
+ />
31
+ <TextInput
32
+ id="theme-code"
33
+ labelText="Code"
34
+ helperText="Short unique code (e.g. DX_OPD)"
35
+ value={value.code}
36
+ onChange={(e) => onChange({ ...value, code: (e.target as HTMLInputElement).value })}
37
+ />
38
+ </div>
39
+
40
+ <div style={{ marginTop: '0.75rem' }}>
41
+ <Select
42
+ id="theme-domain"
43
+ labelText="Main domain"
44
+ value={value.domain}
45
+ onChange={(e) => onChange({ ...value, domain: (e.target as HTMLSelectElement).value as MainDataDomain })}
46
+ >
47
+ {DOMAINS.map((d) => (
48
+ <SelectItem key={d.value} value={d.value} text={d.text} />
49
+ ))}
50
+ </Select>
51
+ </div>
52
+
53
+ <div style={{ marginTop: '0.75rem' }}>
54
+ <TextArea
55
+ id="theme-desc"
56
+ labelText="Description"
57
+ value={value.description ?? ''}
58
+ onChange={(e) => onChange({ ...value, description: (e.target as HTMLTextAreaElement).value })}
59
+ />
60
+ </div>
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,397 @@
1
+ import React from 'react';
2
+ import { Button, ComboBox, Select, SelectItem, Stack, TextInput } from '@carbon/react';
3
+ import { Add, TrashCan } from '@carbon/icons-react';
4
+
5
+ import type {
6
+ DataThemeConfig,
7
+ ThemeCondition,
8
+ ConditionHandler,
9
+ ConditionOperator,
10
+ ConditionValueType,
11
+ } from '../../../types/theme/data-theme.types';
12
+
13
+ import { THEME_OPERATOR_OPTIONS } from '../../../types/condition-operators';
14
+
15
+ import type { TableColumn } from '../../../resources/theme/mamba-table-meta.api';
16
+
17
+ type Props = {
18
+ open: boolean;
19
+ config: DataThemeConfig;
20
+ onChange: (next: DataThemeConfig) => void;
21
+ columns: TableColumn[];
22
+ loadingCols?: boolean;
23
+ };
24
+
25
+ const HANDLERS: ConditionHandler[] = [
26
+ 'CONCEPT_SEARCH',
27
+ 'QUESTION_ANSWER_CONCEPT_SEARCH',
28
+ 'TEXT',
29
+ 'NUMBER',
30
+ 'DATE_RANGE',
31
+ 'BOOLEAN',
32
+ 'LOCATION_PICKER',
33
+ 'CODED_LIST',
34
+ ];
35
+
36
+ const OPERATORS: ConditionOperator[] = THEME_OPERATOR_OPTIONS as unknown as ConditionOperator[];
37
+
38
+ const VALUE_TYPES: ConditionValueType[] = [
39
+ 'conceptUuid',
40
+ 'conceptId',
41
+ 'string',
42
+ 'number',
43
+ 'date',
44
+ 'datetime',
45
+ 'boolean',
46
+ ];
47
+
48
+ type ColumnMode = 'pick' | 'custom' | 'qa';
49
+
50
+ type UiConditionRow = ThemeCondition & {
51
+ _id: string;
52
+ _columnMode: ColumnMode;
53
+ _qaQuestionCol?: string;
54
+ _qaAnswerCol?: string;
55
+ };
56
+
57
+ function makeId() {
58
+ return `${Date.now()}_${Math.random().toString(16).slice(2)}`;
59
+ }
60
+
61
+ function encodeQa(questionCol: string, answerCol: string) {
62
+ const q = (questionCol ?? '').trim();
63
+ const a = (answerCol ?? '').trim();
64
+ if (!q || !a) return '';
65
+ return `QA(${q},${a})`;
66
+ }
67
+
68
+ function parseQaEncoded(col: string | undefined | null) {
69
+ const raw = (col ?? '').trim();
70
+ const m = /^QA\(([^,]+),([^)]+)\)$/.exec(raw);
71
+ if (!m) return null;
72
+ return { questionCol: m[1].trim(), answerCol: m[2].trim() };
73
+ }
74
+
75
+ function toRows(conds: ThemeCondition[] | undefined, columnNames: string[]): UiConditionRow[] {
76
+ return (conds ?? []).map((c) => {
77
+ const qa = parseQaEncoded(c.column);
78
+ if (qa) {
79
+ return { ...c, _id: makeId(), _columnMode: 'qa', _qaQuestionCol: qa.questionCol, _qaAnswerCol: qa.answerCol };
80
+ }
81
+
82
+ if (c.handler === 'QUESTION_ANSWER_CONCEPT_SEARCH') {
83
+ return { ...c, _id: makeId(), _columnMode: 'qa', _qaQuestionCol: '', _qaAnswerCol: '' };
84
+ }
85
+
86
+ const inList = columnNames.includes(c.column);
87
+ return { ...c, _id: makeId(), _columnMode: inList ? 'pick' : 'custom' };
88
+ });
89
+ }
90
+
91
+ function stripRows(rows: UiConditionRow[]): ThemeCondition[] {
92
+ return rows.map((row) => {
93
+ const rest = { ...row };
94
+ delete rest._id;
95
+ delete rest._columnMode;
96
+ delete rest._qaQuestionCol;
97
+ delete rest._qaAnswerCol;
98
+ return rest;
99
+ });
100
+ }
101
+
102
+ function defaultRow(idx: number): UiConditionRow {
103
+ return {
104
+ _id: makeId(),
105
+ _columnMode: 'pick',
106
+ key: `cond_${idx + 1}`,
107
+ label: '',
108
+ handler: 'CONCEPT_SEARCH',
109
+ column: '',
110
+ operator: 'IN',
111
+ valueType: 'conceptUuid',
112
+ };
113
+ }
114
+
115
+ export default function DataThemeConditionsSection({ open, config, onChange, columns, loadingCols }: Props) {
116
+ const columnNames = React.useMemo(
117
+ () => (columns ?? []).map((c) => c?.name).filter(Boolean) as string[],
118
+ [columns],
119
+ );
120
+
121
+ const baseColumnOptions = React.useMemo(() => columnNames.map((name) => ({ id: name, label: name })), [columnNames]);
122
+
123
+ const columnOptions = React.useMemo(() => {
124
+ const specials = [
125
+ { id: '__CUSTOM__', label: 'Custom expression…' },
126
+ { id: '__QA__', label: 'Question + Answer…' },
127
+ ];
128
+ return [...specials, ...baseColumnOptions];
129
+ }, [baseColumnOptions]);
130
+
131
+ const qaColumnOptions = React.useMemo(() => baseColumnOptions, [baseColumnOptions]);
132
+
133
+ const lastCommittedSerializedRef = React.useRef<string>('');
134
+ const [rows, setRows] = React.useState<UiConditionRow[]>(() => toRows(config.conditions, columnNames));
135
+
136
+ const commit = React.useCallback(
137
+ (nextRows: UiConditionRow[]) => {
138
+ setRows(nextRows);
139
+
140
+ const stripped = stripRows(nextRows);
141
+ const serialized = JSON.stringify(stripped ?? []);
142
+ lastCommittedSerializedRef.current = serialized;
143
+
144
+ onChange({ ...config, conditions: stripped });
145
+ },
146
+ [config, onChange],
147
+ );
148
+
149
+ React.useEffect(() => {
150
+ if (!open) return;
151
+ const incoming = JSON.stringify(config.conditions ?? []);
152
+ if (incoming === lastCommittedSerializedRef.current) return;
153
+ lastCommittedSerializedRef.current = incoming;
154
+ setRows(toRows(config.conditions, columnNames));
155
+ }, [open, config.conditions, columnNames]);
156
+
157
+ React.useEffect(() => {
158
+ if (!open) return;
159
+ setRows((prev) =>
160
+ (prev ?? []).map((r) => {
161
+ if (r.handler === 'QUESTION_ANSWER_CONCEPT_SEARCH') {
162
+ const q = r._qaQuestionCol ?? '';
163
+ const a = r._qaAnswerCol ?? '';
164
+ return {
165
+ ...r,
166
+ _columnMode: 'qa',
167
+ column: encodeQa(q, a),
168
+ };
169
+ }
170
+
171
+ if (r._columnMode === 'pick' && r.column && !columnNames.includes(r.column)) {
172
+ return { ...r, _columnMode: 'custom' };
173
+ }
174
+ return r;
175
+ }),
176
+ );
177
+ }, [open, columnNames]);
178
+
179
+ const addRow = () => commit([...(rows ?? []), defaultRow(rows.length)]);
180
+ const updateRow = (id: string, patch: Partial<UiConditionRow>) =>
181
+ commit((rows ?? []).map((r) => (r._id === id ? { ...r, ...patch } : r)));
182
+ const removeRow = (id: string) => commit((rows ?? []).filter((r) => r._id !== id));
183
+
184
+ const canEdit = open && !loadingCols;
185
+
186
+ return (
187
+ <div>
188
+ <div style={{ fontWeight: 600, marginBottom: '0.75rem' }}>Conditions</div>
189
+
190
+ <div style={{ marginBottom: '0.75rem', opacity: 0.85, fontSize: '0.875rem' }}>
191
+ Define filterable fields for this theme. <b>Column/Expr</b> should be a real column where possible; use custom only when needed.
192
+ </div>
193
+
194
+ {rows.length === 0 ? <div style={{ marginBottom: '0.75rem', opacity: 0.8 }}>No conditions yet.</div> : null}
195
+
196
+ <div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1.4fr 1.2fr 2fr 1fr 1.1fr auto', gap: '0.75rem' }}>
197
+ <div style={{ fontWeight: 600 }}>Key</div>
198
+ <div style={{ fontWeight: 600 }}>Label</div>
199
+ <div style={{ fontWeight: 600 }}>Handler</div>
200
+ <div style={{ fontWeight: 600 }}>Column/Expr</div>
201
+ <div style={{ fontWeight: 600 }}>Operator</div>
202
+ <div style={{ fontWeight: 600 }}>Value type</div>
203
+ <div />
204
+
205
+ {rows.map((r) => {
206
+ const selectedItem =
207
+ r._columnMode === 'custom'
208
+ ? columnOptions.find((x) => x.id === '__CUSTOM__') ?? null
209
+ : r._columnMode === 'qa'
210
+ ? columnOptions.find((x) => x.id === '__QA__') ?? null
211
+ : columnOptions.find((x) => x.id === r.column) ?? null;
212
+
213
+ const selectedQuestionItem =
214
+ qaColumnOptions.find((x) => x.id === (r._qaQuestionCol ?? '')) ?? null;
215
+
216
+ const selectedAnswerItem =
217
+ qaColumnOptions.find((x) => x.id === (r._qaAnswerCol ?? '')) ?? null;
218
+
219
+ return (
220
+ <React.Fragment key={r._id}>
221
+ <TextInput
222
+ id={`theme-cond-key-${r._id}`}
223
+ labelText=""
224
+ hideLabel
225
+ value={r.key}
226
+ disabled={!open}
227
+ placeholder="concept_id"
228
+ onChange={(e) => updateRow(r._id, { key: (e.target as HTMLInputElement).value })}
229
+ />
230
+
231
+ <TextInput
232
+ id={`theme-cond-label-${r._id}`}
233
+ labelText=""
234
+ hideLabel
235
+ value={r.label}
236
+ disabled={!open}
237
+ placeholder="Diagnosis"
238
+ onChange={(e) => updateRow(r._id, { label: (e.target as HTMLInputElement).value })}
239
+ />
240
+
241
+ <Select
242
+ id={`theme-cond-handler-${r._id}`}
243
+ labelText=""
244
+ hideLabel
245
+ value={r.handler}
246
+ disabled={!canEdit}
247
+ onChange={(e) => {
248
+ const nextHandler = (e.target as HTMLSelectElement).value as ConditionHandler;
249
+
250
+ if (nextHandler === 'QUESTION_ANSWER_CONCEPT_SEARCH') {
251
+ updateRow(r._id, {
252
+ handler: nextHandler,
253
+ _columnMode: 'qa',
254
+ column: encodeQa(r._qaQuestionCol ?? '', r._qaAnswerCol ?? ''),
255
+ });
256
+ return;
257
+ }
258
+
259
+ updateRow(r._id, {
260
+ handler: nextHandler,
261
+ _columnMode: r._columnMode === 'qa' ? 'pick' : r._columnMode,
262
+ column: r._columnMode === 'qa' ? '' : r.column,
263
+ });
264
+ }}
265
+ >
266
+ {HANDLERS.map((h) => (
267
+ <SelectItem key={h} value={h} text={h} />
268
+ ))}
269
+ </Select>
270
+
271
+ <div>
272
+ <ComboBox
273
+ id={`theme-cond-col-${r._id}`}
274
+ titleText=""
275
+ items={columnOptions}
276
+ itemToString={(it) => (it ? it.label : '')}
277
+ selectedItem={selectedItem}
278
+ disabled={!canEdit || r.handler === 'QUESTION_ANSWER_CONCEPT_SEARCH'}
279
+ onChange={({ selectedItem }) => {
280
+ const id = selectedItem?.id;
281
+
282
+ if (id === '__CUSTOM__') {
283
+ updateRow(r._id, { _columnMode: 'custom', column: '' });
284
+ return;
285
+ }
286
+ if (id === '__QA__') {
287
+ updateRow(r._id, { _columnMode: 'qa', column: encodeQa(r._qaQuestionCol ?? '', r._qaAnswerCol ?? '') });
288
+ return;
289
+ }
290
+ if (typeof id === 'string' && id) {
291
+ updateRow(r._id, { _columnMode: 'pick', column: id });
292
+ }
293
+ }}
294
+ />
295
+
296
+ {r._columnMode === 'custom' && r.handler !== 'QUESTION_ANSWER_CONCEPT_SEARCH' ? (
297
+ <TextInput
298
+ id={`theme-cond-col-custom-${r._id}`}
299
+ labelText=""
300
+ hideLabel
301
+ value={r.column}
302
+ disabled={!canEdit}
303
+ placeholder="a.some_column"
304
+ onChange={(e) => updateRow(r._id, { column: (e.target as HTMLInputElement).value })}
305
+ style={{ marginTop: '0.5rem' }}
306
+ />
307
+ ) : null}
308
+
309
+ {r._columnMode === 'qa' || r.handler === 'QUESTION_ANSWER_CONCEPT_SEARCH' ? (
310
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', marginTop: '0.5rem' }}>
311
+ <ComboBox
312
+ id={`theme-cond-qa-q-${r._id}`}
313
+ titleText=""
314
+ items={qaColumnOptions}
315
+ itemToString={(it) => (it ? it.label : '')}
316
+ selectedItem={selectedQuestionItem}
317
+ disabled={!canEdit}
318
+ placeholder="Question column"
319
+ onChange={({ selectedItem }) => {
320
+ const q = selectedItem?.id ?? '';
321
+ const a = r._qaAnswerCol ?? '';
322
+ updateRow(r._id, {
323
+ _qaQuestionCol: q,
324
+ _columnMode: 'qa',
325
+ column: encodeQa(String(q), a),
326
+ });
327
+ }}
328
+ />
329
+ <ComboBox
330
+ id={`theme-cond-qa-a-${r._id}`}
331
+ titleText=""
332
+ items={qaColumnOptions}
333
+ itemToString={(it) => (it ? it.label : '')}
334
+ selectedItem={selectedAnswerItem}
335
+ disabled={!canEdit}
336
+ placeholder="Answer column"
337
+ onChange={({ selectedItem }) => {
338
+ const a = selectedItem?.id ?? '';
339
+ const q = r._qaQuestionCol ?? '';
340
+ updateRow(r._id, {
341
+ _qaAnswerCol: a,
342
+ _columnMode: 'qa',
343
+ column: encodeQa(q, String(a)),
344
+ });
345
+ }}
346
+ />
347
+ </div>
348
+ ) : null}
349
+ </div>
350
+
351
+ <Select
352
+ id={`theme-cond-op-${r._id}`}
353
+ labelText=""
354
+ hideLabel
355
+ value={r.operator}
356
+ disabled={!canEdit}
357
+ onChange={(e) => updateRow(r._id, { operator: (e.target as HTMLSelectElement).value as ConditionOperator })}
358
+ >
359
+ {OPERATORS.map((op) => (
360
+ <SelectItem key={op} value={op} text={op} />
361
+ ))}
362
+ </Select>
363
+
364
+ <Select
365
+ id={`theme-cond-vt-${r._id}`}
366
+ labelText=""
367
+ hideLabel
368
+ value={r.valueType}
369
+ disabled={!canEdit}
370
+ onChange={(e) => updateRow(r._id, { valueType: (e.target as HTMLSelectElement).value as ConditionValueType })}
371
+ >
372
+ {VALUE_TYPES.map((vt) => (
373
+ <SelectItem key={vt} value={vt} text={vt} />
374
+ ))}
375
+ </Select>
376
+
377
+ <Button
378
+ kind="ghost"
379
+ size="sm"
380
+ renderIcon={TrashCan}
381
+ iconDescription="Remove condition"
382
+ disabled={!canEdit}
383
+ onClick={() => removeRow(r._id)}
384
+ />
385
+ </React.Fragment>
386
+ );
387
+ })}
388
+ </div>
389
+
390
+ <Stack orientation="horizontal" gap={3} style={{ marginTop: '0.75rem' }}>
391
+ <Button kind="secondary" size="sm" renderIcon={Add} disabled={!canEdit} onClick={addRow}>
392
+ Add condition
393
+ </Button>
394
+ </Stack>
395
+ </div>
396
+ );
397
+ }