@openmrs/esm-metadataexport-app 4.4.1-pre.799 → 4.4.1-pre.803

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 (63) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/{5739.css.map → 1118.css.map} +1 -1
  3. package/dist/1118.js +1 -0
  4. package/dist/1118.js.map +1 -0
  5. package/dist/3566.css +1 -0
  6. package/dist/3566.css.map +1 -0
  7. package/dist/3566.js +1 -0
  8. package/dist/3566.js.map +1 -0
  9. package/dist/5065.css +1 -0
  10. package/dist/5065.css.map +1 -0
  11. package/dist/5065.js +1 -0
  12. package/dist/5065.js.map +1 -0
  13. package/dist/5117.js +1 -1
  14. package/dist/5117.js.map +1 -1
  15. package/dist/6813.js +1 -1
  16. package/dist/6813.js.map +1 -1
  17. package/dist/9503.css +1 -0
  18. package/dist/9503.css.map +1 -0
  19. package/dist/9503.js +1 -0
  20. package/dist/9503.js.map +1 -0
  21. package/dist/openmrs-esm-metadataexport-app.js +2 -2
  22. package/dist/openmrs-esm-metadataexport-app.js.buildmanifest.json +94 -94
  23. package/dist/openmrs-esm-metadataexport-app.js.map +1 -1
  24. package/dist/routes.json +1 -1
  25. package/package.json +1 -1
  26. package/src/components/metadata-export-header/metadata-export-header.component.tsx +2 -2
  27. package/src/components/{new-package → metadata-package-form}/add-package-action-button/add-package-action-button.component.tsx +6 -6
  28. package/src/components/{new-package → metadata-package-form}/add-package-action-button/add-package-action-button.test.tsx +12 -12
  29. package/src/components/metadata-package-form/metadata-package-form-utils.ts +9 -0
  30. package/src/components/{new-package/new-package.workspace.test.tsx → metadata-package-form/metadata-package-form.workspace.test.tsx} +147 -6
  31. package/src/components/{new-package/new-package.workspace.tsx → metadata-package-form/metadata-package-form.workspace.tsx} +111 -22
  32. package/src/components/packages-table/packages-table.component.tsx +3 -3
  33. package/src/components/packages-table/packages-table.test.tsx +10 -10
  34. package/src/components/{view-package/delete-package.modal.test.tsx → view-metadata-package/delete-metadata-package.modal.test.tsx} +4 -2
  35. package/src/components/{view-package/delete-package.modal.tsx → view-metadata-package/delete-metadata-package.modal.tsx} +3 -3
  36. package/src/components/{view-package/view-package-utils.ts → view-metadata-package/view-metadata-package-utils.ts} +2 -2
  37. package/src/components/{view-package/view-package.workspace.scss → view-metadata-package/view-metadata-package.workspace.scss} +14 -2
  38. package/src/components/{view-package/view-package.workspace.test.tsx → view-metadata-package/view-metadata-package.workspace.test.tsx} +52 -2
  39. package/src/components/{view-package/view-package.workspace.tsx → view-metadata-package/view-metadata-package.workspace.tsx} +28 -7
  40. package/src/components/{view-package → view-metadata-package}/view-package-action-button/view-package-action-button.component.test.tsx +4 -4
  41. package/src/components/{view-package → view-metadata-package}/view-package-action-button/view-package-action-button.component.tsx +2 -2
  42. package/src/index.ts +6 -6
  43. package/src/packages/packages.resource.ts +42 -19
  44. package/src/routes.json +6 -6
  45. package/translations/en.json +8 -0
  46. package/dist/4974.css +0 -1
  47. package/dist/4974.css.map +0 -1
  48. package/dist/4974.js +0 -1
  49. package/dist/4974.js.map +0 -1
  50. package/dist/5309.css +0 -1
  51. package/dist/5309.css.map +0 -1
  52. package/dist/5309.js +0 -1
  53. package/dist/5309.js.map +0 -1
  54. package/dist/5739.js +0 -1
  55. package/dist/5739.js.map +0 -1
  56. package/dist/827.css +0 -1
  57. package/dist/827.css.map +0 -1
  58. package/dist/827.js +0 -1
  59. package/dist/827.js.map +0 -1
  60. package/src/components/new-package/new-package-utils.ts +0 -8
  61. /package/dist/{5739.css → 1118.css} +0 -0
  62. /package/src/components/{new-package/new-package.workspace.scss → metadata-package-form/metadata-package-form.workspace.scss} +0 -0
  63. /package/src/components/{view-package/delete-package.modal.scss → view-metadata-package/delete-metadata-package.modal.scss} +0 -0
