@openmrs/esm-reports-app 4.2.2-pre.356 → 4.2.2-pre.394

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 (140) hide show
  1. package/.turbo/turbo-build.log +17 -17
  2. package/dist/1104.js +1 -0
  3. package/dist/1104.js.map +1 -0
  4. package/dist/1119.js +1 -1
  5. package/dist/1197.js +1 -1
  6. package/dist/1650.js +1 -1
  7. package/dist/1650.js.map +1 -1
  8. package/dist/2146.js +1 -1
  9. package/dist/2252.js +2 -0
  10. package/dist/2252.js.map +1 -0
  11. package/dist/2690.js +1 -1
  12. package/dist/2820.js +1 -0
  13. package/dist/289.js +1 -0
  14. package/dist/289.js.map +1 -0
  15. package/dist/3099.js +1 -1
  16. package/dist/3203.js +2 -0
  17. package/dist/3203.js.map +1 -0
  18. package/dist/3584.js +1 -1
  19. package/dist/3715.js +1 -1
  20. package/dist/3877.js +1 -0
  21. package/dist/3877.js.map +1 -0
  22. package/dist/4055.js +1 -1
  23. package/dist/4132.js +1 -1
  24. package/dist/4300.js +1 -1
  25. package/dist/4335.js +1 -1
  26. package/dist/439.js +2 -0
  27. package/dist/439.js.map +1 -0
  28. package/dist/4584.js +1 -1
  29. package/dist/4618.js +1 -1
  30. package/dist/4652.js +1 -1
  31. package/dist/4944.js +1 -1
  32. package/dist/5173.js +1 -1
  33. package/dist/5241.js +1 -1
  34. package/dist/5442.js +1 -1
  35. package/dist/5661.js +1 -1
  36. package/dist/6022.js +1 -1
  37. package/dist/6468.js +1 -1
  38. package/dist/6589.js +1 -0
  39. package/dist/6679.js +1 -1
  40. package/dist/6840.js +1 -1
  41. package/dist/6859.js +1 -1
  42. package/dist/7097.js +1 -1
  43. package/dist/7159.js +1 -1
  44. package/dist/723.js +1 -1
  45. package/dist/7617.js +1 -1
  46. package/dist/795.js +1 -1
  47. package/dist/8163.js +1 -1
  48. package/dist/826.js +1 -1
  49. package/dist/826.js.map +1 -1
  50. package/dist/8349.js +1 -1
  51. package/dist/8371.js +1 -0
  52. package/dist/8390.js +1 -1
  53. package/dist/8488.js +2 -0
  54. package/dist/8488.js.map +1 -0
  55. package/dist/8618.js +1 -1
  56. package/dist/890.js +1 -1
  57. package/dist/9090.js +1 -1
  58. package/dist/9090.js.map +1 -1
  59. package/dist/9214.js +1 -1
  60. package/dist/9538.js +1 -1
  61. package/dist/9569.js +1 -1
  62. package/dist/986.js +1 -1
  63. package/dist/9879.js +1 -1
  64. package/dist/9895.js +1 -1
  65. package/dist/9900.js +1 -1
  66. package/dist/9913.js +1 -1
  67. package/dist/main.js +1 -1
  68. package/dist/main.js.map +1 -1
  69. package/dist/openmrs-esm-reports-app.js +1 -1
  70. package/dist/openmrs-esm-reports-app.js.buildmanifest.json +325 -234
  71. package/dist/routes.json +1 -1
  72. package/package.json +1 -1
  73. package/src/components/concept-search/concept-search-input.component.tsx +59 -0
  74. package/src/components/concept-search/concept-search-input.scss +13 -0
  75. package/src/components/concept-search/concept-search-input.test.tsx +119 -0
  76. package/src/components/concept-search/concept-search-results.scss +107 -0
  77. package/src/components/concept-search/concept-search-results.tsx +135 -0
  78. package/src/components/report-parameter-input.component.tsx +84 -19
  79. package/src/components/report-parameter.component.tsx +23 -0
  80. package/src/hooks/useConceptSearch.ts +20 -0
  81. package/src/hooks/useConceptSearchInput.test.ts +118 -0
  82. package/src/hooks/useConceptSearchInput.ts +65 -0
  83. package/translations/am.json +1 -0
  84. package/translations/ar.json +1 -0
  85. package/translations/ar_SY.json +1 -0
  86. package/translations/bn.json +1 -0
  87. package/translations/cs.json +87 -0
  88. package/translations/de.json +1 -0
  89. package/translations/en.json +1 -0
  90. package/translations/en_US.json +1 -0
  91. package/translations/es.json +1 -0
  92. package/translations/es_MX.json +1 -0
  93. package/translations/fr.json +1 -0
  94. package/translations/he.json +1 -0
  95. package/translations/hi.json +1 -0
  96. package/translations/hi_IN.json +1 -0
  97. package/translations/id.json +1 -0
  98. package/translations/it.json +1 -0
  99. package/translations/ka.json +1 -0
  100. package/translations/km.json +1 -0
  101. package/translations/ku.json +1 -0
  102. package/translations/ky.json +1 -0
  103. package/translations/lg.json +1 -0
  104. package/translations/ne.json +1 -0
  105. package/translations/pl.json +1 -0
  106. package/translations/pt.json +1 -0
  107. package/translations/pt_BR.json +1 -0
  108. package/translations/qu.json +1 -0
  109. package/translations/ro_RO.json +1 -0
  110. package/translations/ru_RU.json +1 -0
  111. package/translations/si.json +1 -0
  112. package/translations/sq.json +87 -0
  113. package/translations/sw.json +1 -0
  114. package/translations/sw_KE.json +1 -0
  115. package/translations/tr.json +1 -0
  116. package/translations/tr_TR.json +1 -0
  117. package/translations/uk.json +1 -0
  118. package/translations/uz.json +1 -0
  119. package/translations/uz@Latn.json +1 -0
  120. package/translations/uz_UZ.json +1 -0
  121. package/translations/vi.json +1 -0
  122. package/translations/zh.json +1 -0
  123. package/translations/zh_CN.json +1 -0
  124. package/translations/zh_TW.json +87 -0
  125. package/dist/1877.js +0 -1
  126. package/dist/1877.js.map +0 -1
  127. package/dist/2316.js +0 -2
  128. package/dist/2316.js.map +0 -1
  129. package/dist/2345.js +0 -2
  130. package/dist/2345.js.map +0 -1
  131. package/dist/2358.js +0 -1
  132. package/dist/2358.js.map +0 -1
  133. package/dist/5520.js +0 -2
  134. package/dist/5520.js.map +0 -1
  135. package/dist/8178.js +0 -2
  136. package/dist/8178.js.map +0 -1
  137. /package/dist/{2345.js.LICENSE.txt → 2252.js.LICENSE.txt} +0 -0
  138. /package/dist/{2316.js.LICENSE.txt → 3203.js.LICENSE.txt} +0 -0
  139. /package/dist/{5520.js.LICENSE.txt → 439.js.LICENSE.txt} +0 -0
  140. /package/dist/{8178.js.LICENSE.txt → 8488.js.LICENSE.txt} +0 -0
