@openmrs/esm-metadataexport-app 4.4.1-pre.718 → 4.4.1-pre.759

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 (183) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/1480.js +1 -1
  3. package/dist/1480.js.map +1 -1
  4. package/dist/1646.js +1 -1
  5. package/dist/1646.js.map +1 -1
  6. package/dist/1744.js +1 -0
  7. package/dist/1744.js.map +1 -0
  8. package/dist/1869.js +1 -1
  9. package/dist/1869.js.map +1 -1
  10. package/dist/1877.js +1 -1
  11. package/dist/1877.js.map +1 -1
  12. package/dist/2317.js +1 -1
  13. package/dist/2317.js.map +1 -1
  14. package/dist/2416.js +1 -1
  15. package/dist/2416.js.map +1 -1
  16. package/dist/282.js +1 -1
  17. package/dist/282.js.map +1 -1
  18. package/dist/2881.js +1 -1
  19. package/dist/2881.js.map +1 -1
  20. package/dist/3378.js +1 -1
  21. package/dist/3378.js.map +1 -1
  22. package/dist/3963.js +1 -1
  23. package/dist/3963.js.map +1 -1
  24. package/dist/4106.js +1 -1
  25. package/dist/4106.js.map +1 -1
  26. package/dist/4111.js +1 -1
  27. package/dist/4111.js.map +1 -1
  28. package/dist/434.js +1 -1
  29. package/dist/434.js.map +1 -1
  30. package/dist/4348.js +1 -1
  31. package/dist/4348.js.map +1 -1
  32. package/dist/4359.js +2 -2
  33. package/dist/4359.js.map +1 -1
  34. package/dist/4383.js +1 -1
  35. package/dist/4383.js.map +1 -1
  36. package/dist/4606.css +1 -0
  37. package/dist/4606.css.map +1 -0
  38. package/dist/4606.js +21 -21
  39. package/dist/4606.js.map +1 -1
  40. package/dist/4658.js +1 -1
  41. package/dist/4658.js.map +1 -1
  42. package/dist/4928.js +1 -1
  43. package/dist/4928.js.map +1 -1
  44. package/dist/4974.css +1 -0
  45. package/dist/4974.css.map +1 -0
  46. package/dist/4974.js +1 -0
  47. package/dist/4974.js.map +1 -0
  48. package/dist/5117.js +1 -1
  49. package/dist/5117.js.map +1 -1
  50. package/dist/5132.js +1 -1
  51. package/dist/5132.js.map +1 -1
  52. package/dist/5145.js +1 -1
  53. package/dist/5145.js.map +1 -1
  54. package/dist/5309.css +1 -0
  55. package/dist/5309.css.map +1 -0
  56. package/dist/5309.js +1 -1
  57. package/dist/5309.js.map +1 -1
  58. package/dist/5503.js +1 -1
  59. package/dist/5503.js.map +1 -1
  60. package/dist/556.js +1 -1
  61. package/dist/556.js.map +1 -1
  62. package/dist/5644.js +1 -1
  63. package/dist/5644.js.map +1 -1
  64. package/dist/5739.css +1 -0
  65. package/dist/5739.css.map +1 -0
  66. package/dist/5739.js +1 -0
  67. package/dist/5739.js.map +1 -0
  68. package/dist/5940.js +1 -1
  69. package/dist/5940.js.map +1 -1
  70. package/dist/6047.js +1 -1
  71. package/dist/6047.js.map +1 -1
  72. package/dist/6371.js +1 -1
  73. package/dist/6371.js.map +1 -1
  74. package/dist/6377.js +1 -1
  75. package/dist/6377.js.map +1 -1
  76. package/dist/6444.js +1 -1
  77. package/dist/6444.js.map +1 -1
  78. package/dist/6508.js +1 -1
  79. package/dist/6508.js.map +1 -1
  80. package/dist/6724.js +1 -1
  81. package/dist/6724.js.map +1 -1
  82. package/dist/6813.js +1 -0
  83. package/dist/6813.js.map +1 -0
  84. package/dist/6904.js +1 -1
  85. package/dist/6904.js.map +1 -1
  86. package/dist/7045.js +1 -1
  87. package/dist/7045.js.map +1 -1
  88. package/dist/7121.js +1 -1
  89. package/dist/7121.js.map +1 -1
  90. package/dist/7175.js +1 -1
  91. package/dist/7175.js.map +1 -1
  92. package/dist/7182.js +1 -1
  93. package/dist/7182.js.map +1 -1
  94. package/dist/7742.js +1 -1
  95. package/dist/7742.js.map +1 -1
  96. package/dist/7912.js +1 -1
  97. package/dist/7912.js.map +1 -1
  98. package/dist/827.css +1 -0
  99. package/dist/827.css.map +1 -0
  100. package/dist/827.js +1 -0
  101. package/dist/827.js.map +1 -0
  102. package/dist/8358.js +1 -1
  103. package/dist/8358.js.map +1 -1
  104. package/dist/8359.js +1 -1
  105. package/dist/8359.js.map +1 -1
  106. package/dist/8695.js +1 -1
  107. package/dist/8695.js.map +1 -1
  108. package/dist/903.js +1 -1
  109. package/dist/903.js.map +1 -1
  110. package/dist/9072.js +1 -1
  111. package/dist/9072.js.map +1 -1
  112. package/dist/9806.js +1 -1
  113. package/dist/9806.js.map +1 -1
  114. package/dist/openmrs-esm-metadataexport-app.js +3 -3
  115. package/dist/openmrs-esm-metadataexport-app.js.buildmanifest.json +333 -365
  116. package/dist/openmrs-esm-metadataexport-app.js.map +1 -1
  117. package/dist/routes.json +1 -1
  118. package/package.json +1 -1
  119. package/src/components/new-package/new-package.workspace.tsx +1 -1
  120. package/src/components/packages-table/packages-table.component.tsx +26 -3
  121. package/src/components/packages-table/packages-table.test.tsx +71 -1
  122. package/src/components/view-package/delete-package.modal.scss +5 -0
  123. package/src/components/view-package/delete-package.modal.test.tsx +80 -0
  124. package/src/components/view-package/delete-package.modal.tsx +82 -0
  125. package/src/components/view-package/view-package-action-button/view-package-action-button.component.test.tsx +72 -0
  126. package/src/components/view-package/view-package-action-button/view-package-action-button.component.tsx +36 -0
  127. package/src/components/view-package/view-package-utils.ts +9 -0
  128. package/src/components/view-package/view-package.workspace.scss +105 -0
  129. package/src/components/view-package/view-package.workspace.test.tsx +227 -0
  130. package/src/components/view-package/view-package.workspace.tsx +191 -0
  131. package/src/index.ts +9 -0
  132. package/src/packages/packages.resource.ts +54 -1
  133. package/src/routes.json +14 -0
  134. package/translations/am.json +17 -1
  135. package/translations/ar.json +17 -1
  136. package/translations/ar_SY.json +17 -1
  137. package/translations/bn.json +17 -1
  138. package/translations/cs.json +17 -1
  139. package/translations/de.json +17 -1
  140. package/translations/en.json +31 -1
  141. package/translations/en_US.json +17 -1
  142. package/translations/es.json +17 -1
  143. package/translations/es_MX.json +17 -1
  144. package/translations/fr.json +17 -1
  145. package/translations/he.json +17 -1
  146. package/translations/hi.json +17 -1
  147. package/translations/hi_IN.json +17 -1
  148. package/translations/id.json +17 -1
  149. package/translations/it.json +17 -1
  150. package/translations/ka.json +17 -1
  151. package/translations/km.json +17 -1
  152. package/translations/ku.json +17 -1
  153. package/translations/ky.json +17 -1
  154. package/translations/lg.json +17 -1
  155. package/translations/ne.json +17 -1
  156. package/translations/pl.json +17 -1
  157. package/translations/pt.json +17 -1
  158. package/translations/pt_BR.json +17 -1
  159. package/translations/qu.json +17 -1
  160. package/translations/ro_RO.json +17 -1
  161. package/translations/ru_RU.json +17 -1
  162. package/translations/si.json +17 -1
  163. package/translations/sq.json +17 -1
  164. package/translations/sw.json +17 -1
  165. package/translations/sw_KE.json +17 -1
  166. package/translations/tr.json +17 -1
  167. package/translations/tr_TR.json +17 -1
  168. package/translations/uk.json +17 -1
  169. package/translations/uz.json +17 -1
  170. package/translations/uz@Latn.json +17 -1
  171. package/translations/uz_UZ.json +17 -1
  172. package/translations/vi.json +17 -1
  173. package/translations/zh.json +17 -1
  174. package/translations/zh_CN.json +17 -1
  175. package/translations/zh_TW.json +17 -1
  176. package/dist/3529.js +0 -1
  177. package/dist/3529.js.map +0 -1
  178. package/dist/7572.js +0 -1
  179. package/dist/7572.js.map +0 -1
  180. package/dist/8192.js +0 -1
  181. package/dist/8192.js.map +0 -1
  182. package/dist/main.js +0 -3
  183. package/dist/main.js.map +0 -1