@@ -0,0 +1,9 @@
1
+ import { type TFunction } from 'i18next';
2
+ import { launchWorkspace } from '@openmrs/esm-framework';
3
+
4
+ export const launchPackageFormWorkspace = (t: TFunction, uuid?: string) => {
5
+ launchWorkspace('metadata-package-form-workspace', {
6
+ workspaceTitle: uuid ? t('editpackage', 'Edit package') : t('newpackage', 'New package'),
7
+ uuid,
8
+ });
9
+ };
@@ -4,8 +4,8 @@ import userEvent from '@testing-library/user-event';
4
4
  import { OpenmrsFetchError, showSnackbar } from '@openmrs/esm-framework';
5
5
  import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
6
6
  import { useDomains } from '../../domain-lookups/domain-lookups.resource';
7
- import { createPackage } from '../../packages/packages.resource';
8
- import NewPackageWorkspace from './new-package.workspace';
7
+ import { createPackage, editPackage, usePackage } from '../../packages/packages.resource';
8
+ import MetadataPackageFormWorkspace from './metadata-package-form.workspace';
9
9
 
10
10
  vi.mock('../../domain-lookups/domain-lookups.resource', async (importOriginal) => ({
11
11
  ...(await importOriginal<object>()),
@@ -14,10 +14,14 @@ vi.mock('../../domain-lookups/domain-lookups.resource', async (importOriginal) =
14
14
 
15
15
  vi.mock('../../packages/packages.resource', () => ({
16
16
  createPackage: vi.fn(),
17
+ editPackage: vi.fn(),
18
+ usePackage: vi.fn(),
17
19
  }));
18
20
 
19
21
  const mockUseDomains = useDomains as Mock;
20
22
  const mockCreatePackage = createPackage as Mock;
23
+ const mockEditPackage = editPackage as Mock;
24
+ const mockUsePackage = usePackage as Mock;
21
25
  const mockShowSnackbar = showSnackbar as Mock;
22
26
  const mockCloseWorkspace = vi.fn();
23
27
  const mockCloseWorkspaceWithSavedChanges = vi.fn();
@@ -35,9 +39,10 @@ const fetchError = (responseBody: unknown) =>
35
39
  new Error(),
36
40
  );
37
41
 
38
- function renderWorkspace() {
39
- render(
40
- <NewPackageWorkspace
42
+ function renderWorkspace(uuid?: string) {
43
+ return render(
44
+ <MetadataPackageFormWorkspace
45
+ uuid={uuid}
41
46
  closeWorkspace={mockCloseWorkspace}
42
47
  closeWorkspaceWithSavedChanges={mockCloseWorkspaceWithSavedChanges}
43
48
  promptBeforeClosing={mockPromptBeforeClosing}
@@ -48,10 +53,12 @@ function renderWorkspace() {
48
53
  );
49
54
  }
50
55
 
51
- describe('NewPackageWorkspace', () => {
56
+ describe('PackageFormWorkspace', () => {
52
57
  beforeEach(() => {
53
58
  mockUseDomains.mockReturnValue({ domains, isLoading: false, error: undefined });
59
+ mockUsePackage.mockReturnValue({ exportPackage: undefined, isLoading: false, error: undefined });
54
60
  mockCreatePackage.mockResolvedValue({ data: {} });
61
+ mockEditPackage.mockResolvedValue({ data: {} });
55
62
  });
56
63
 
57
64
  it('renders a checkbox for each domain with a human-readable label', () => {
@@ -203,4 +210,138 @@ describe('NewPackageWorkspace', () => {
203
210
 
204
211
  expect(mockCloseWorkspace).toHaveBeenCalled();
205
212
  });
213
+
214
+ describe('edit mode', () => {
215
+ const uuid = 'package-uuid';
216
+
217
+ it('shows a loading indicator while the package is being fetched', () => {
218
+ mockUsePackage.mockReturnValue({ exportPackage: undefined, isLoading: true, error: undefined });
219
+ renderWorkspace(uuid);
220
+
221
+ expect(screen.getByText('Loading package…')).toBeInTheDocument();
222
+ expect(screen.queryByRole('textbox', { name: 'Package name' })).not.toBeInTheDocument();
223
+ });
224
+
225
+ it('shows an error notification when the package request fails', () => {
226
+ mockUsePackage.mockReturnValue({ exportPackage: undefined, isLoading: false, error: new Error('Not found') });
227
+ renderWorkspace(uuid);
228
+
229
+ expect(screen.getByText('Error loading package')).toBeInTheDocument();
230
+ expect(screen.getByText('Not found')).toBeInTheDocument();
231
+ });
232
+
233
+ it('seeds the form from the fetched package', () => {
234
+ mockUsePackage.mockReturnValue({
235
+ exportPackage: {
236
+ name: 'Core reference data',
237
+ description: 'Some notes',
238
+ entries: [{ domain: 'CONCEPTS' }, { domain: 'ENCOUNTER_TYPES' }],
239
+ },
240
+ isLoading: false,
241
+ error: undefined,
242
+ });
243
+ renderWorkspace(uuid);
244
+
245
+ expect(screen.getByRole('textbox', { name: 'Package name' })).toHaveValue('Core reference data');
246
+ expect(screen.getByRole('textbox', { name: 'Description' })).toHaveValue('Some notes');
247
+ expect(screen.getByRole('checkbox', { name: 'Concepts' })).toBeChecked();
248
+ expect(screen.getByRole('checkbox', { name: 'Encounter types' })).toBeChecked();
249
+ expect(screen.getByRole('checkbox', { name: 'Attribute types' })).not.toBeChecked();
250
+ });
251
+
252
+ it('does not overwrite in-progress edits when the package or domains revalidate', async () => {
253
+ // A fresh object each call mimics SWR handing back a new reference on revalidation.
254
+ const serverPackage = () => ({
255
+ exportPackage: { name: 'Original name', description: 'Original notes', entries: [{ domain: 'CONCEPTS' }] },
256
+ isLoading: false,
257
+ error: undefined,
258
+ });
259
+ mockUsePackage.mockReturnValue(serverPackage());
260
+ const user = userEvent.setup();
261
+ const { rerender } = renderWorkspace(uuid);
262
+
263
+ const nameInput = screen.getByRole('textbox', { name: 'Package name' });
264
+ await user.clear(nameInput);
265
+ await user.type(nameInput, 'My edited name');
266
+
267
+ // Simulate a background revalidation returning the original server values again.
268
+ mockUsePackage.mockReturnValue(serverPackage());
269
+ rerender(
270
+ <MetadataPackageFormWorkspace
271
+ uuid={uuid}
272
+ closeWorkspace={mockCloseWorkspace}
273
+ closeWorkspaceWithSavedChanges={mockCloseWorkspaceWithSavedChanges}
274
+ promptBeforeClosing={mockPromptBeforeClosing}
275
+ setTitle={vi.fn()}
276
+ // @ts-expect-error - the workspace only uses closeWorkspace and promptBeforeClosing from the default props
277
+ additionalProps={{}}
278
+ />,
279
+ );
280
+
281
+ expect(screen.getByRole('textbox', { name: 'Package name' })).toHaveValue('My edited name');
282
+ });
283
+
284
+ it('selects every domain when the package has no entries', () => {
285
+ mockUsePackage.mockReturnValue({
286
+ exportPackage: { name: 'Everything', description: '', entries: [] },
287
+ isLoading: false,
288
+ error: undefined,
289
+ });
290
+ renderWorkspace(uuid);
291
+
292
+ for (const label of ['Attribute types', 'Concepts', 'Encounter types']) {
293
+ expect(screen.getByRole('checkbox', { name: label })).toBeChecked();
294
+ }
295
+ });
296
+
297
+ it('updates the package via editPackage, shows a snackbar, and closes the workspace', async () => {
298
+ mockUsePackage.mockReturnValue({
299
+ exportPackage: {
300
+ name: 'Core reference data',
301
+ description: 'Some notes',
302
+ entries: [{ domain: 'CONCEPTS' }],
303
+ },
304
+ isLoading: false,
305
+ error: undefined,
306
+ });
307
+ const user = userEvent.setup();
308
+ renderWorkspace(uuid);
309
+
310
+ await user.click(screen.getByRole('checkbox', { name: 'Encounter types' }));
311
+ await user.click(screen.getByRole('button', { name: 'Save changes' }));
312
+
313
+ expect(mockEditPackage).toHaveBeenCalledWith(uuid, {
314
+ name: 'Core reference data',
315
+ description: 'Some notes',
316
+ entries: [{ domain: 'CONCEPTS' }, { domain: 'ENCOUNTER_TYPES' }],
317
+ });
318
+ expect(mockCreatePackage).not.toHaveBeenCalled();
319
+ expect(mockShowSnackbar).toHaveBeenCalledWith(
320
+ expect.objectContaining({ title: 'Package updated', kind: 'success' }),
321
+ );
322
+ expect(mockCloseWorkspaceWithSavedChanges).toHaveBeenCalled();
323
+ });
324
+
325
+ it('shows an update-failure snackbar and keeps the workspace open on error', async () => {
326
+ mockUsePackage.mockReturnValue({
327
+ exportPackage: { name: 'Core reference data', description: '', entries: [{ domain: 'CONCEPTS' }] },
328
+ isLoading: false,
329
+ error: undefined,
330
+ });
331
+ mockEditPackage.mockRejectedValue(new Error('Server exploded'));
332
+ const user = userEvent.setup();
333
+ renderWorkspace(uuid);
334
+
335
+ await user.click(screen.getByRole('button', { name: 'Save changes' }));
336
+
337
+ expect(mockShowSnackbar).toHaveBeenCalledWith(
338
+ expect.objectContaining({
339
+ title: 'Failed to update package',
340
+ subtitle: 'An unexpected error occurred',
341
+ kind: 'error',
342
+ }),
343
+ );
344
+ expect(mockCloseWorkspaceWithSavedChanges).not.toHaveBeenCalled();
345
+ });
346
+ });
206
347
  });
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useState } from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import {
4
4
  Button,
@@ -21,13 +21,18 @@ import {
21
21
  OpenmrsFetchError,
22
22
  } from '@openmrs/esm-framework';
23
23
  import { formatDomainLabel, useDomains } from '../../domain-lookups/domain-lookups.resource';
24
- import { createPackage } from '../../packages/packages.resource';
25
- import styles from './new-package.workspace.scss';
24
+ import { createPackage, editPackage, usePackage } from '../../packages/packages.resource';
25
+ import styles from './metadata-package-form.workspace.scss';
26
26
 
27
27
  const packagesUrl = `${restBaseUrl}/metadataexport/packages`;
28
28
  const isPackagesCacheKey = (key: unknown) => typeof key === 'string' && key.startsWith(packagesUrl);
29
29
 
30
- const NewPackageWorkspace: React.FC<DefaultWorkspaceProps> = ({
30
+ interface PackageFormWorkspaceProps extends DefaultWorkspaceProps {
31
+ uuid?: string;
32
+ }
33
+
34
+ const MetadataPackageFormWorkspace: React.FC<PackageFormWorkspaceProps> = ({
35
+ uuid,
31
36
  closeWorkspace,
32
37
  closeWorkspaceWithSavedChanges,
33
38
  promptBeforeClosing,
@@ -35,6 +40,7 @@ const NewPackageWorkspace: React.FC<DefaultWorkspaceProps> = ({
35
40
  const { t } = useTranslation();
36
41
  const { mutate } = useSWRConfig();
37
42
  const isTablet = useLayoutType() === 'tablet';
43
+ const isEditMode = Boolean(uuid);
38
44
  const { domains, isLoading, error } = useDomains();
39
45
 
40
46
  const [packageName, setPackageName] = useState('');
@@ -45,6 +51,8 @@ const NewPackageWorkspace: React.FC<DefaultWorkspaceProps> = ({
45
51
  const allSelected = domains.length > 0 && selectedDomains.size === domains.length;
46
52
  const someSelected = selectedDomains.size > 0 && !allSelected;
47
53
 
54
+ const { exportPackage, isLoading: isLoadingPackage, error: packageError } = usePackage(uuid ?? '');
55
+
48
56
  const toggleSelectAll = useCallback(
49
57
  (checked: boolean) => {
50
58
  setSelectedDomains(checked ? new Set(domains) : new Set());
@@ -70,6 +78,28 @@ const NewPackageWorkspace: React.FC<DefaultWorkspaceProps> = ({
70
78
  promptBeforeClosing(() => hasUnsavedChanges);
71
79
  }, [hasUnsavedChanges, promptBeforeClosing]);
72
80
 
81
+ // Seed the form from the fetched package exactly once, so a later SWR revalidation
82
+ // of the package or domain list doesn't clobber the user's in-progress edits.
83
+ // An empty entries list means the package includes every registered domain.
84
+ const hasPrefilledFromPackage = useRef(false);
85
+ useEffect(() => {
86
+ if (!isEditMode || hasPrefilledFromPackage.current || !exportPackage) {
87
+ return;
88
+ }
89
+ // Wait for the domain list before expanding an "all domains" package.
90
+ if (exportPackage.entries.length === 0 && domains.length === 0) {
91
+ return;
92
+ }
93
+ hasPrefilledFromPackage.current = true;
94
+ setPackageName(exportPackage.name);
95
+ setDescription(exportPackage.description ?? '');
96
+ setSelectedDomains(
97
+ exportPackage.entries.length === 0
98
+ ? new Set(domains)
99
+ : new Set(exportPackage.entries.map((entry) => entry.domain)),
100
+ );
101
+ }, [isEditMode, exportPackage, domains]);
102
+
73
103
  const handleSubmit = useCallback(
74
104
  async (event: React.FormEvent<HTMLFormElement>) => {
75
105
  event.preventDefault();
@@ -77,17 +107,32 @@ const NewPackageWorkspace: React.FC<DefaultWorkspaceProps> = ({
77
107
 
78
108
  // An explicit empty array tells the server to include every registered domain.
79
109
  const entries = allSelected ? [] : Array.from(selectedDomains, (domain) => ({ domain }));
110
+ const payload = { name: packageName.trim(), description: description.trim(), entries };
80
111
 
81
112
  try {
82
- await createPackage({ name: packageName.trim(), description: description.trim(), entries });
83
- // Revalidate the packages list so the table shows the new package without a refresh.
84
- await mutate(isPackagesCacheKey);
85
- showSnackbar({
86
- title: t('packageCreated', 'Package created'),
87
- subtitle: t('packageCreatedSubtitle', '{{name}} was created successfully', { name: packageName.trim() }),
88
- kind: 'success',
89
- isLowContrast: true,
90
- });
113
+ const name = packageName.trim();
114
+
115
+ if (isEditMode && uuid) {
116
+ await editPackage(uuid, payload);
117
+ // Revalidate the packages list so the table reflects the change without a refresh.
118
+ await mutate(isPackagesCacheKey);
119
+ showSnackbar({
120
+ title: t('packageUpdated', 'Package updated'),
121
+ subtitle: t('packageUpdatedSubtitle', '{{name}} was updated successfully', { name }),
122
+ kind: 'success',
123
+ isLowContrast: true,
124
+ });
125
+ } else {
126
+ await createPackage(payload);
127
+ // Revalidate the packages list so the table reflects the change without a refresh.
128
+ await mutate(isPackagesCacheKey);
129
+ showSnackbar({
130
+ title: t('packageCreated', 'Package created'),
131
+ subtitle: t('packageCreatedSubtitle', '{{name}} was created successfully', { name }),
132
+ kind: 'success',
133
+ isLowContrast: true,
134
+ });
135
+ }
91
136
  // Bypass the "unsaved changes" prompt now that the package is persisted.
92
137
  closeWorkspaceWithSavedChanges();
93
138
  } catch (submitError) {
@@ -96,21 +141,63 @@ const NewPackageWorkspace: React.FC<DefaultWorkspaceProps> = ({
96
141
  typeof responseBody === 'object' && responseBody !== null
97
142
  ? Object.values(responseBody.fieldErrors ?? {})[0] ?? responseBody.error
98
143
  : null;
99
- showSnackbar({
100
- title: t('packageCreationFailed', 'Failed to create package'),
101
- subtitle: reason ?? t('unexpectedError', 'An unexpected error occurred'),
102
- kind: 'error',
103
- isLowContrast: false,
104
- });
144
+ const subtitle = reason ?? t('unexpectedError', 'An unexpected error occurred');
145
+
146
+ if (isEditMode) {
147
+ showSnackbar({
148
+ title: t('packageUpdateFailed', 'Failed to update package'),
149
+ subtitle,
150
+ kind: 'error',
151
+ isLowContrast: false,
152
+ });
153
+ } else {
154
+ showSnackbar({
155
+ title: t('packageCreationFailed', 'Failed to create package'),
156
+ subtitle,
157
+ kind: 'error',
158
+ isLowContrast: false,
159
+ });
160
+ }
105
161
  } finally {
106
162
  setIsSubmitting(false);
107
163
  }
108
164
  },
109
- [allSelected, closeWorkspaceWithSavedChanges, description, mutate, packageName, selectedDomains, t],
165
+ [
166
+ allSelected,
167
+ closeWorkspaceWithSavedChanges,
168
+ description,
169
+ isEditMode,
170
+ mutate,
171
+ packageName,
172
+ selectedDomains,
173
+ t,
174
+ uuid,
175
+ ],
110
176
  );
111
177
 
112
178
  const isSubmitDisabled = packageName.trim().length === 0 || selectedDomains.size === 0 || isSubmitting;
113
179
 
180
+ if (isEditMode && isLoadingPackage) {
181
+ return (
182
+ <div className={styles.form}>
183
+ <InlineLoading description={t('loadingPackage', 'Loading package…')} />
184
+ </div>
185
+ );
186
+ }
187
+
188
+ if (packageError) {
189
+ return (
190
+ <div className={styles.form}>
191
+ <InlineNotification
192
+ kind="error"
193
+ lowContrast
194
+ title={t('errorLoadingPackage', 'Error loading package')}
195
+ subtitle={packageError.message}
196
+ />
197
+ </div>
198
+ );
199
+ }
200
+
114
201
  return (
115
202
  <Form className={styles.form} onSubmit={handleSubmit}>
116
203
  <div className={styles.grid}>
@@ -184,7 +271,9 @@ const NewPackageWorkspace: React.FC<DefaultWorkspaceProps> = ({
184
271
  </Button>
185
272
  <Button className={styles.button} kind="primary" type="submit" disabled={isSubmitDisabled}>
186
273
  {isSubmitting ? (
187
- <InlineLoading description={t('creating', 'Creating') + '…'} />
274
+ <InlineLoading description={(isEditMode ? t('saving', 'Saving') : t('creating', 'Creating')) + '…'} />
275
+ ) : isEditMode ? (
276
+ t('saveChanges', 'Save changes')
188
277
  ) : (
189
278
  t('createPackage', 'Create package')
190
279
  )}
@@ -194,4 +283,4 @@ const NewPackageWorkspace: React.FC<DefaultWorkspaceProps> = ({
194
283
  );
195
284
  };
196
285
 
197
- export default NewPackageWorkspace;
286
+ export default MetadataPackageFormWorkspace;
@@ -22,8 +22,8 @@ import {
22
22
  } from '@openmrs/esm-framework';
23
23
  import { formatDomainLabel } from '../../domain-lookups/domain-lookups.resource';
24
24
  import { useAllPackages } from '../../packages/packages.resource';
25
- import { launchAddNewPackageWorkspace } from '../new-package/new-package-utils';
26
- import ViewPackageActionButton from '../view-package/view-package-action-button/view-package-action-button.component';
25
+ import { launchPackageFormWorkspace } from '../metadata-package-form/metadata-package-form-utils';
26
+ import ViewPackageActionButton from '../view-metadata-package/view-package-action-button/view-package-action-button.component';
27
27
  import styles from './packages-table.scss';
28
28
 
29
29
  /**
@@ -97,7 +97,7 @@ const PackagesTable: React.FC = () => {
97
97
  <EmptyCard
98
98
  displayText={t('packages__lower', 'packages')}
99
99
  headerTitle={headerTitle}
100
- launchForm={canManage ? () => launchAddNewPackageWorkspace(t) : undefined}
100
+ launchForm={canManage ? () => launchPackageFormWorkspace(t) : undefined}
101
101
  />
102
102
  </div>
103
103
  );
@@ -5,8 +5,8 @@ import { type Session, useSession, userHasAccess } from '@openmrs/esm-framework'
5
5
  import { beforeEach, describe, expect, it, vi } from 'vitest';
6
6
  import { type ExportPackage } from '../../types/index';
7
7
  import { useAllPackages } from '../../packages/packages.resource';
8
- import { launchAddNewPackageWorkspace } from '../new-package/new-package-utils';
9
- import { launchViewPackageWorkspace } from '../view-package/view-package-utils';
8
+ import { launchPackageFormWorkspace } from '../metadata-package-form/metadata-package-form-utils';
9
+ import { launchViewMetadataPackageWorkspace } from '../view-metadata-package/view-metadata-package-utils';
10
10
  import PackagesTable from './packages-table.component';
11
11
 
12
12
  vi.mock('../../packages/packages.resource', async (importOriginal) => ({
@@ -14,19 +14,19 @@ vi.mock('../../packages/packages.resource', async (importOriginal) => ({
14
14
  useAllPackages: vi.fn(),
15
15
  }));
16
16
 
17
- vi.mock('../new-package/new-package-utils', () => ({
18
- launchAddNewPackageWorkspace: vi.fn(),
17
+ vi.mock('../metadata-package-form/metadata-package-form-utils', () => ({
18
+ launchPackageFormWorkspace: vi.fn(),
19
19
  }));
20
20
 
21
- vi.mock('../view-package/view-package-utils', () => ({
22
- launchViewPackageWorkspace: vi.fn(),
21
+ vi.mock('../view-metadata-package/view-metadata-package-utils', () => ({
22
+ launchViewMetadataPackageWorkspace: vi.fn(),
23
23
  }));
24
24
 
25
25
  const mockUseAllPackages = vi.mocked(useAllPackages);
26
26
  const mockUseSession = vi.mocked(useSession);
27
27
  const mockUserHasAccess = vi.mocked(userHasAccess);
28
- const mockLaunchAddNewPackageWorkspace = vi.mocked(launchAddNewPackageWorkspace);
29
- const mockLaunchViewPackageWorkspace = vi.mocked(launchViewPackageWorkspace);
28
+ const mockLaunchPackageFormWorkspace = vi.mocked(launchPackageFormWorkspace);
29
+ const mockLaunchViewPackageWorkspace = vi.mocked(launchViewMetadataPackageWorkspace);
30
30
 
31
31
  const sessionWithUser = () => ({ user: { uuid: 'cc8507b8-7c9a-486b-85dc-b8f25ad1e4cc' } }) as unknown as Session;
32
32
 
@@ -81,7 +81,7 @@ describe('PackagesTable', () => {
81
81
  await user.click(screen.getByRole('button', { name: 'Record packages' }));
82
82
 
83
83
  expect(mockUserHasAccess).toHaveBeenCalledWith('Manage Metadata Export Packages', expect.anything());
84
- expect(mockLaunchAddNewPackageWorkspace).toHaveBeenCalledTimes(1);
84
+ expect(mockLaunchPackageFormWorkspace).toHaveBeenCalledTimes(1);
85
85
  });
86
86
 
87
87
  it('does not offer the empty-state action to users without the manage privilege', () => {
@@ -89,7 +89,7 @@ describe('PackagesTable', () => {
89
89
  render(<PackagesTable />);
90
90
 
91
91
  expect(screen.queryByRole('button', { name: 'Record packages' })).not.toBeInTheDocument();
92
- expect(mockLaunchAddNewPackageWorkspace).not.toHaveBeenCalled();
92
+ expect(mockLaunchPackageFormWorkspace).not.toHaveBeenCalled();
93
93
  });
94
94
 
95
95
  it('renders the build status per package and launches the view workspace with the package for the clicked row', async () => {
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { describe, it, expect, vi, type Mock } from 'vitest';
5
5
  import * as esmFramework from '@openmrs/esm-framework';
6
- import DeletePackageModal from './delete-package.modal';
6
+ import DeleteMetadataPackageModal from './delete-metadata-package.modal';
7
7
  import { deletePackage } from '../../packages/packages.resource';
8
8
  import type { ExportPackage } from '../../types';
9
9
 
@@ -28,7 +28,9 @@ const mockCloseModal = vi.fn();
28
28
  const mockOnDeleted = vi.fn();
29
29
 
30
30
  function renderModal() {
31
- render(<DeletePackageModal closeModal={mockCloseModal} exportPackage={exportPackage} onDeleted={mockOnDeleted} />);
31
+ render(
32
+ <DeleteMetadataPackageModal closeModal={mockCloseModal} exportPackage={exportPackage} onDeleted={mockOnDeleted} />,
33
+ );
32
34
  }
33
35
 
34
36
  describe('DeletePackageModal', () => {
@@ -5,7 +5,7 @@ import { useSWRConfig } from 'swr';
5
5
  import { OpenmrsFetchError, restBaseUrl, showSnackbar } from '@openmrs/esm-framework';
6
6
  import { deletePackage } from '../../packages/packages.resource';
7
7
  import type { ExportPackage } from '../../types';
8
- import styles from './delete-package.modal.scss';
8
+ import styles from './delete-metadata-package.modal.scss';
9
9
 
10
10
  const packagesUrl = `${restBaseUrl}/metadataexport/packages`;
11
11
  const isPackagesCacheKey = (key: unknown) => typeof key === 'string' && key.startsWith(packagesUrl);
@@ -16,7 +16,7 @@ interface DeletePackageModalProps {
16
16
  onDeleted: () => void;
17
17
  }
18
18
 
19
- const DeletePackageModal: React.FC<DeletePackageModalProps> = ({ closeModal, exportPackage, onDeleted }) => {
19
+ const DeleteMetadataPackageModal: React.FC<DeletePackageModalProps> = ({ closeModal, exportPackage, onDeleted }) => {
20
20
  const { t } = useTranslation();
21
21
  const { mutate } = useSWRConfig();
22
22
  const [isDeleting, setIsDeleting] = useState(false);
@@ -79,4 +79,4 @@ const DeletePackageModal: React.FC<DeletePackageModalProps> = ({ closeModal, exp
79
79
  );
80
80
  };
81
81
 
82
- export default DeletePackageModal;
82
+ export default DeleteMetadataPackageModal;
@@ -1,8 +1,8 @@
1
1
  import { launchWorkspace } from '@openmrs/esm-framework';
2
2
  import { type ExportPackage } from '../../types/index';
3
3
 
4
- export const launchViewPackageWorkspace = (exportPackage: ExportPackage) => {
5
- launchWorkspace('view-package-workspace', {
4
+ export const launchViewMetadataPackageWorkspace = (exportPackage: ExportPackage) => {
5
+ launchWorkspace('view-metadata-package-workspace', {
6
6
  workspaceTitle: exportPackage.name,
7
7
  exportPackage,
8
8
  });
@@ -25,12 +25,24 @@
25
25
 
26
26
  .actionRow {
27
27
  display: flex;
28
- flex-direction: row;
28
+ flex-direction: column;
29
29
  gap: layout.$spacing-04;
30
30
  }
31
31
 
32
32
  .triggerButton {
33
- flex: 1 1 auto;
33
+ width: 100%;
34
+ max-width: none;
35
+ justify-content: center;
36
+ }
37
+
38
+ .secondaryActions {
39
+ display: flex;
40
+ flex-direction: row;
41
+ gap: layout.$spacing-04;
42
+ }
43
+
44
+ .secondaryButton {
45
+ flex: 1 1 0;
34
46
  max-width: none;
35
47
  justify-content: center;
36
48
  }
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
5
5
  import * as esmFramework from '@openmrs/esm-framework';
6
- import ViewPackageWorkspace from './view-package.workspace';
6
+ import ViewMetadataPackageWorkspace from './view-metadata-package.workspace';
7
7
  import { usePackageBuilds, triggerBuild } from '../../packages/packages.resource';
8
8
  import type { ExportPackage, ExportPackageBuild } from '../../types';
9
9
 
@@ -75,7 +75,7 @@ function mockBuilds(overrides: Partial<ReturnType<typeof usePackageBuilds>> = {}
75
75
 
76
76
  function renderWorkspace(pkg: ExportPackage = exportPackage) {
77
77
  render(
78
- <ViewPackageWorkspace
78
+ <ViewMetadataPackageWorkspace
79
79
  exportPackage={pkg}
80
80
  closeWorkspace={mockCloseWorkspace}
81
81
  closeWorkspaceWithSavedChanges={mockCloseWorkspaceWithSavedChanges}
@@ -224,4 +224,54 @@ describe('ViewPackageWorkspace', () => {
224
224
  }),
225
225
  );
226
226
  });
227
+
228
+ it('triggers a build and revalidates the builds list', async () => {
229
+ const user = userEvent.setup();
230
+ const mutate = vi.fn();
231
+ mockBuilds({ mutate });
232
+ mockTriggerBuild.mockResolvedValue({});
233
+ renderWorkspace();
234
+
235
+ await user.click(screen.getByRole('button', { name: 'Trigger new build' }));
236
+
237
+ expect(mockTriggerBuild).toHaveBeenCalledWith(exportPackage.uuid);
238
+ await waitFor(() => expect(mutate).toHaveBeenCalled());
239
+ expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }));
240
+ });
241
+
242
+ it('shows an error snackbar when triggering a build fails', async () => {
243
+ const user = userEvent.setup();
244
+ const error = new esmFramework.OpenmrsFetchError(
245
+ '/url',
246
+ {} as Response,
247
+ { error: 'A build is already running for this package' },
248
+ new Error(),
249
+ );
250
+ mockTriggerBuild.mockRejectedValue(error);
251
+ renderWorkspace();
252
+
253
+ await user.click(screen.getByRole('button', { name: 'Trigger new build' }));
254
+
255
+ await waitFor(() =>
256
+ expect(mockShowSnackbar).toHaveBeenCalledWith(
257
+ expect.objectContaining({ kind: 'error', subtitle: 'A build is already running for this package' }),
258
+ ),
259
+ );
260
+ });
261
+
262
+ it('launches the delete confirmation modal wired to close the workspace on success', async () => {
263
+ const user = userEvent.setup();
264
+ renderWorkspace();
265
+
266
+ await user.click(screen.getByRole('button', { name: /delete/i }));
267
+
268
+ expect(mockShowModal).toHaveBeenCalledWith(
269
+ 'delete-package-modal',
270
+ expect.objectContaining({
271
+ exportPackage,
272
+ onDeleted: mockCloseWorkspace,
273
+ closeModal: expect.any(Function),
274
+ }),
275
+ );
276
+ });
227
277
  });