package/dist/routes.json CHANGED
@@ -1 +1 @@
1
- {"$schema":"https://json.openmrs.org/routes.schema.json","backendDependencies":{"fhir2":">=1.2","webservices.rest":">=2.2.0","reportingrest":">=1.15.0","reporting":">=1.27.0"},"extensions":[{"name":"admin-report-link","slot":"system-admin-page-card-link-slot","component":"reportsLink"},{"name":"run-report","component":"runReport"}],"pages":[{"component":"root","route":"reports"},{"component":"overview","route":"overview"},{"component":"scheduled-overview","route":"scheduled-overview"},{"component":"reports-data-overview","route":"reports-data-overview"}],"modals":[{"name":"cancel-report-modal","component":"cancelReportModal"}],"version":"4.2.2-pre.356"}
1
+ {"$schema":"https://json.openmrs.org/routes.schema.json","backendDependencies":{"fhir2":">=1.2","webservices.rest":">=2.2.0","reportingrest":">=1.15.0","reporting":">=1.27.0"},"extensions":[{"name":"admin-report-link","slot":"system-admin-page-card-link-slot","component":"reportsLink"},{"name":"run-report","component":"runReport"}],"pages":[{"component":"root","route":"reports"},{"component":"overview","route":"overview"},{"component":"scheduled-overview","route":"scheduled-overview"},{"component":"reports-data-overview","route":"reports-data-overview"}],"modals":[{"name":"cancel-report-modal","component":"cancelReportModal"}],"version":"4.2.2-pre.394"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-reports-app",
3
- "version": "4.2.2-pre.356",
3
+ "version": "4.2.2-pre.394",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Reports admin dashboard for 03",
6
6
  "browser": "dist/openmrs-esm-reports-app.js",
@@ -0,0 +1,59 @@
1
+ import React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Search } from '@carbon/react';
4
+ import ConceptSearchResults from './concept-search-results';
5
+ import { useConceptSearchInput } from '../../hooks/useConceptSearchInput';
6
+ import styles from './concept-search-input.scss';
7
+
8
+ interface ConceptSearchInputProps {
9
+ parameterName: string;
10
+ labelText?: string;
11
+ placeholder?: string;
12
+ onConceptSelect: (conceptUuid: string, conceptDisplay: string) => void;
13
+ }
14
+
15
+ const ConceptSearchInput: React.FC<ConceptSearchInputProps> = ({
16
+ parameterName,
17
+ labelText,
18
+ placeholder,
19
+ onConceptSelect,
20
+ }) => {
21
+ const { t } = useTranslation();
22
+
23
+ const {
24
+ searchInputRef,
25
+ searchTerm,
26
+ selectedConcept,
27
+ debouncedSearchTerm,
28
+ handleSearchTermChange,
29
+ focusAndClearSearchInput,
30
+ handleConceptSelect,
31
+ } = useConceptSearchInput({
32
+ parameterName,
33
+ onConceptSelect,
34
+ });
35
+
36
+ return (
37
+ <div className={styles.conceptSearchContainer}>
38
+ <Search
39
+ size="lg"
40
+ placeholder={placeholder || t('searchFieldPlaceholder', 'Search for a concept')}
41
+ labelText={labelText || t('searchFieldPlaceholder', 'Search for a concept')}
42
+ onChange={handleSearchTermChange}
43
+ ref={searchInputRef}
44
+ value={searchTerm}
45
+ />
46
+ {!selectedConcept && (
47
+ <div className={styles.conceptSearchResultsDropdown}>
48
+ <ConceptSearchResults
49
+ searchTerm={debouncedSearchTerm}
50
+ focusAndClearSearchInput={focusAndClearSearchInput}
51
+ onConceptSelect={handleConceptSelect}
52
+ />
53
+ </div>
54
+ )}
55
+ </div>
56
+ );
57
+ };
58
+
59
+ export default ConceptSearchInput;
@@ -0,0 +1,13 @@
1
+ @use '@carbon/layout';
2
+
3
+ .conceptSearchContainer {
4
+ position: relative;
5
+ }
6
+
7
+ .conceptSearchResultsDropdown {
8
+ position: absolute;
9
+ top: 100%;
10
+ left: 0;
11
+ right: 0;
12
+ z-index: 1000;
13
+ }
@@ -0,0 +1,119 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import ConceptSearchInput from './concept-search-input.component';
5
+
6
+ // Mock the dependencies
7
+ jest.mock('react-i18next', () => ({
8
+ useTranslation: () => ({
9
+ t: (key: string, defaultValue: string) => defaultValue,
10
+ }),
11
+ }));
12
+
13
+ jest.mock('@openmrs/esm-framework', () => ({
14
+ useDebounce: jest.fn((value) => value),
15
+ }));
16
+
17
+ jest.mock('./concept-search-results', () => {
18
+ return function MockConceptSearchResults({ searchTerm, onConceptSelect }: any) {
19
+ if (!searchTerm) return null;
20
+
21
+ return (
22
+ <div data-testid="concept-search-results">
23
+ <div
24
+ data-testid="concept-result"
25
+ onClick={() => onConceptSelect({ uuid: 'test-uuid', display: 'Test Concept' })}
26
+ >
27
+ Test Concept
28
+ </div>
29
+ </div>
30
+ );
31
+ };
32
+ });
33
+
34
+ describe('ConceptSearchInput', () => {
35
+ const mockOnConceptSelect = jest.fn();
36
+ const defaultProps = {
37
+ parameterName: 'testParameter',
38
+ onConceptSelect: mockOnConceptSelect,
39
+ };
40
+
41
+ beforeEach(() => {
42
+ jest.clearAllMocks();
43
+ });
44
+
45
+ it('should render search input with default placeholder', () => {
46
+ render(<ConceptSearchInput {...defaultProps} />);
47
+
48
+ expect(screen.getByRole('searchbox')).toBeInTheDocument();
49
+ expect(screen.getByRole('search', { name: 'Search for a concept' })).toBeInTheDocument();
50
+ });
51
+
52
+ it('should render search input with custom placeholder and label', () => {
53
+ render(<ConceptSearchInput {...defaultProps} placeholder="Custom placeholder" labelText="Custom label" />);
54
+
55
+ expect(screen.getByRole('searchbox')).toHaveAttribute('placeholder', 'Custom placeholder');
56
+ expect(screen.getByRole('search', { name: 'Custom placeholder' })).toBeInTheDocument();
57
+ expect(screen.getByLabelText('Custom label')).toBeInTheDocument();
58
+ });
59
+
60
+ it('should show search results when typing', async () => {
61
+ render(<ConceptSearchInput {...defaultProps} />);
62
+
63
+ const searchInput = screen.getByRole('searchbox');
64
+ fireEvent.change(searchInput, { target: { value: 'test' } });
65
+
66
+ await waitFor(() => {
67
+ expect(screen.getByTestId('concept-search-results')).toBeInTheDocument();
68
+ });
69
+ });
70
+
71
+ it('should call onConceptSelect when a concept is selected', async () => {
72
+ render(<ConceptSearchInput {...defaultProps} />);
73
+
74
+ const searchInput = screen.getByRole('searchbox');
75
+ fireEvent.change(searchInput, { target: { value: 'test' } });
76
+
77
+ await waitFor(() => {
78
+ expect(screen.getByTestId('concept-search-results')).toBeInTheDocument();
79
+ });
80
+
81
+ fireEvent.click(screen.getByTestId('concept-result'));
82
+
83
+ expect(mockOnConceptSelect).toHaveBeenCalledWith('test-uuid', 'Test Concept');
84
+ });
85
+
86
+ it('should hide search results after concept selection', async () => {
87
+ render(<ConceptSearchInput {...defaultProps} />);
88
+
89
+ const searchInput = screen.getByRole('searchbox');
90
+ fireEvent.change(searchInput, { target: { value: 'test' } });
91
+
92
+ await waitFor(() => {
93
+ expect(screen.getByTestId('concept-search-results')).toBeInTheDocument();
94
+ });
95
+
96
+ fireEvent.click(screen.getByTestId('concept-result'));
97
+
98
+ await waitFor(() => {
99
+ expect(screen.queryByTestId('concept-search-results')).not.toBeInTheDocument();
100
+ });
101
+ });
102
+
103
+ it('should update input value with selected concept display name', async () => {
104
+ render(<ConceptSearchInput {...defaultProps} />);
105
+
106
+ const searchInput = screen.getByRole('searchbox') as HTMLInputElement;
107
+ fireEvent.change(searchInput, { target: { value: 'test' } });
108
+
109
+ await waitFor(() => {
110
+ expect(screen.getByTestId('concept-search-results')).toBeInTheDocument();
111
+ });
112
+
113
+ fireEvent.click(screen.getByTestId('concept-result'));
114
+
115
+ await waitFor(() => {
116
+ expect(searchInput.value).toBe('Test Concept');
117
+ });
118
+ });
119
+ });
@@ -0,0 +1,107 @@
1
+ @use '@carbon/colors';
2
+ @use '@carbon/layout';
3
+ @use '@carbon/type';
4
+ @use '@openmrs/esm-styleguide/src/vars' as *;
5
+
6
+ .container {
7
+ position: relative;
8
+ z-index: 1000;
9
+ margin: layout.$spacing-03 0 0 0;
10
+ background: $ui-01;
11
+ border: 1px solid $ui-03;
12
+ border-radius: layout.$spacing-02;
13
+ box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.2);
14
+ max-height: 300px;
15
+ overflow-y: auto;
16
+ }
17
+
18
+ .searchResultsCount {
19
+ @include type.type-style('body-compact-01');
20
+ color: $text-02;
21
+ }
22
+
23
+ .conceptBasketSearchResultsHeader {
24
+ display: flex;
25
+ justify-content: space-between;
26
+ margin-bottom: layout.$spacing-03;
27
+ align-items: center;
28
+ padding: layout.$spacing-03 layout.$spacing-03 0 layout.$spacing-03;
29
+ border-bottom: 1px solid $ui-03;
30
+ padding-bottom: layout.$spacing-03;
31
+ }
32
+
33
+ .tabletSearchResultTile {
34
+ &:not(:last-of-type) {
35
+ margin-bottom: layout.$spacing-04;
36
+ }
37
+ }
38
+
39
+ .searchResultSkeletonWrapper {
40
+ margin: layout.$spacing-03 0;
41
+ padding: layout.$spacing-03;
42
+
43
+ :global(.cds--skeleton__text) {
44
+ margin: 0;
45
+ }
46
+
47
+ .searchResultCntSkeleton {
48
+ margin-right: layout.$spacing-07;
49
+ }
50
+
51
+ .skeletonTile {
52
+ display: flex;
53
+ align-items: center;
54
+ }
55
+ }
56
+
57
+ .emptyState {
58
+ margin: layout.$spacing-03;
59
+ display: flex;
60
+ justify-content: center;
61
+ align-items: center;
62
+ padding: layout.$spacing-05;
63
+ text-align: center;
64
+ background-color: $ui-01;
65
+ }
66
+
67
+ :global(.omrs-breakpoint-lt-small-desktop) .emptyState {
68
+ background-color: $ui-02;
69
+ }
70
+
71
+ .link {
72
+ color: $interactive-01;
73
+ text-decoration: underline;
74
+ cursor: pointer;
75
+ }
76
+
77
+ .resultsContainer {
78
+ padding: layout.$spacing-03;
79
+ }
80
+
81
+ .resultsContainer div[role='option'] {
82
+ margin: 0;
83
+ padding: layout.$spacing-03;
84
+ cursor: pointer;
85
+ border-radius: layout.$spacing-02;
86
+ @include type.type-style('body-compact-01');
87
+ }
88
+
89
+ .resultsContainer div[role='option']:hover,
90
+ .resultsContainer div[role='option']:focus {
91
+ background-color: $ui-03;
92
+ outline: 2px solid $interactive-01;
93
+ outline-offset: -2px;
94
+ }
95
+
96
+ .resultsContainer::-webkit-scrollbar-thumb {
97
+ border-radius: layout.$spacing-02;
98
+ }
99
+
100
+ .productiveHeading01 {
101
+ @include type.type-style('heading-compact-01');
102
+ margin-bottom: layout.$spacing-03;
103
+ }
104
+
105
+ .bodyShort01 {
106
+ @include type.type-style('body-compact-01');
107
+ }
@@ -0,0 +1,135 @@
1
+ import React from 'react';
2
+ import styles from './concept-search-results.scss';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { useLayoutType } from '@openmrs/esm-framework';
5
+ import { Button, ButtonSkeleton, SkeletonText, Tile } from '@carbon/react';
6
+ import classNames from 'classnames';
7
+ import { useConceptSearch } from '../../hooks/useConceptSearch';
8
+
9
+ export interface ConceptSearchResultsProps {
10
+ searchTerm: string;
11
+ focusAndClearSearchInput: () => void;
12
+ onConceptSelect?: (concept: { uuid: string; display?: string }) => void;
13
+ }
14
+
15
+ export default function ConceptSearchResults({
16
+ searchTerm,
17
+ focusAndClearSearchInput,
18
+ onConceptSelect,
19
+ }: ConceptSearchResultsProps) {
20
+ const { t } = useTranslation();
21
+ const isTablet = useLayoutType() === 'tablet';
22
+ const { concepts, isLoading, error } = useConceptSearch(searchTerm);
23
+
24
+ if (!searchTerm) {
25
+ return null;
26
+ }
27
+
28
+ if (isLoading) {
29
+ return <ConceptSearchSkeleton />;
30
+ }
31
+
32
+ if (error) {
33
+ return (
34
+ <Tile className={styles.emptyState}>
35
+ <div>
36
+ <h4 className={styles.productiveHeading01}>
37
+ {t('errorFetchingConceptResults', 'Error fetching results for "{{searchTerm}}"', {
38
+ searchTerm,
39
+ })}
40
+ </h4>
41
+ <p className={styles.bodyShort01}>
42
+ <span>{t('trySearchingAgain', 'Please try searching again')}</span>
43
+ </p>
44
+ </div>
45
+ </Tile>
46
+ );
47
+ }
48
+
49
+ if (concepts?.length === 0) {
50
+ return (
51
+ <Tile className={styles.emptyState}>
52
+ <div>
53
+ <h4 className={styles.productiveHeading01}>
54
+ {t('noResultsForConceptSearch', 'No results to display for "{{searchTerm}}"', {
55
+ searchTerm,
56
+ })}
57
+ </h4>
58
+ <p className={styles.bodyShort01}>
59
+ <span>{t('tryTo', 'Try to')}</span>{' '}
60
+ <span className={styles.link} role="link" tabIndex={0} onClick={focusAndClearSearchInput}>
61
+ {t('searchAgain', 'search again')}
62
+ </span>{' '}
63
+ <span>{t('usingADifferentTerm', 'using a different term')}</span>
64
+ </p>
65
+ </div>
66
+ </Tile>
67
+ );
68
+ }
69
+
70
+ return (
71
+ <div className={styles.container}>
72
+ <div className={styles.conceptBasketSearchResultsHeader}>
73
+ <span className={styles.searchResultsCount}>
74
+ {t('searchResultsMatchesForTerm', '{{count}} results for "{{searchTerm}}"', {
75
+ count: concepts?.length,
76
+ searchTerm,
77
+ })}
78
+ </span>
79
+ <Button kind="ghost" onClick={focusAndClearSearchInput} size={isTablet ? 'md' : 'sm'}>
80
+ {t('clearSearchResults', 'Clear Results')}
81
+ </Button>
82
+ </div>
83
+ <div
84
+ className={styles.resultsContainer}
85
+ role="listbox"
86
+ aria-label={t('conceptSearchResults', 'Concept search results')}
87
+ >
88
+ {concepts?.map((concept, index) => (
89
+ <div
90
+ key={concept.uuid}
91
+ role="option"
92
+ tabIndex={0}
93
+ aria-selected={false}
94
+ onClick={() => {
95
+ onConceptSelect?.(concept);
96
+ }}
97
+ onKeyDown={(e) => {
98
+ if (e.key === 'Enter' || e.key === ' ') {
99
+ e.preventDefault();
100
+ onConceptSelect?.(concept);
101
+ }
102
+ }}
103
+ style={{ cursor: 'pointer' }}
104
+ >
105
+ {concept.display}
106
+ </div>
107
+ ))}
108
+ </div>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ const ConceptSearchSkeleton = () => {
114
+ const isTablet = useLayoutType() === 'tablet';
115
+ const tileClassName = classNames({
116
+ [styles.tabletSearchResultTile]: isTablet,
117
+ [styles.desktopSearchResultTile]: !isTablet,
118
+ [styles.skeletonTile]: true,
119
+ });
120
+ const buttonSize = isTablet ? 'md' : 'sm';
121
+
122
+ return (
123
+ <div className={styles.searchResultSkeletonWrapper}>
124
+ <div className={styles.conceptBasketSearchResultsHeader}>
125
+ <SkeletonText className={styles.searchResultCntSkeleton} />
126
+ <ButtonSkeleton size={buttonSize} />
127
+ </div>
128
+ {[...Array(4)].map((_, index) => (
129
+ <Tile key={index} className={tileClassName}>
130
+ <SkeletonText />
131
+ </Tile>
132
+ ))}
133
+ </div>
134
+ );
135
+ };
@@ -1,8 +1,11 @@
1
- import React, { useCallback, useEffect, useState } from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { isEqual } from 'lodash-es';
3
- import { DatePicker, DatePickerInput, Select, SelectItem, TextInput } from '@carbon/react';
3
+ import { DatePicker, DatePickerInput, Select, SelectItem, TextInput, Search } from '@carbon/react';
4
4
  import { useLocations } from './reports.resource';
5
5
  import styles from './run-report/run-report-form.scss';
6
+ import { useTranslation } from 'react-i18next';
7
+ import { useDebounce } from '@openmrs/esm-framework';
8
+ import ConceptSearchResults from './concept-search/concept-search-results';
6
9
 
7
10
  interface ReportParameterInputProps {
8
11
  parameter: any;
@@ -21,8 +24,56 @@ function getInitialValue(parameter: any, value: any) {
21
24
  }
22
25
 
23
26
  const ReportParameterInput: React.FC<ReportParameterInputProps> = ({ parameter, value, onChange }) => {
27
+ const { t } = useTranslation();
24
28
  const { locations } = useLocations();
25
29
  const [valueInternal, setValueInternal] = useState<string | number>(getInitialValue(parameter, value));
30
+ const searchInputRef = useRef(null);
31
+ const [searchTerm, setSearchTerm] = useState('');
32
+ const [selectedConcept, setSelectedConcept] = useState<{ uuid: string; display?: string } | null>(null);
33
+ const debouncedSearchTerm = useDebounce(searchTerm, 300);
34
+
35
+ const focusAndClearSearchInput = useCallback(() => {
36
+ setSearchTerm('');
37
+ searchInputRef.current?.focus();
38
+ }, [setSearchTerm]);
39
+
40
+ const handleOnChange = useCallback(
41
+ (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
42
+ let eventValue = null;
43
+ const target = event.target as HTMLInputElement | HTMLSelectElement;
44
+ if ((target as HTMLInputElement).type === 'checkbox') {
45
+ eventValue = (target as HTMLInputElement).checked;
46
+ } else {
47
+ eventValue = target.value;
48
+ }
49
+
50
+ setValueInternal(eventValue);
51
+
52
+ if (parameter.type === 'java.util.Date') {
53
+ onChange(new Date(eventValue).toLocaleDateString());
54
+ } else {
55
+ onChange(eventValue);
56
+ }
57
+ },
58
+ [onChange, parameter.type],
59
+ );
60
+
61
+ const handleConceptSelect = useCallback(
62
+ (concept: { uuid: string; display?: string }) => {
63
+ setSelectedConcept(concept);
64
+ setSearchTerm(concept.display || '');
65
+ if (handleOnChange) {
66
+ const syntheticEvent = {
67
+ target: {
68
+ name: parameter.name,
69
+ value: concept.uuid,
70
+ },
71
+ } as React.ChangeEvent<HTMLInputElement>;
72
+ handleOnChange(syntheticEvent);
73
+ }
74
+ },
75
+ [handleOnChange, parameter.name],
76
+ );
26
77
 
27
78
  const isValueEqual = useCallback(
28
79
  (valueA, valueB) => {
@@ -35,6 +86,17 @@ const ReportParameterInput: React.FC<ReportParameterInputProps> = ({ parameter,
35
86
  [parameter.type],
36
87
  );
37
88
 
89
+ const handleSearchTermChange = useCallback(
90
+ (event: React.ChangeEvent<HTMLInputElement>) => {
91
+ setSearchTerm(event.target.value ?? '');
92
+ // Clear selected concept when user starts typing again
93
+ if (selectedConcept) {
94
+ setSelectedConcept(null);
95
+ }
96
+ },
97
+ [setSearchTerm, selectedConcept],
98
+ );
99
+
38
100
  useEffect(() => {
39
101
  const newInternalValue = getInitialValue(parameter, value);
40
102
  setValueInternal((prevValue) => {
@@ -88,6 +150,26 @@ const ReportParameterInput: React.FC<ReportParameterInputProps> = ({ parameter,
88
150
  ))}
89
151
  </Select>
90
152
  );
153
+ case 'org.openmrs.Concept':
154
+ return (
155
+ <div>
156
+ <Search
157
+ size="lg"
158
+ placeholder={t('searchFieldPlaceholder', 'Search for a concept')}
159
+ labelText={t('searchFieldPlaceholder', 'Search for a concept')}
160
+ onChange={handleSearchTermChange}
161
+ ref={searchInputRef}
162
+ value={searchTerm}
163
+ />
164
+ {!selectedConcept && (
165
+ <ConceptSearchResults
166
+ searchTerm={debouncedSearchTerm}
167
+ focusAndClearSearchInput={focusAndClearSearchInput}
168
+ onConceptSelect={handleConceptSelect}
169
+ />
170
+ )}
171
+ </div>
172
+ );
91
173
  default:
92
174
  return (
93
175
  <span className={styles.unknownParameterTypeSpan}>
@@ -97,23 +179,6 @@ const ReportParameterInput: React.FC<ReportParameterInputProps> = ({ parameter,
97
179
  }
98
180
  };
99
181
 
100
- function handleOnChange(event) {
101
- let eventValue = null;
102
- if (event.target.type === 'checkbox') {
103
- eventValue = event.target.checked;
104
- } else {
105
- eventValue = event.target.value;
106
- }
107
-
108
- setValueInternal(eventValue);
109
-
110
- if (parameter.type === 'java.util.Date') {
111
- onChange(new Date(eventValue).toLocaleDateString());
112
- } else {
113
- onChange(eventValue);
114
- }
115
- }
116
-
117
182
  function handleOnDateChange(dateValue) {
118
183
  const newDate = new Date(dateValue);
119
184
  setValueInternal(newDate.toISOString());
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
3
3
  import { TextInput, Select, SelectItem } from '@carbon/react';
4
4
  import { OpenmrsDatePicker } from '@openmrs/esm-framework';
5
5
  import styles from './report-parameter.scss';
6
+ import ConceptSearchInput from './concept-search/concept-search-input.component';
6
7
 
7
8
  type DateReportParameter = 'java.util.Date';
8
9
  type InputReportParameter = 'java.lang.String' | 'java.lang.Integer';
@@ -42,6 +43,19 @@ const ReportParameter: React.FC<ReportParameterProps> = ({
42
43
  handleOnDateChange,
43
44
  }) => {
44
45
  const { t } = useTranslation();
46
+
47
+ const handleConceptSelect = (conceptUuid: string, conceptDisplay: string) => {
48
+ if (handleOnChange) {
49
+ const syntheticEvent = {
50
+ target: {
51
+ name: parameter.name,
52
+ value: conceptUuid,
53
+ },
54
+ } as React.ChangeEvent<HTMLInputElement>;
55
+ handleOnChange(syntheticEvent);
56
+ }
57
+ };
58
+
45
59
  switch (parameter.type) {
46
60
  case 'java.util.Date':
47
61
  return (
@@ -90,6 +104,15 @@ const ReportParameter: React.FC<ReportParameterProps> = ({
90
104
  </Select>
91
105
  </div>
92
106
  );
107
+ case 'org.openmrs.Concept':
108
+ return (
109
+ <ConceptSearchInput
110
+ parameterName={parameter.name}
111
+ labelText={parameter.label}
112
+ placeholder={t('searchFieldPlaceholder', 'Search for a concept')}
113
+ onConceptSelect={handleConceptSelect}
114
+ />
115
+ );
93
116
  default:
94
117
  return (
95
118
  <div>
@@ -0,0 +1,20 @@
1
+ import { type Concept, type FetchResponse, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
2
+ import { useMemo } from 'react';
3
+ import useSWRImmutable from 'swr/immutable';
4
+
5
+ export function useConceptSearch(query: string) {
6
+ const { data, ...rest } = useSWRImmutable<FetchResponse<{ results: Array<Concept> }>, Error>(
7
+ query ? `${restBaseUrl}/concept?q=${query}&v=full` : null,
8
+ openmrsFetch,
9
+ );
10
+
11
+ const results = useMemo(
12
+ () => ({
13
+ concepts: data?.data?.results,
14
+ ...rest,
15
+ }),
16
+ [data, rest],
17
+ );
18
+
19
+ return results;
20
+ }