@@ -0,0 +1,72 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
5
+ import { type Session, useSession, userHasAccess } from '@openmrs/esm-framework';
6
+ import ViewPackageActionButton from './view-package-action-button.component';
7
+ import { launchViewPackageWorkspace } from '../view-package-utils';
8
+ import { type ExportPackage } from '../../../types';
9
+
10
+ vi.mock('../view-package-utils', () => ({
11
+ launchViewPackageWorkspace: vi.fn(),
12
+ }));
13
+
14
+ const mockLaunchViewPackageWorkspace = launchViewPackageWorkspace as Mock;
15
+ const mockUseSession = vi.mocked(useSession);
16
+ const mockUserHasAccess = vi.mocked(userHasAccess);
17
+
18
+ const sessionWithUser = () => ({ user: { uuid: 'cc8507b8-7c9a-486b-85dc-b8f25ad1e4cc' } }) as unknown as Session;
19
+
20
+ const mockPackage: ExportPackage = {
21
+ uuid: 'a1b2c3d4-0000-0000-0000-000000000000',
22
+ name: 'Core data',
23
+ description: 'testing one',
24
+ retired: false,
25
+ dateCreated: 0,
26
+ entries: [],
27
+ latestBuild: null,
28
+ };
29
+
30
+ describe('ViewPackageActionButton', () => {
31
+ beforeEach(() => {
32
+ mockUseSession.mockReturnValue(sessionWithUser());
33
+ mockUserHasAccess.mockReturnValue(true);
34
+ });
35
+
36
+ it('renders a "View" button', () => {
37
+ render(<ViewPackageActionButton exportPackage={mockPackage} />);
38
+
39
+ expect(screen.getByRole('button', { name: 'View' })).toBeInTheDocument();
40
+ });
41
+
42
+ it('launches the view package workspace with the package when a privileged user clicks', async () => {
43
+ const user = userEvent.setup();
44
+ render(<ViewPackageActionButton exportPackage={mockPackage} />);
45
+
46
+ await user.click(screen.getByRole('button', { name: 'View' }));
47
+
48
+ expect(mockUserHasAccess).toHaveBeenCalledWith('Get Metadata Export Packages', expect.anything());
49
+ expect(mockLaunchViewPackageWorkspace).toHaveBeenCalledTimes(1);
50
+ expect(mockLaunchViewPackageWorkspace).toHaveBeenCalledWith(mockPackage);
51
+ });
52
+
53
+ it('disables the button for users without the Get Metadata Export Packages privilege', async () => {
54
+ const user = userEvent.setup();
55
+ mockUserHasAccess.mockReturnValue(false);
56
+ render(<ViewPackageActionButton exportPackage={mockPackage} />);
57
+
58
+ const button = screen.getByRole('button', { name: 'View' });
59
+ expect(button).toBeDisabled();
60
+
61
+ await user.click(button);
62
+ expect(mockLaunchViewPackageWorkspace).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it('disables the button when there is no authenticated user', () => {
66
+ mockUseSession.mockReturnValue({ authenticated: false } as unknown as Session);
67
+ render(<ViewPackageActionButton exportPackage={mockPackage} />);
68
+
69
+ expect(screen.getByRole('button', { name: 'View' })).toBeDisabled();
70
+ expect(mockUserHasAccess).not.toHaveBeenCalled();
71
+ });
72
+ });
@@ -0,0 +1,36 @@
1
+ import React, { useCallback } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Button } from '@carbon/react';
4
+ import { ArrowRight } from '@carbon/react/icons';
5
+ import { useSession, userHasAccess } from '@openmrs/esm-framework';
6
+ import { launchViewPackageWorkspace } from '../view-package-utils';
7
+ import { type ExportPackage } from '../../../types';
8
+
9
+ interface ViewPackageActionButtonProps {
10
+ exportPackage: ExportPackage;
11
+ }
12
+
13
+ const ViewPackageActionButton: React.FC<ViewPackageActionButtonProps> = ({ exportPackage }) => {
14
+ const { t } = useTranslation();
15
+ const session = useSession();
16
+
17
+ const canView = session.user ? userHasAccess('Get Metadata Export Packages', session.user) : false;
18
+
19
+ const handleView = useCallback(() => {
20
+ launchViewPackageWorkspace(exportPackage);
21
+ }, [exportPackage]);
22
+
23
+ return (
24
+ <Button
25
+ disabled={!canView}
26
+ onClick={handleView}
27
+ size="md"
28
+ kind="ghost"
29
+ renderIcon={(props) => <ArrowRight size={16} {...props} />}
30
+ >
31
+ {t('view', 'View')}
32
+ </Button>
33
+ );
34
+ };
35
+
36
+ export default ViewPackageActionButton;
@@ -0,0 +1,9 @@
1
+ import { launchWorkspace } from '@openmrs/esm-framework';
2
+ import { type ExportPackage } from '../../types/index';
3
+
4
+ export const launchViewPackageWorkspace = (exportPackage: ExportPackage) => {
5
+ launchWorkspace('view-package-workspace', {
6
+ workspaceTitle: exportPackage.name,
7
+ exportPackage,
8
+ });
9
+ };
@@ -0,0 +1,105 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/type';
3
+ @use '@openmrs/esm-styleguide/src/vars' as *;
4
+
5
+ .container {
6
+ display: flex;
7
+ flex-direction: column;
8
+ height: 100%;
9
+ }
10
+
11
+ .body {
12
+ flex: 1 1 auto;
13
+ overflow-y: auto;
14
+ padding: layout.$spacing-05;
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: layout.$spacing-06;
18
+ }
19
+
20
+ .section {
21
+ display: flex;
22
+ flex-direction: column;
23
+ gap: layout.$spacing-04;
24
+ }
25
+
26
+ .actionRow {
27
+ display: flex;
28
+ flex-direction: row;
29
+ gap: layout.$spacing-04;
30
+ }
31
+
32
+ .triggerButton {
33
+ flex: 1 1 auto;
34
+ max-width: none;
35
+ justify-content: center;
36
+ }
37
+
38
+ .sectionLabel {
39
+ @include type.type-style('heading-compact-01');
40
+ color: $text-02;
41
+ text-transform: uppercase;
42
+ letter-spacing: 0.02em;
43
+ }
44
+
45
+ .detailList {
46
+ display: grid;
47
+ grid-template-columns: max-content 1fr;
48
+ gap: layout.$spacing-03 layout.$spacing-06;
49
+ margin: 0;
50
+ }
51
+
52
+ .detailKey {
53
+ @include type.type-style('body-compact-01');
54
+ color: $text-02;
55
+ }
56
+
57
+ .detailValue {
58
+ @include type.type-style('body-compact-01');
59
+ color: $text-02;
60
+ margin: 0;
61
+ }
62
+
63
+ .buildList {
64
+ display: flex;
65
+ flex-direction: column;
66
+ list-style: none;
67
+ margin: 0;
68
+ padding: 0;
69
+ }
70
+
71
+ .buildItem {
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: layout.$spacing-02;
75
+ padding: layout.$spacing-04 0;
76
+ border-top: 1px solid $ui-03;
77
+ }
78
+
79
+ .buildRow {
80
+ display: flex;
81
+ justify-content: space-between;
82
+ align-items: center;
83
+ }
84
+
85
+ .buildVersion {
86
+ @include type.type-style('heading-compact-01');
87
+ color: $text-02;
88
+ }
89
+
90
+ .buildMeta {
91
+ @include type.type-style('body-compact-01');
92
+ color: $text-02;
93
+ }
94
+
95
+ .buildError {
96
+ @include type.type-style('body-compact-01');
97
+ color: $danger;
98
+ margin: 0;
99
+ }
100
+
101
+ .emptyBuilds {
102
+ @include type.type-style('body-compact-01');
103
+ color: $text-02;
104
+ margin: 0;
105
+ }
@@ -0,0 +1,227 @@
1
+ import React from 'react';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
5
+ import * as esmFramework from '@openmrs/esm-framework';
6
+ import ViewPackageWorkspace from './view-package.workspace';
7
+ import { usePackageBuilds, triggerBuild } from '../../packages/packages.resource';
8
+ import type { ExportPackage, ExportPackageBuild } from '../../types';
9
+
10
+ vi.mock('@openmrs/esm-framework', async (importOriginal) => {
11
+ const original = await importOriginal<typeof esmFramework>();
12
+ return {
13
+ ...original,
14
+ makeUrl: (path: string) => `${window.openmrsBase}${path}`,
15
+ showModal: vi.fn(() => vi.fn()),
16
+ };
17
+ });
18
+
19
+ vi.mock('../../packages/packages.resource', () => ({
20
+ usePackageBuilds: vi.fn(),
21
+ triggerBuild: vi.fn(),
22
+ }));
23
+
24
+ const mockTriggerBuild = triggerBuild as Mock;
25
+ const mockShowModal = esmFramework.showModal as Mock;
26
+ const mockShowSnackbar = esmFramework.showSnackbar as Mock;
27
+
28
+ const mockUsePackageBuilds = usePackageBuilds as Mock;
29
+ const mockUseSession = vi.mocked(esmFramework.useSession);
30
+ const mockUserHasAccess = vi.mocked(esmFramework.userHasAccess);
31
+
32
+ const sessionWithUser = () =>
33
+ ({ user: { uuid: 'cc8507b8-7c9a-486b-85dc-b8f25ad1e4cc' } }) as unknown as esmFramework.Session;
34
+ const mockCloseWorkspace = vi.fn();
35
+ const mockCloseWorkspaceWithSavedChanges = vi.fn();
36
+ const mockPromptBeforeClosing = vi.fn();
37
+
38
+ const build = (overrides: Partial<ExportPackageBuild> = {}): ExportPackageBuild => ({
39
+ uuid: '23dbfd25-eec7-4f4e-b26c-d95324346ce3',
40
+ packageUuid: '361d69db-c018-4545-87a7-e987e8af9e85',
41
+ version: 2,
42
+ status: 'COMPLETED',
43
+ dateCreated: Date.now() - 15 * 60 * 1000,
44
+ dateStarted: Date.now() - 10 * 60 * 1000,
45
+ dateCompleted: Date.now() - 5 * 60 * 1000,
46
+ errorMessage: null,
47
+ downloadUrl: '/download/build-1',
48
+ manifest: null,
49
+ ...overrides,
50
+ });
51
+
52
+ const exportPackage: ExportPackage = {
53
+ uuid: '361d69db-c018-4545-87a7-e987e8af9e85',
54
+ name: 'Core reference data',
55
+ description: 'Some notes',
56
+ retired: false,
57
+ dateCreated: Date.now(),
58
+ entries: [
59
+ { domain: 'CONCEPTS', itemUuids: [] },
60
+ { domain: 'ENCOUNTER_TYPES', itemUuids: [] },
61
+ ],
62
+ latestBuild: null,
63
+ };
64
+
65
+ function mockBuilds(overrides: Partial<ReturnType<typeof usePackageBuilds>> = {}) {
66
+ mockUsePackageBuilds.mockReturnValue({
67
+ builds: [],
68
+ isLoading: false,
69
+ isValidating: false,
70
+ error: undefined,
71
+ mutate: vi.fn(),
72
+ ...overrides,
73
+ });
74
+ }
75
+
76
+ function renderWorkspace(pkg: ExportPackage = exportPackage) {
77
+ render(
78
+ <ViewPackageWorkspace
79
+ exportPackage={pkg}
80
+ closeWorkspace={mockCloseWorkspace}
81
+ closeWorkspaceWithSavedChanges={mockCloseWorkspaceWithSavedChanges}
82
+ promptBeforeClosing={mockPromptBeforeClosing}
83
+ setTitle={vi.fn()}
84
+ />,
85
+ );
86
+ }
87
+
88
+ describe('ViewPackageWorkspace', () => {
89
+ beforeEach(() => {
90
+ mockBuilds();
91
+ mockUseSession.mockReturnValue(sessionWithUser());
92
+ mockUserHasAccess.mockReturnValue(true);
93
+ });
94
+
95
+ it('renders the package domains as human-readable labels', () => {
96
+ renderWorkspace();
97
+
98
+ expect(screen.getByText('Concepts, Encounter types')).toBeInTheDocument();
99
+ });
100
+
101
+ it('shows "All domains" when the package has no entries', () => {
102
+ renderWorkspace({ ...exportPackage, entries: [] });
103
+
104
+ expect(screen.getByText('All domains')).toBeInTheDocument();
105
+ });
106
+
107
+ it('shows a loading indicator while builds are loading', () => {
108
+ mockBuilds({ isLoading: true });
109
+ renderWorkspace();
110
+
111
+ expect(screen.getByText('Loading builds…')).toBeInTheDocument();
112
+ });
113
+
114
+ it('shows an error state when the builds request fails', () => {
115
+ mockBuilds({ error: new Error('Boom') });
116
+ renderWorkspace();
117
+
118
+ expect(screen.getByText('Error State')).toBeInTheDocument();
119
+ });
120
+
121
+ it('shows an empty message when there are no builds', () => {
122
+ renderWorkspace();
123
+
124
+ expect(screen.getByText('No builds yet')).toBeInTheDocument();
125
+ });
126
+
127
+ it('renders each build with its version, status, and download link', () => {
128
+ mockBuilds({ builds: [build()] });
129
+ renderWorkspace();
130
+
131
+ expect(screen.getByText('Build 2')).toBeInTheDocument();
132
+ expect(screen.getByText('COMPLETED')).toBeInTheDocument();
133
+ expect(screen.getByText(/^Started .* ago$/)).toBeInTheDocument();
134
+ expect(screen.getByText(/^Completed .* ago$/)).toBeInTheDocument();
135
+ expect(screen.getByRole('link', { name: 'Download' })).toHaveAttribute(
136
+ 'href',
137
+ `/openmrs/ws/rest/v1/metadataexport/builds/${build().uuid}/download`,
138
+ );
139
+ });
140
+
141
+ it('hides the download link from users without the Manage Metadata Export Packages privilege', () => {
142
+ mockUserHasAccess.mockReturnValue(false);
143
+ mockBuilds({ builds: [build()] });
144
+ renderWorkspace();
145
+
146
+ expect(screen.getByText('COMPLETED')).toBeInTheDocument();
147
+ expect(screen.queryByRole('link', { name: 'Download' })).not.toBeInTheDocument();
148
+ expect(mockUserHasAccess).toHaveBeenCalledWith('Manage Metadata Export Packages', expect.anything());
149
+ });
150
+
151
+ it('omits the download link for a build without a download URL', () => {
152
+ mockBuilds({ builds: [build({ status: 'QUEUED', downloadUrl: null, dateStarted: null, dateCompleted: null })] });
153
+ renderWorkspace();
154
+
155
+ expect(screen.getByText('QUEUED')).toBeInTheDocument();
156
+ expect(screen.getByText('Not started')).toBeInTheDocument();
157
+ expect(screen.queryByText(/^Completed/)).not.toBeInTheDocument();
158
+ expect(screen.queryByRole('link', { name: 'Download' })).not.toBeInTheDocument();
159
+ });
160
+
161
+ it('shows the failure reason for a failed build', () => {
162
+ mockBuilds({
163
+ builds: [build({ status: 'FAILED', downloadUrl: null, errorMessage: 'Serialization failed for concept 5497' })],
164
+ });
165
+ renderWorkspace();
166
+
167
+ expect(screen.getByText('FAILED')).toBeInTheDocument();
168
+ expect(screen.getByText('Serialization failed for concept 5497')).toBeInTheDocument();
169
+ });
170
+
171
+ it('does not show a failure reason for a non-failed build', () => {
172
+ mockBuilds({ builds: [build({ status: 'COMPLETED', errorMessage: 'stale error' })] });
173
+ renderWorkspace();
174
+
175
+ expect(screen.queryByText('stale error')).not.toBeInTheDocument();
176
+ });
177
+
178
+ it('triggers a build and revalidates the builds list', async () => {
179
+ const user = userEvent.setup();
180
+ const mutate = vi.fn();
181
+ mockBuilds({ mutate });
182
+ mockTriggerBuild.mockResolvedValue({});
183
+ renderWorkspace();
184
+
185
+ await user.click(screen.getByRole('button', { name: 'Trigger new build' }));
186
+
187
+ expect(mockTriggerBuild).toHaveBeenCalledWith(exportPackage.uuid);
188
+ await waitFor(() => expect(mutate).toHaveBeenCalled());
189
+ expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }));
190
+ });
191
+
192
+ it('shows an error snackbar when triggering a build fails', async () => {
193
+ const user = userEvent.setup();
194
+ const error = new esmFramework.OpenmrsFetchError(
195
+ '/url',
196
+ {} as Response,
197
+ { error: 'A build is already running for this package' },
198
+ new Error(),
199
+ );
200
+ mockTriggerBuild.mockRejectedValue(error);
201
+ renderWorkspace();
202
+
203
+ await user.click(screen.getByRole('button', { name: 'Trigger new build' }));
204
+
205
+ await waitFor(() =>
206
+ expect(mockShowSnackbar).toHaveBeenCalledWith(
207
+ expect.objectContaining({ kind: 'error', subtitle: 'A build is already running for this package' }),
208
+ ),
209
+ );
210
+ });
211
+
212
+ it('launches the delete confirmation modal wired to close the workspace on success', async () => {
213
+ const user = userEvent.setup();
214
+ renderWorkspace();
215
+
216
+ await user.click(screen.getByRole('button', { name: /delete/i }));
217
+
218
+ expect(mockShowModal).toHaveBeenCalledWith(
219
+ 'delete-package-modal',
220
+ expect.objectContaining({
221
+ exportPackage,
222
+ onDeleted: mockCloseWorkspace,
223
+ closeModal: expect.any(Function),
224
+ }),
225
+ );
226
+ });
227
+ });
@@ -0,0 +1,191 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Download } from '@carbon/react/icons';
4
+ import { Button, InlineLoading, Link, Tag } from '@carbon/react';
5
+ import { useSWRConfig } from 'swr';
6
+ import {
7
+ type DefaultWorkspaceProps,
8
+ ErrorState,
9
+ OpenmrsFetchError,
10
+ formatDurationBetween,
11
+ makeUrl,
12
+ restBaseUrl,
13
+ showModal,
14
+ showSnackbar,
15
+ useSession,
16
+ userHasAccess,
17
+ } from '@openmrs/esm-framework';
18
+ import { formatDomainLabel } from '../../domain-lookups/domain-lookups.resource';
19
+ import { triggerBuild, usePackageBuilds } from '../../packages/packages.resource';
20
+ import type { ExportBuildStatus, ExportPackage } from '../../types';
21
+ import styles from './view-package.workspace.scss';
22
+
23
+ const packagesUrl = `${restBaseUrl}/metadataexport/packages`;
24
+ const isPackagesCacheKey = (key: unknown) => typeof key === 'string' && key.startsWith(packagesUrl);
25
+
26
+ interface ViewPackageWorkspaceProps extends DefaultWorkspaceProps {
27
+ exportPackage: ExportPackage;
28
+ }
29
+
30
+ const statusTagType: Record<ExportBuildStatus, 'gray' | 'blue' | 'green' | 'red'> = {
31
+ QUEUED: 'gray',
32
+ RUNNING: 'blue',
33
+ COMPLETED: 'green',
34
+ FAILED: 'red',
35
+ };
36
+
37
+ const ViewPackageWorkspace: React.FC<ViewPackageWorkspaceProps> = ({ exportPackage, closeWorkspace }) => {
38
+ const { t } = useTranslation();
39
+ const session = useSession();
40
+ const { mutate: globalMutate } = useSWRConfig();
41
+ const { builds, isLoading, error, mutate: mutateBuilds } = usePackageBuilds(exportPackage.uuid);
42
+
43
+ // Downloading a build hits a Manage-gated backend endpoint, so hide it from Get-only viewers.
44
+ const canManage = session.user ? userHasAccess('Manage Metadata Export Packages', session.user) : false;
45
+ const [isTriggeringBuild, setIsTriggeringBuild] = useState(false);
46
+
47
+ const handleTriggerBuild = useCallback(async () => {
48
+ setIsTriggeringBuild(true);
49
+ try {
50
+ await triggerBuild(exportPackage.uuid);
51
+ // Revalidate the builds list so the new build appears without a refresh, and the
52
+ // packages list so the table's Status column reflects the newly queued build.
53
+ await Promise.all([mutateBuilds(), globalMutate(isPackagesCacheKey)]);
54
+ showSnackbar({
55
+ title: t('buildTriggered', 'Build triggered'),
56
+ subtitle: t('buildTriggeredSubtitle', 'A new build was started for {{name}}', { name: exportPackage.name }),
57
+ kind: 'success',
58
+ isLowContrast: true,
59
+ });
60
+ } catch (triggerError) {
61
+ const responseBody = triggerError instanceof OpenmrsFetchError ? triggerError.responseBody : null;
62
+ const reason = typeof responseBody === 'object' && responseBody !== null ? responseBody.error : null;
63
+ showSnackbar({
64
+ title: t('buildTriggerFailed', 'Failed to trigger build'),
65
+ subtitle: reason ?? t('unexpectedError', 'An unexpected error occurred'),
66
+ kind: 'error',
67
+ });
68
+ } finally {
69
+ setIsTriggeringBuild(false);
70
+ }
71
+ }, [exportPackage.uuid, exportPackage.name, mutateBuilds, globalMutate, t]);
72
+
73
+ // revalidation: usePackageBuilds refreshes while a build is active, so when the
74
+ // set of build statuses changes (e.g. a build finishes) refresh the packages list too, keeping
75
+ const buildStatusVersion = builds.map((build) => `${build.uuid}:${build.status}`).join('|');
76
+ const previousBuildStatusVersion = useRef<string | null>(null);
77
+ useEffect(() => {
78
+ if (isLoading) {
79
+ return;
80
+ }
81
+ if (previousBuildStatusVersion.current !== null && previousBuildStatusVersion.current !== buildStatusVersion) {
82
+ globalMutate(isPackagesCacheKey);
83
+ }
84
+ previousBuildStatusVersion.current = buildStatusVersion;
85
+ }, [buildStatusVersion, isLoading, globalMutate]);
86
+
87
+ const launchDeleteModal = useCallback(() => {
88
+ const dispose = showModal('delete-package-modal', {
89
+ closeModal: () => dispose(),
90
+ exportPackage,
91
+ onDeleted: closeWorkspace,
92
+ });
93
+ }, [exportPackage, closeWorkspace]);
94
+
95
+ const domainsLabel = useMemo(() => {
96
+ // An empty entries list means the package includes every registered domain.
97
+ const includesEveryDomain = exportPackage.entries.length === 0;
98
+ if (includesEveryDomain) {
99
+ return t('allDomains', 'All domains');
100
+ }
101
+
102
+ return exportPackage.entries.map((entry) => formatDomainLabel(entry.domain)).join(', ');
103
+ }, [exportPackage.entries, t]);
104
+
105
+ return (
106
+ <div className={styles.container}>
107
+ <div className={styles.body}>
108
+ {canManage && (
109
+ <section className={styles.actionRow}>
110
+ <Button
111
+ className={styles.triggerButton}
112
+ kind="primary"
113
+ onClick={handleTriggerBuild}
114
+ disabled={isTriggeringBuild}
115
+ >
116
+ {isTriggeringBuild ? (
117
+ <InlineLoading description={t('triggeringBuild', 'Triggering build') + '…'} />
118
+ ) : (
119
+ t('triggerNewBuild', 'Trigger new build')
120
+ )}
121
+ </Button>
122
+ <Button kind="danger--tertiary" onClick={launchDeleteModal} disabled={isTriggeringBuild}>
123
+ {t('delete', 'Delete')}
124
+ </Button>
125
+ </section>
126
+ )}
127
+
128
+ <section className={styles.section}>
129
+ <span className={styles.sectionLabel}>{t('details', 'Details')}</span>
130
+ <dl className={styles.detailList}>
131
+ <dt className={styles.detailKey}>{t('domains', 'Domains')}</dt>
132
+ <dd className={styles.detailValue}>{domainsLabel}</dd>
133
+ </dl>
134
+ </section>
135
+
136
+ <section className={styles.section}>
137
+ <span className={styles.sectionLabel}>{t('builds', 'Builds')}</span>
138
+ {isLoading ? (
139
+ <InlineLoading description={t('loadingBuilds', 'Loading builds…')} />
140
+ ) : error ? (
141
+ <ErrorState error={error} headerTitle={t('builds', 'Builds')} />
142
+ ) : builds.length === 0 ? (
143
+ <p className={styles.emptyBuilds}>{t('noBuildsYet', 'No builds yet')}</p>
144
+ ) : (
145
+ <ul className={styles.buildList}>
146
+ {builds.map((build) => (
147
+ <li key={build.uuid} className={styles.buildItem}>
148
+ <div className={styles.buildRow}>
149
+ <span className={styles.buildVersion}>
150
+ {t('buildVersion', 'Build {{version}}', { version: build.version })}
151
+ </span>
152
+ <Tag type={statusTagType[build.status]} size="sm">
153
+ {t(build.status)}
154
+ </Tag>
155
+ </div>
156
+ <div className={styles.buildRow}>
157
+ <span className={styles.buildMeta}>
158
+ {build.dateStarted
159
+ ? t('startedAgo', 'Started {{time}} ago', { time: formatDurationBetween(build.dateStarted) })
160
+ : t('notStarted', 'Not started')}
161
+ </span>
162
+ {build.dateCompleted && (
163
+ <span className={styles.buildMeta}>
164
+ {t('completedAgo', 'Completed {{time}} ago', {
165
+ time: formatDurationBetween(build.dateCompleted),
166
+ })}
167
+ </span>
168
+ )}
169
+ </div>
170
+ {build.status === 'FAILED' && build.errorMessage && (
171
+ <p className={styles.buildError}>{build.errorMessage}</p>
172
+ )}
173
+ {canManage && build.downloadUrl && (
174
+ <Link
175
+ href={makeUrl(`${restBaseUrl}/metadataexport/builds/${build.uuid}/download`)}
176
+ renderIcon={() => <Download size={16} />}
177
+ >
178
+ {t('download', 'Download')}
179
+ </Link>
180
+ )}
181
+ </li>
182
+ ))}
183
+ </ul>
184
+ )}
185
+ </section>
186
+ </div>
187
+ </div>
188
+ );
189
+ };
190
+
191
+ export default ViewPackageWorkspace;
package/src/index.ts CHANGED
@@ -20,9 +20,18 @@ export const root = getAsyncLifecycle(() => import('./root.component'), options)
20
20
  export const metadataExportCardLink = getAsyncLifecycle(() => import('./admin-card-link.component'), options);
21
21
 
22
22
  // Modals
23
+ export const deletePackageModal = getAsyncLifecycle(
24
+ () => import('./components/view-package/delete-package.modal'),
25
+ options,
26
+ );
23
27
 
24
28
  // Workspaces
25
29
  export const newPackageWorkspace = getAsyncLifecycle(
26
30
  () => import('./components/new-package/new-package.workspace'),
27
31
  options,
28
32
  );
33
+
34
+ export const viewPackageWorkspace = getAsyncLifecycle(
35
+ () => import('./components/view-package/view-package.workspace'),
36
+ options,
37
+ );