@kenyaemr/esm-admin-app 5.4.4-pre.297 → 5.4.4-pre.3

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 (124) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/100.js +1 -0
  3. package/dist/100.js.map +1 -0
  4. package/dist/1442.js +1 -0
  5. package/dist/1442.js.map +1 -0
  6. package/dist/1462.js +1 -1
  7. package/dist/1972.js +1 -1
  8. package/dist/2016.js +1 -1
  9. package/dist/2080.js +1 -1
  10. package/dist/2080.js.map +1 -1
  11. package/dist/2096.js +1 -1
  12. package/dist/2096.js.map +1 -1
  13. package/dist/2270.js +1 -1
  14. package/dist/2270.js.map +1 -1
  15. package/dist/2294.js +1 -1
  16. package/dist/2294.js.map +1 -1
  17. package/dist/2467.js +1 -0
  18. package/dist/2467.js.map +1 -0
  19. package/dist/257.js +1 -1
  20. package/dist/257.js.map +1 -1
  21. package/dist/2948.js +1 -1
  22. package/dist/3380.js +1 -0
  23. package/dist/3380.js.map +1 -0
  24. package/dist/3548.js +1 -1
  25. package/dist/3816.js +1 -1
  26. package/dist/4337.js +1 -1
  27. package/dist/4687.js +1 -0
  28. package/dist/4687.js.map +1 -0
  29. package/dist/4858.js +1 -1
  30. package/dist/487.js +1 -1
  31. package/dist/5297.js +1 -0
  32. package/dist/5297.js.map +1 -0
  33. package/dist/5910.js +1 -0
  34. package/dist/5910.js.map +1 -0
  35. package/dist/5940.js +1 -1
  36. package/dist/5940.js.map +1 -1
  37. package/dist/6253.js +1 -0
  38. package/dist/6253.js.map +1 -0
  39. package/dist/6455.js +1 -0
  40. package/dist/6455.js.map +1 -0
  41. package/dist/6456.js +1 -1
  42. package/dist/6925.js +1 -0
  43. package/dist/6925.js.map +1 -0
  44. package/dist/7607.js +1 -1
  45. package/dist/7737.js +1 -1
  46. package/dist/7739.js +1 -1
  47. package/dist/7866.js +1 -1
  48. package/dist/7866.js.map +1 -1
  49. package/dist/8206.js +1 -0
  50. package/dist/8206.js.map +1 -0
  51. package/dist/8376.js +1 -1
  52. package/dist/846.js +17 -0
  53. package/dist/846.js.map +1 -0
  54. package/dist/{4047.js → 8487.js} +1 -1
  55. package/dist/8487.js.map +1 -0
  56. package/dist/8528.js +1 -0
  57. package/dist/8528.js.map +1 -0
  58. package/dist/8727.js +1 -1
  59. package/dist/9182.js +1 -1
  60. package/dist/9446.js +1 -1
  61. package/dist/9585.js +1 -0
  62. package/dist/9585.js.map +1 -0
  63. package/dist/9647.js +1 -0
  64. package/dist/9647.js.map +1 -0
  65. package/dist/kenyaemr-esm-admin-app.js +3 -3
  66. package/dist/kenyaemr-esm-admin-app.js.buildmanifest.json +329 -306
  67. package/dist/kenyaemr-esm-admin-app.js.map +1 -1
  68. package/dist/main.js +3 -3
  69. package/dist/main.js.map +1 -1
  70. package/dist/routes.json +1 -1
  71. package/package.json +1 -1
  72. package/src/components/confirm-modal/confirmation-operation.test.tsx +8 -19
  73. package/src/components/dashboard/dashboard.component.tsx +4 -1
  74. package/src/components/empty-state/empty-state-log.test.tsx +3 -4
  75. package/src/components/global-property/dashboard/global-property-dashboard.component.tsx +23 -0
  76. package/src/components/global-property/dashboard/global-property-dashboard.scss +6 -0
  77. package/src/components/global-property/hooks/useGlobalProperty.ts +64 -0
  78. package/src/components/global-property/index.ts +14 -0
  79. package/src/components/global-property/modal/delete-global-property-modal.component.tsx +71 -0
  80. package/src/components/global-property/modal/delete-global-property-modal.test.tsx +131 -0
  81. package/src/components/global-property/table/global-property-table.component.tsx +249 -0
  82. package/src/components/global-property/table/global-property-table.scss +34 -0
  83. package/src/components/global-property/table/global-property-table.test.tsx +198 -0
  84. package/src/components/global-property/workspace/global-property-form-schema.ts +32 -0
  85. package/src/components/global-property/workspace/global-property.workspace.scss +40 -0
  86. package/src/components/global-property/workspace/global-property.workspace.test.tsx +172 -0
  87. package/src/components/global-property/workspace/global-property.workspace.tsx +260 -0
  88. package/src/components/logs-table/operation-log-table.component.tsx +87 -75
  89. package/src/components/logs-table/operation-log.test.tsx +134 -28
  90. package/src/index.ts +9 -1
  91. package/src/root.component.tsx +2 -0
  92. package/src/routes.json +20 -1
  93. package/src/types/index.ts +11 -2
  94. package/translations/am.json +39 -2
  95. package/translations/en.json +62 -24
  96. package/translations/fr.json +167 -128
  97. package/translations/sw.json +311 -275
  98. package/dist/12.js +0 -17
  99. package/dist/12.js.map +0 -1
  100. package/dist/1201.js +0 -1
  101. package/dist/1201.js.map +0 -1
  102. package/dist/2402.js +0 -1
  103. package/dist/2402.js.map +0 -1
  104. package/dist/3366.js +0 -1
  105. package/dist/3366.js.map +0 -1
  106. package/dist/3852.js +0 -1
  107. package/dist/3852.js.map +0 -1
  108. package/dist/4047.js.map +0 -1
  109. package/dist/552.js +0 -1
  110. package/dist/552.js.map +0 -1
  111. package/dist/6092.js +0 -1
  112. package/dist/6092.js.map +0 -1
  113. package/dist/6399.js +0 -1
  114. package/dist/6399.js.map +0 -1
  115. package/dist/6676.js +0 -1
  116. package/dist/6676.js.map +0 -1
  117. package/dist/6976.js +0 -1
  118. package/dist/6976.js.map +0 -1
  119. package/dist/7916.js +0 -1
  120. package/dist/7916.js.map +0 -1
  121. package/dist/9406.js +0 -1
  122. package/dist/9406.js.map +0 -1
  123. package/dist/9711.js +0 -1
  124. package/dist/9711.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kenyaemr/esm-admin-app",
3
- "version": "5.4.4-pre.297",
3
+ "version": "5.4.4-pre.3",
4
4
  "description": "Facilitates the management of ETL tables",
5
5
  "keywords": [
6
6
  "openmrs"
@@ -1,24 +1,13 @@
1
1
  import React from 'react';
2
2
  import { render, screen, fireEvent } from '@testing-library/react';
3
3
  import '@testing-library/jest-dom';
4
- import { useTranslation } from 'react-i18next';
4
+ import { vi, describe, it, expect } from 'vitest';
5
5
  import OperationConfirmation from './confirmation-operation-modal.component';
6
6
 
7
- jest.mock('react-i18next', () => ({
8
- useTranslation: jest.fn(),
9
- }));
10
-
11
7
  describe('OperationConfirmation', () => {
12
- beforeEach(() => {
13
- (useTranslation as jest.Mock).mockReturnValue({
14
- t: (key, defaultValue, options) =>
15
- options?.operationTypeOrName ? `Do you want to ${options.operationTypeOrName}?` : defaultValue || key,
16
- });
17
- });
18
-
19
8
  it('renders the component with provided props', () => {
20
- const closeMock = jest.fn();
21
- const confirmMock = jest.fn();
9
+ const closeMock = vi.fn();
10
+ const confirmMock = vi.fn();
22
11
  const operationName = 'refresh';
23
12
  const operationType = 'refreshed';
24
13
 
@@ -32,14 +21,14 @@ describe('OperationConfirmation', () => {
32
21
  );
33
22
 
34
23
  expect(screen.getByRole('heading', { name: 'Confirmation' })).toBeInTheDocument();
35
- expect(screen.getByText('Do you want to refreshed?')).toBeInTheDocument();
24
+ expect(screen.getByText('Do you want to {{operationTypeOrName}}?')).toBeInTheDocument();
36
25
  expect(screen.getByText('No')).toBeInTheDocument();
37
26
  expect(screen.getByText('Yes')).toBeInTheDocument();
38
27
  });
39
28
 
40
29
  it('calls close when the No button is clicked', () => {
41
- const closeMock = jest.fn();
42
- const confirmMock = jest.fn();
30
+ const closeMock = vi.fn();
31
+ const confirmMock = vi.fn();
43
32
 
44
33
  render(
45
34
  <OperationConfirmation close={closeMock} confirm={confirmMock} operationName="delete" operationType="deleted" />,
@@ -53,8 +42,8 @@ describe('OperationConfirmation', () => {
53
42
  });
54
43
 
55
44
  it('calls confirm when the Yes button is clicked', () => {
56
- const closeMock = jest.fn();
57
- const confirmMock = jest.fn();
45
+ const closeMock = vi.fn();
46
+ const confirmMock = vi.fn();
58
47
 
59
48
  render(
60
49
  <OperationConfirmation close={closeMock} confirm={confirmMock} operationName="delete" operationType="deleted" />,
@@ -12,6 +12,7 @@ import {
12
12
  } from '../logs-table/operation-log-resource';
13
13
  import LogTable from '../logs-table/operation-log-table.component';
14
14
  import { showModal, showSnackbar } from '@openmrs/esm-framework';
15
+ import { extractErrorMessagesFromResponse } from '../locations/helpers';
15
16
 
16
17
  const Dashboard: React.FC = () => {
17
18
  const { t } = useTranslation();
@@ -59,7 +60,9 @@ const Dashboard: React.FC = () => {
59
60
  } catch (error) {
60
61
  showSnackbar({
61
62
  title: t('operationError', '{{operationName}} failed', { operationName }),
62
- subtitle: t('operationErrorSubtitle', 'An error occurred during the operation.'),
63
+ subtitle: t('operationErrorSubtitle', 'An error occurred during the operation. {{error}}', {
64
+ error: extractErrorMessagesFromResponse(error),
65
+ }),
63
66
  kind: 'error',
64
67
  isLowContrast: true,
65
68
  });
@@ -1,12 +1,11 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import '@testing-library/jest-dom';
3
- import { EmptyDataIllustration } from '@openmrs/esm-patient-common-lib';
3
+ import { vi, describe, it, expect } from 'vitest';
4
4
  import EmptyState from './empty-state-log.components';
5
5
  import React from 'react';
6
6
 
7
- // Mock the EmptyDataIllustration component
8
- jest.mock('@openmrs/esm-patient-common-lib', () => ({
9
- EmptyDataIllustration: jest.fn(() => <div>Mocked EmptyDataIllustration</div>),
7
+ vi.mock('@openmrs/esm-patient-common-lib', () => ({
8
+ EmptyDataIllustration: vi.fn(() => <div>Mocked EmptyDataIllustration</div>),
10
9
  }));
11
10
 
12
11
  describe('EmptyState', () => {
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import { PageHeader, FacilityPictogram } from '@openmrs/esm-framework';
3
+ import { useTranslation } from 'react-i18next';
4
+
5
+ import GlobalPropertyTable from '../table/global-property-table.component';
6
+ import styles from './global-property-dashboard.scss';
7
+
8
+ const GlobalPropertyDashboard: React.FC = () => {
9
+ const { t } = useTranslation();
10
+
11
+ return (
12
+ <div>
13
+ <PageHeader
14
+ className={styles.pageHeader}
15
+ title={t('globalProperty', 'Global Property')}
16
+ illustration={<FacilityPictogram />}
17
+ />
18
+ <GlobalPropertyTable />
19
+ </div>
20
+ );
21
+ };
22
+
23
+ export default GlobalPropertyDashboard;
@@ -0,0 +1,6 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/colors';
3
+
4
+ .pageHeader {
5
+ border-bottom: 1px solid colors.$gray-10;
6
+ }
@@ -0,0 +1,64 @@
1
+ import { openmrsFetch, restBaseUrl, useOpenmrsPagination } from '@openmrs/esm-framework';
2
+
3
+ import { type SystemSettings } from '../../../types';
4
+
5
+ export type GlobalPropertyPayload = {
6
+ property: string;
7
+ value: string;
8
+ description?: string | null;
9
+ datatypeClassname?: string | null;
10
+ datatypeConfig?: string | null;
11
+ preferredHandlerClassname?: string | null;
12
+ handlerConfig?: string | null;
13
+ };
14
+
15
+ export async function saveOrUpdateGlobalProperty(payload: GlobalPropertyPayload, uuid?: string): Promise<void> {
16
+ const url = uuid ? `${restBaseUrl}/systemsetting/${uuid}` : `${restBaseUrl}/systemsetting`;
17
+ const body: Record<string, unknown> = {
18
+ property: payload.property,
19
+ value: payload.value,
20
+ };
21
+ if (payload.description) {
22
+ body.description = payload.description;
23
+ }
24
+ if (payload.datatypeClassname) {
25
+ body.datatypeClassname = payload.datatypeClassname;
26
+ }
27
+ if (payload.datatypeConfig) {
28
+ body.datatypeConfig = payload.datatypeConfig;
29
+ }
30
+ if (payload.preferredHandlerClassname) {
31
+ body.preferredHandlerClassname = payload.preferredHandlerClassname;
32
+ }
33
+ if (payload.handlerConfig != null) {
34
+ body.handlerConfig = payload.handlerConfig;
35
+ }
36
+
37
+ const response = await openmrsFetch(url, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify(body),
41
+ });
42
+
43
+ if (!response.ok) {
44
+ const errorData = await response.json().catch(() => ({}));
45
+ throw new Error(errorData?.error?.message ?? 'Failed to save global property');
46
+ }
47
+ }
48
+
49
+ export async function deleteGlobalProperty(uuid: string): Promise<void> {
50
+ const response = await openmrsFetch(`${restBaseUrl}/systemsetting/${uuid}?purge=true`, {
51
+ method: 'DELETE',
52
+ });
53
+ if (!response.ok) {
54
+ const errorData = await response.json().catch(() => ({}));
55
+ throw new Error(errorData?.error?.message ?? 'Failed to delete global property');
56
+ }
57
+ }
58
+
59
+ export function useGlobalProperties(pageSize: number = 10, searchTerm?: string) {
60
+ const searchQuery = searchTerm ? `&q=${encodeURIComponent(searchTerm)}` : '';
61
+ const baseUrl = `${restBaseUrl}/systemsetting?v=default`;
62
+ const url = searchTerm ? `${baseUrl}${searchQuery}` : baseUrl;
63
+ return useOpenmrsPagination<SystemSettings>(url, pageSize);
64
+ }
@@ -0,0 +1,14 @@
1
+ import { getAsyncLifecycle, getSyncLifecycle } from '@openmrs/esm-framework';
2
+ import { createLeftPanelLink } from '../../left-pannel-link.component';
3
+ import { moduleName } from '../../constants';
4
+
5
+ // t('globalProperty','Global property')
6
+ export const globalPropertyLink = getSyncLifecycle(
7
+ createLeftPanelLink({ title: 'globalProperty', name: 'global-property' }),
8
+ { moduleName, featureName: 'global-property' },
9
+ );
10
+
11
+ export const globalPropertyDashboard = getAsyncLifecycle(
12
+ () => import('./dashboard/global-property-dashboard.component'),
13
+ { moduleName, featureName: 'global-property' },
14
+ );
@@ -0,0 +1,71 @@
1
+ import React, { useState } from 'react';
2
+ import { Button, InlineLoading } from '@carbon/react';
3
+ import { showSnackbar } from '@openmrs/esm-framework';
4
+ import { useTranslation } from 'react-i18next';
5
+
6
+ import { deleteGlobalProperty } from '../hooks/useGlobalProperty';
7
+
8
+ interface DeleteGlobalPropertyModalProps {
9
+ close: () => void;
10
+ property: string;
11
+ uuid: string;
12
+ onDeleted: () => void;
13
+ }
14
+
15
+ const DeleteGlobalPropertyModal: React.FC<DeleteGlobalPropertyModalProps> = ({ close, property, uuid, onDeleted }) => {
16
+ const { t } = useTranslation();
17
+ const [isDeleting, setIsDeleting] = useState(false);
18
+
19
+ const handleDelete = async () => {
20
+ setIsDeleting(true);
21
+ try {
22
+ await deleteGlobalProperty(uuid);
23
+ showSnackbar({
24
+ isLowContrast: true,
25
+ kind: 'success',
26
+ title: t('gpDeleted', 'Global property deleted'),
27
+ subtitle: t('gpDeletedSubtitle', '{{property}} was deleted successfully.', { property }),
28
+ });
29
+ onDeleted();
30
+ close();
31
+ } catch (error) {
32
+ showSnackbar({
33
+ kind: 'error',
34
+ title: t('gpDeleteError', 'Error deleting global property'),
35
+ subtitle: error?.message,
36
+ });
37
+ } finally {
38
+ setIsDeleting(false);
39
+ }
40
+ };
41
+
42
+ return (
43
+ <>
44
+ <div className="cds--modal-header">
45
+ <h3 className="cds--modal-header__heading">{t('deleteGlobalProperty', 'Delete global property')}</h3>
46
+ </div>
47
+ <div className="cds--modal-content">
48
+ <p>
49
+ {t(
50
+ 'deleteGlobalPropertyConfirmation',
51
+ 'Are you sure you want to delete the global property "{{property}}"?',
52
+ {
53
+ property,
54
+ },
55
+ )}
56
+ </p>
57
+ <p>{t('deleteGlobalPropertyWarning', 'This action cannot be undone.')}</p>
58
+ </div>
59
+ <div className="cds--modal-footer">
60
+ <Button kind="secondary" onClick={close} disabled={isDeleting}>
61
+ {t('cancel', 'Cancel')}
62
+ </Button>
63
+ <Button kind="danger" onClick={handleDelete} disabled={isDeleting}>
64
+ {isDeleting ? <InlineLoading description={t('deleting', 'Deleting...')} /> : t('delete', 'Delete')}
65
+ </Button>
66
+ </div>
67
+ </>
68
+ );
69
+ };
70
+
71
+ export default DeleteGlobalPropertyModal;
@@ -0,0 +1,131 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
5
+ import DeleteGlobalPropertyModal from './delete-global-property-modal.component';
6
+
7
+ const mockShowSnackbar = vi.fn();
8
+ const mockDeleteGlobalProperty = vi.fn();
9
+
10
+ vi.mock('react-i18next', () => ({
11
+ useTranslation: () => ({ t: (_key: string, fallback: string, opts?: Record<string, unknown>) => fallback }),
12
+ }));
13
+
14
+ vi.mock('@openmrs/esm-framework', () => ({
15
+ showSnackbar: (...args: unknown[]) => mockShowSnackbar(...args),
16
+ }));
17
+
18
+ vi.mock('../hooks/useGlobalProperty', () => ({
19
+ deleteGlobalProperty: (...args: unknown[]) => mockDeleteGlobalProperty(...args),
20
+ }));
21
+
22
+ describe('DeleteGlobalPropertyModal', () => {
23
+ const defaultProps = {
24
+ close: vi.fn(),
25
+ property: 'setting.example',
26
+ uuid: 'uuid-abc',
27
+ onDeleted: vi.fn(),
28
+ };
29
+
30
+ beforeEach(() => {
31
+ vi.clearAllMocks();
32
+ });
33
+
34
+ it('displays the modal heading', () => {
35
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
36
+ expect(screen.getByText('Delete global property')).toBeInTheDocument();
37
+ });
38
+
39
+ it('shows the property name in the confirmation message', () => {
40
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
41
+ expect(screen.getByText('Are you sure you want to delete the global property "{{property}}"?')).toBeInTheDocument();
42
+ });
43
+
44
+ it('shows a warning that the action cannot be undone', () => {
45
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
46
+ expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument();
47
+ });
48
+
49
+ it('calls close when the Cancel button is clicked', () => {
50
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
51
+ fireEvent.click(screen.getByText('Cancel'));
52
+ expect(defaultProps.close).toHaveBeenCalledTimes(1);
53
+ });
54
+
55
+ it('does not call onDeleted when Cancel is clicked', () => {
56
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
57
+ fireEvent.click(screen.getByText('Cancel'));
58
+ expect(defaultProps.onDeleted).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it('calls deleteGlobalProperty with the correct uuid when Delete is clicked', async () => {
62
+ mockDeleteGlobalProperty.mockResolvedValue(undefined);
63
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
64
+ fireEvent.click(screen.getByText('Delete'));
65
+ await waitFor(() => {
66
+ expect(mockDeleteGlobalProperty).toHaveBeenCalledWith('uuid-abc');
67
+ });
68
+ });
69
+
70
+ it('shows a success snackbar and closes the modal after successful deletion', async () => {
71
+ mockDeleteGlobalProperty.mockResolvedValue(undefined);
72
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
73
+ fireEvent.click(screen.getByText('Delete'));
74
+ await waitFor(() => {
75
+ expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }));
76
+ expect(defaultProps.onDeleted).toHaveBeenCalledTimes(1);
77
+ expect(defaultProps.close).toHaveBeenCalledTimes(1);
78
+ });
79
+ });
80
+
81
+ it('shows an error snackbar when deletion fails', async () => {
82
+ mockDeleteGlobalProperty.mockRejectedValue(new Error('Server error'));
83
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
84
+ fireEvent.click(screen.getByText('Delete'));
85
+ await waitFor(() => {
86
+ expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' }));
87
+ expect(defaultProps.close).not.toHaveBeenCalled();
88
+ expect(defaultProps.onDeleted).not.toHaveBeenCalled();
89
+ });
90
+ });
91
+
92
+ it('disables both buttons while deletion is in progress', async () => {
93
+ let resolveDelete: () => void;
94
+ mockDeleteGlobalProperty.mockReturnValue(
95
+ new Promise<void>((res) => {
96
+ resolveDelete = res;
97
+ }),
98
+ );
99
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
100
+
101
+ fireEvent.click(screen.getByText('Delete'));
102
+
103
+ await waitFor(() => {
104
+ expect(screen.getByText('Cancel')).toBeDisabled();
105
+ });
106
+
107
+ await act(async () => {
108
+ resolveDelete!();
109
+ });
110
+ });
111
+
112
+ it('shows a loading indicator while deletion is in progress', async () => {
113
+ let resolveDelete: () => void;
114
+ mockDeleteGlobalProperty.mockReturnValue(
115
+ new Promise<void>((res) => {
116
+ resolveDelete = res;
117
+ }),
118
+ );
119
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
120
+
121
+ fireEvent.click(screen.getByText('Delete'));
122
+
123
+ await waitFor(() => {
124
+ expect(screen.getByText('Deleting...')).toBeInTheDocument();
125
+ });
126
+
127
+ await act(async () => {
128
+ resolveDelete!();
129
+ });
130
+ });
131
+ });
@@ -0,0 +1,249 @@
1
+ import React, { useCallback, useMemo, useState } from 'react';
2
+ import {
3
+ Button,
4
+ DataTable,
5
+ DataTableSkeleton,
6
+ Pagination,
7
+ Search,
8
+ Table,
9
+ TableBody,
10
+ TableCell,
11
+ TableHead,
12
+ TableHeader,
13
+ TableRow,
14
+ type DataTableHeader,
15
+ } from '@carbon/react';
16
+ import { Add, Edit, TrashCan } from '@carbon/react/icons';
17
+ import { useTranslation } from 'react-i18next';
18
+ import {
19
+ ErrorCard,
20
+ isDesktop,
21
+ launchWorkspace2,
22
+ showModal,
23
+ useDebounce,
24
+ useLayoutType,
25
+ usePaginationInfo,
26
+ } from '@openmrs/esm-framework';
27
+
28
+ import { useGlobalProperties } from '../hooks/useGlobalProperty';
29
+ import styles from './global-property-table.scss';
30
+
31
+ const DEFAULT_PAGE_SIZE = 10;
32
+ const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
33
+
34
+ type GlobalPropertyRow = {
35
+ id: string;
36
+ property: string;
37
+ value: string;
38
+ };
39
+
40
+ const GlobalPropertyTable: React.FC = () => {
41
+ const { t } = useTranslation();
42
+ const layoutType = useLayoutType();
43
+ const desktop = isDesktop(layoutType);
44
+
45
+ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
46
+ const [searchTerm, setSearchTerm] = useState('');
47
+ const debouncedSearchTerm = useDebounce(searchTerm, 300);
48
+
49
+ const {
50
+ isLoading,
51
+ data: globalProperties = [],
52
+ error,
53
+ goTo,
54
+ currentPage,
55
+ totalCount,
56
+ mutate,
57
+ } = useGlobalProperties(pageSize, debouncedSearchTerm);
58
+
59
+ const { pageSizes } = usePaginationInfo(DEFAULT_PAGE_SIZE, totalCount, currentPage, globalProperties.length);
60
+
61
+ const headers: Array<DataTableHeader> = useMemo(
62
+ () => [
63
+ { key: 'property', header: t('property', 'Property') },
64
+ { key: 'value', header: t('value', 'Value') },
65
+ ],
66
+ [t],
67
+ );
68
+
69
+ const rows: Array<GlobalPropertyRow> = useMemo(
70
+ () =>
71
+ globalProperties.map((gp, idx) => ({
72
+ id: gp?.uuid ?? `gp-${idx}`,
73
+ property: gp?.property ?? '',
74
+ value: gp?.value ?? '',
75
+ })),
76
+ [globalProperties],
77
+ );
78
+
79
+ const handleSearchChange = useCallback(
80
+ (event: React.ChangeEvent<HTMLInputElement>) => {
81
+ setSearchTerm(event.target.value);
82
+ if (currentPage !== 1) {
83
+ goTo(1);
84
+ }
85
+ },
86
+ [currentPage, goTo],
87
+ );
88
+
89
+ const handlePaginationChange = useCallback(
90
+ ({ page, pageSize: newSize }: { page: number; pageSize: number }) => {
91
+ if (newSize !== pageSize) {
92
+ setPageSize(newSize);
93
+ }
94
+ goTo(page);
95
+ },
96
+ [pageSize, goTo],
97
+ );
98
+
99
+ const openWorkspace = useCallback(
100
+ (systemSetting?: (typeof globalProperties)[number]) => {
101
+ launchWorkspace2('global-property-workspace', {
102
+ systemSetting,
103
+ mutateGlobalProperty: mutate,
104
+ });
105
+ },
106
+ [globalProperties, mutate],
107
+ );
108
+
109
+ const handleEdit = useCallback(
110
+ (row: GlobalPropertyRow) => {
111
+ const systemSetting = globalProperties.find((gp) => gp.uuid === row.id);
112
+ openWorkspace(systemSetting);
113
+ },
114
+ [globalProperties, openWorkspace],
115
+ );
116
+
117
+ const handleDelete = useCallback(
118
+ (row: GlobalPropertyRow) => {
119
+ const dispose = showModal('delete-global-property-modal', {
120
+ close: () => dispose(),
121
+ property: row.property,
122
+ uuid: row.id,
123
+ onDeleted: () => mutate(),
124
+ });
125
+ },
126
+ [mutate],
127
+ );
128
+
129
+ const renderContent = () => {
130
+ if (isLoading) {
131
+ return (
132
+ <DataTableSkeleton
133
+ aria-label={t('globalProperties', 'Global properties')}
134
+ headers={headers}
135
+ showHeader
136
+ showToolbar
137
+ />
138
+ );
139
+ }
140
+
141
+ if (rows.length === 0) {
142
+ return (
143
+ <p className={styles.emptyState}>
144
+ {debouncedSearchTerm
145
+ ? t('noMatchingGlobalProperties', 'No global properties match your search')
146
+ : t('noGlobalProperties', 'No global properties to display')}
147
+ </p>
148
+ );
149
+ }
150
+
151
+ return (
152
+ <>
153
+ <DataTable useZebraStyles size={desktop ? 'sm' : 'md'} rows={rows} headers={headers}>
154
+ {({ rows: renderRows, headers: renderHeaders, getTableProps, getHeaderProps, getRowProps, getCellProps }) => (
155
+ <Table {...getTableProps()}>
156
+ <TableHead>
157
+ <TableRow>
158
+ {renderHeaders.map((header) => (
159
+ <TableHeader {...getHeaderProps({ header })}>{header.header}</TableHeader>
160
+ ))}
161
+ <TableHeader>
162
+ <span className={styles.visuallyHidden}>{t('actions', 'Actions')}</span>
163
+ </TableHeader>
164
+ </TableRow>
165
+ </TableHead>
166
+ <TableBody>
167
+ {renderRows.map((row) => {
168
+ const sourceRow = rows.find((r) => r.id === row.id)!;
169
+ return (
170
+ <TableRow {...getRowProps({ row })}>
171
+ {row.cells.map((cell) => (
172
+ <TableCell {...getCellProps({ cell })}>{cell.value}</TableCell>
173
+ ))}
174
+ <TableCell className={styles.actionsCell}>
175
+ <Button
176
+ kind="ghost"
177
+ size="sm"
178
+ renderIcon={Edit}
179
+ iconDescription={t('edit', 'Edit')}
180
+ hasIconOnly
181
+ onClick={() => handleEdit(sourceRow)}
182
+ />
183
+ <Button
184
+ kind="danger--ghost"
185
+ size="sm"
186
+ renderIcon={TrashCan}
187
+ iconDescription={t('delete', 'Delete')}
188
+ hasIconOnly
189
+ onClick={() => handleDelete(sourceRow)}
190
+ />
191
+ </TableCell>
192
+ </TableRow>
193
+ );
194
+ })}
195
+ </TableBody>
196
+ </Table>
197
+ )}
198
+ </DataTable>
199
+
200
+ <Pagination
201
+ itemsPerPageText={t('itemsPerPage', 'Items per page:')}
202
+ forwardText={t('nextPage', 'Next page')}
203
+ backwardText={t('previousPage', 'Previous page')}
204
+ itemRangeText={(min, max, total) =>
205
+ t('minMaxItems', '{{min}}-{{max}} of {{total}} items', { min, max, total })
206
+ }
207
+ pageRangeText={(_current, total) => t('pageRangeText', 'of {{count}} pages', { count: total })}
208
+ page={currentPage}
209
+ pageSize={pageSize}
210
+ pageSizes={pageSizes?.length > 0 ? pageSizes : PAGE_SIZE_OPTIONS}
211
+ totalItems={totalCount ?? 0}
212
+ onChange={handlePaginationChange}
213
+ />
214
+ </>
215
+ );
216
+ };
217
+
218
+ if (error) {
219
+ return (
220
+ <div className={styles.dataTableContainer}>
221
+ <ErrorCard error={error} headerTitle={t('globalPropertyError', 'Global property')} />
222
+ </div>
223
+ );
224
+ }
225
+
226
+ return (
227
+ <div className={styles.dataTableContainer}>
228
+ <div className={styles.tableHeaderSection}>
229
+ <Search
230
+ id="global-property-search"
231
+ labelText=""
232
+ placeholder={t('searchGlobalPropertiesByName', 'Search global property by name')}
233
+ closeButtonLabelText={t('clearSearchButton', 'Clear search button')}
234
+ size={desktop ? 'md' : 'lg'}
235
+ value={searchTerm}
236
+ onChange={handleSearchChange}
237
+ type="search"
238
+ />
239
+ <Button size={desktop ? 'md' : 'lg'} kind="ghost" renderIcon={Add} onClick={() => openWorkspace()}>
240
+ {t('addGlobalProperty', 'Add new global property')}
241
+ </Button>
242
+ </div>
243
+
244
+ {renderContent()}
245
+ </div>
246
+ );
247
+ };
248
+
249
+ export default GlobalPropertyTable;