@openmrs/esm-metadataexport-app 4.4.1-pre.705 → 4.4.1-pre.726

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 (40) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/3529.js +1 -1
  3. package/dist/3529.js.map +1 -1
  4. package/dist/4359.js +2 -2
  5. package/dist/4359.js.map +1 -1
  6. package/dist/{6425.js → 4606.js} +15 -15
  7. package/dist/4606.js.map +1 -0
  8. package/dist/4974.js +1 -0
  9. package/dist/4974.js.map +1 -0
  10. package/dist/5117.js +1 -1
  11. package/dist/5117.js.map +1 -1
  12. package/dist/5145.js +1 -1
  13. package/dist/5145.js.map +1 -1
  14. package/dist/5309.js +1 -1
  15. package/dist/5309.js.map +1 -1
  16. package/dist/{7572.js → 5739.js} +1 -1
  17. package/dist/5739.js.map +1 -0
  18. package/dist/main.js +2 -2
  19. package/dist/main.js.map +1 -1
  20. package/dist/openmrs-esm-metadataexport-app.js +2 -2
  21. package/dist/openmrs-esm-metadataexport-app.js.buildmanifest.json +94 -70
  22. package/dist/openmrs-esm-metadataexport-app.js.map +1 -1
  23. package/dist/routes.json +1 -1
  24. package/package.json +1 -1
  25. package/src/components/new-package/new-package.workspace.tsx +1 -1
  26. package/src/components/packages-table/packages-table.component.tsx +26 -3
  27. package/src/components/packages-table/packages-table.test.tsx +71 -1
  28. package/src/components/view-package/view-package-action-button/view-package-action-button.component.test.tsx +72 -0
  29. package/src/components/view-package/view-package-action-button/view-package-action-button.component.tsx +36 -0
  30. package/src/components/view-package/view-package-utils.ts +9 -0
  31. package/src/components/view-package/view-package.workspace.scss +93 -0
  32. package/src/components/view-package/view-package.workspace.test.tsx +170 -0
  33. package/src/components/view-package/view-package.workspace.tsx +114 -0
  34. package/src/index.ts +5 -0
  35. package/src/packages/packages.resource.ts +16 -1
  36. package/src/routes.json +8 -0
  37. package/translations/de.json +20 -20
  38. package/translations/en.json +17 -1
  39. package/dist/6425.js.map +0 -1
  40. package/dist/7572.js.map +0 -1
@@ -1,10 +1,12 @@
1
1
  import React from 'react';
2
- import { render, screen } from '@testing-library/react';
2
+ import { render, screen, within } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { type Session, useSession, userHasAccess } from '@openmrs/esm-framework';
5
5
  import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { type ExportPackage } from '../../types/index';
6
7
  import { useAllPackages } from '../../packages/packages.resource';
7
8
  import { launchAddNewPackageWorkspace } from '../new-package/new-package-utils';
9
+ import { launchViewPackageWorkspace } from '../view-package/view-package-utils';
8
10
  import PackagesTable from './packages-table.component';
9
11
 
10
12
  vi.mock('../../packages/packages.resource', async (importOriginal) => ({
@@ -16,13 +18,49 @@ vi.mock('../new-package/new-package-utils', () => ({
16
18
  launchAddNewPackageWorkspace: vi.fn(),
17
19
  }));
18
20
 
21
+ vi.mock('../view-package/view-package-utils', () => ({
22
+ launchViewPackageWorkspace: vi.fn(),
23
+ }));
24
+
19
25
  const mockUseAllPackages = vi.mocked(useAllPackages);
20
26
  const mockUseSession = vi.mocked(useSession);
21
27
  const mockUserHasAccess = vi.mocked(userHasAccess);
22
28
  const mockLaunchAddNewPackageWorkspace = vi.mocked(launchAddNewPackageWorkspace);
29
+ const mockLaunchViewPackageWorkspace = vi.mocked(launchViewPackageWorkspace);
23
30
 
24
31
  const sessionWithUser = () => ({ user: { uuid: 'cc8507b8-7c9a-486b-85dc-b8f25ad1e4cc' } }) as unknown as Session;
25
32
 
33
+ const builtPackage: ExportPackage = {
34
+ uuid: 'a1b2c3d4-0000-0000-0000-000000000001',
35
+ name: 'Core reference data',
36
+ description: 'Shared reference metadata',
37
+ retired: false,
38
+ dateCreated: 0,
39
+ entries: [{ domain: 'PATIENT_IDENTIFIER_TYPE', itemUuids: [] }],
40
+ latestBuild: {
41
+ uuid: 'build-0000-0000-0000-000000000001',
42
+ packageUuid: 'a1b2c3d4-0000-0000-0000-000000000001',
43
+ version: 1,
44
+ status: 'COMPLETED',
45
+ dateCreated: 0,
46
+ dateStarted: 0,
47
+ dateCompleted: 0,
48
+ errorMessage: null,
49
+ downloadUrl: null,
50
+ manifest: null,
51
+ },
52
+ };
53
+
54
+ const unbuiltPackage: ExportPackage = {
55
+ uuid: 'a1b2c3d4-0000-0000-0000-000000000002',
56
+ name: 'Facility data',
57
+ description: 'Facility metadata',
58
+ retired: false,
59
+ dateCreated: 0,
60
+ entries: [],
61
+ latestBuild: null,
62
+ };
63
+
26
64
  describe('PackagesTable', () => {
27
65
  beforeEach(() => {
28
66
  mockUseAllPackages.mockReturnValue({
@@ -53,4 +91,36 @@ describe('PackagesTable', () => {
53
91
  expect(screen.queryByRole('button', { name: 'Record packages' })).not.toBeInTheDocument();
54
92
  expect(mockLaunchAddNewPackageWorkspace).not.toHaveBeenCalled();
55
93
  });
94
+
95
+ it('renders the build status per package and launches the view workspace with the package for the clicked row', async () => {
96
+ const user = userEvent.setup();
97
+ mockUseAllPackages.mockReturnValue({
98
+ packages: [builtPackage, unbuiltPackage],
99
+ isLoading: false,
100
+ isValidating: false,
101
+ error: undefined,
102
+ mutate: vi.fn(),
103
+ });
104
+ render(<PackagesTable />);
105
+
106
+ const builtRow = screen.getByRole('row', { name: /Core reference data/ });
107
+ const unbuiltRow = screen.getByRole('row', { name: /Facility data/ });
108
+
109
+ // Status cell reflects the latest build's status, or falls back to "No builds".
110
+ expect(within(builtRow).getByText('COMPLETED')).toBeInTheDocument();
111
+ expect(within(unbuiltRow).getByText('No builds')).toBeInTheDocument();
112
+
113
+ // Empty entries render as "All domains"; otherwise the formatted domain labels.
114
+ expect(within(builtRow).getByText('Patient identifier type')).toBeInTheDocument();
115
+ expect(within(unbuiltRow).getByText('All domains')).toBeInTheDocument();
116
+
117
+ // Clicking View resolves the row back to its package via the packagesByUuid lookup.
118
+ await user.click(within(builtRow).getByRole('button', { name: 'View' }));
119
+ expect(mockLaunchViewPackageWorkspace).toHaveBeenLastCalledWith(builtPackage);
120
+
121
+ await user.click(within(unbuiltRow).getByRole('button', { name: 'View' }));
122
+ expect(mockLaunchViewPackageWorkspace).toHaveBeenLastCalledWith(unbuiltPackage);
123
+
124
+ expect(mockLaunchViewPackageWorkspace).toHaveBeenCalledTimes(2);
125
+ });
56
126
  });
@@ -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,93 @@
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
+ .sectionLabel {
27
+ @include type.type-style('heading-compact-01');
28
+ color: $text-02;
29
+ text-transform: uppercase;
30
+ letter-spacing: 0.02em;
31
+ }
32
+
33
+ .detailList {
34
+ display: grid;
35
+ grid-template-columns: max-content 1fr;
36
+ gap: layout.$spacing-03 layout.$spacing-06;
37
+ margin: 0;
38
+ }
39
+
40
+ .detailKey {
41
+ @include type.type-style('body-compact-01');
42
+ color: $text-02;
43
+ }
44
+
45
+ .detailValue {
46
+ @include type.type-style('body-compact-01');
47
+ color: $text-02;
48
+ margin: 0;
49
+ }
50
+
51
+ .buildList {
52
+ display: flex;
53
+ flex-direction: column;
54
+ list-style: none;
55
+ margin: 0;
56
+ padding: 0;
57
+ }
58
+
59
+ .buildItem {
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: layout.$spacing-02;
63
+ padding: layout.$spacing-04 0;
64
+ border-top: 1px solid $ui-03;
65
+ }
66
+
67
+ .buildRow {
68
+ display: flex;
69
+ justify-content: space-between;
70
+ align-items: center;
71
+ }
72
+
73
+ .buildVersion {
74
+ @include type.type-style('heading-compact-01');
75
+ color: $text-02;
76
+ }
77
+
78
+ .buildMeta {
79
+ @include type.type-style('body-compact-01');
80
+ color: $text-02;
81
+ }
82
+
83
+ .buildError {
84
+ @include type.type-style('body-compact-01');
85
+ color: $danger;
86
+ margin: 0;
87
+ }
88
+
89
+ .emptyBuilds {
90
+ @include type.type-style('body-compact-01');
91
+ color: $text-02;
92
+ margin: 0;
93
+ }
@@ -0,0 +1,170 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
4
+ import * as esmFramework from '@openmrs/esm-framework';
5
+ import ViewPackageWorkspace from './view-package.workspace';
6
+ import { usePackageBuilds } from '../../packages/packages.resource';
7
+ import { type ExportPackage, type ExportPackageBuild } from '../../types';
8
+
9
+ vi.mock('@openmrs/esm-framework', async (importOriginal) => {
10
+ const original = await importOriginal<typeof esmFramework>();
11
+ return {
12
+ ...original,
13
+ makeUrl: (path: string) => `${window.openmrsBase}${path}`,
14
+ };
15
+ });
16
+
17
+ vi.mock('../../packages/packages.resource', () => ({
18
+ usePackageBuilds: vi.fn(),
19
+ }));
20
+
21
+ const mockUsePackageBuilds = usePackageBuilds as Mock;
22
+ const mockUseSession = vi.mocked(esmFramework.useSession);
23
+ const mockUserHasAccess = vi.mocked(esmFramework.userHasAccess);
24
+
25
+ const sessionWithUser = () =>
26
+ ({ user: { uuid: 'cc8507b8-7c9a-486b-85dc-b8f25ad1e4cc' } }) as unknown as esmFramework.Session;
27
+ const mockCloseWorkspace = vi.fn();
28
+ const mockCloseWorkspaceWithSavedChanges = vi.fn();
29
+ const mockPromptBeforeClosing = vi.fn();
30
+
31
+ const build = (overrides: Partial<ExportPackageBuild> = {}): ExportPackageBuild => ({
32
+ uuid: '23dbfd25-eec7-4f4e-b26c-d95324346ce3',
33
+ packageUuid: '361d69db-c018-4545-87a7-e987e8af9e85',
34
+ version: 2,
35
+ status: 'COMPLETED',
36
+ dateCreated: Date.now() - 15 * 60 * 1000,
37
+ dateStarted: Date.now() - 10 * 60 * 1000,
38
+ dateCompleted: Date.now() - 5 * 60 * 1000,
39
+ errorMessage: null,
40
+ downloadUrl: '/download/build-1',
41
+ manifest: null,
42
+ ...overrides,
43
+ });
44
+
45
+ const exportPackage: ExportPackage = {
46
+ uuid: '361d69db-c018-4545-87a7-e987e8af9e85',
47
+ name: 'Core reference data',
48
+ description: 'Some notes',
49
+ retired: false,
50
+ dateCreated: Date.now(),
51
+ entries: [
52
+ { domain: 'CONCEPTS', itemUuids: [] },
53
+ { domain: 'ENCOUNTER_TYPES', itemUuids: [] },
54
+ ],
55
+ latestBuild: null,
56
+ };
57
+
58
+ function mockBuilds(overrides: Partial<ReturnType<typeof usePackageBuilds>> = {}) {
59
+ mockUsePackageBuilds.mockReturnValue({
60
+ builds: [],
61
+ isLoading: false,
62
+ isValidating: false,
63
+ error: undefined,
64
+ mutate: vi.fn(),
65
+ ...overrides,
66
+ });
67
+ }
68
+
69
+ function renderWorkspace(pkg: ExportPackage = exportPackage) {
70
+ render(
71
+ <ViewPackageWorkspace
72
+ exportPackage={pkg}
73
+ closeWorkspace={mockCloseWorkspace}
74
+ closeWorkspaceWithSavedChanges={mockCloseWorkspaceWithSavedChanges}
75
+ promptBeforeClosing={mockPromptBeforeClosing}
76
+ setTitle={vi.fn()}
77
+ />,
78
+ );
79
+ }
80
+
81
+ describe('ViewPackageWorkspace', () => {
82
+ beforeEach(() => {
83
+ mockBuilds();
84
+ mockUseSession.mockReturnValue(sessionWithUser());
85
+ mockUserHasAccess.mockReturnValue(true);
86
+ });
87
+
88
+ it('renders the package domains as human-readable labels', () => {
89
+ renderWorkspace();
90
+
91
+ expect(screen.getByText('Concepts, Encounter types')).toBeInTheDocument();
92
+ });
93
+
94
+ it('shows "All domains" when the package has no entries', () => {
95
+ renderWorkspace({ ...exportPackage, entries: [] });
96
+
97
+ expect(screen.getByText('All domains')).toBeInTheDocument();
98
+ });
99
+
100
+ it('shows a loading indicator while builds are loading', () => {
101
+ mockBuilds({ isLoading: true });
102
+ renderWorkspace();
103
+
104
+ expect(screen.getByText('Loading builds…')).toBeInTheDocument();
105
+ });
106
+
107
+ it('shows an error state when the builds request fails', () => {
108
+ mockBuilds({ error: new Error('Boom') });
109
+ renderWorkspace();
110
+
111
+ expect(screen.getByText('Error State')).toBeInTheDocument();
112
+ });
113
+
114
+ it('shows an empty message when there are no builds', () => {
115
+ renderWorkspace();
116
+
117
+ expect(screen.getByText('No builds yet')).toBeInTheDocument();
118
+ });
119
+
120
+ it('renders each build with its version, status, and download link', () => {
121
+ mockBuilds({ builds: [build()] });
122
+ renderWorkspace();
123
+
124
+ expect(screen.getByText('Build 2')).toBeInTheDocument();
125
+ expect(screen.getByText('COMPLETED')).toBeInTheDocument();
126
+ expect(screen.getByText(/^Started .* ago$/)).toBeInTheDocument();
127
+ expect(screen.getByText(/^Completed .* ago$/)).toBeInTheDocument();
128
+ expect(screen.getByRole('link', { name: 'Download' })).toHaveAttribute(
129
+ 'href',
130
+ `/openmrs/ws/rest/v1/metadataexport/builds/${build().uuid}/download`,
131
+ );
132
+ });
133
+
134
+ it('hides the download link from users without the Manage Metadata Export Packages privilege', () => {
135
+ mockUserHasAccess.mockReturnValue(false);
136
+ mockBuilds({ builds: [build()] });
137
+ renderWorkspace();
138
+
139
+ expect(screen.getByText('COMPLETED')).toBeInTheDocument();
140
+ expect(screen.queryByRole('link', { name: 'Download' })).not.toBeInTheDocument();
141
+ expect(mockUserHasAccess).toHaveBeenCalledWith('Manage Metadata Export Packages', expect.anything());
142
+ });
143
+
144
+ it('omits the download link for a build without a download URL', () => {
145
+ mockBuilds({ builds: [build({ status: 'QUEUED', downloadUrl: null, dateStarted: null, dateCompleted: null })] });
146
+ renderWorkspace();
147
+
148
+ expect(screen.getByText('QUEUED')).toBeInTheDocument();
149
+ expect(screen.getByText('Not started')).toBeInTheDocument();
150
+ expect(screen.queryByText(/^Completed/)).not.toBeInTheDocument();
151
+ expect(screen.queryByRole('link', { name: 'Download' })).not.toBeInTheDocument();
152
+ });
153
+
154
+ it('shows the failure reason for a failed build', () => {
155
+ mockBuilds({
156
+ builds: [build({ status: 'FAILED', downloadUrl: null, errorMessage: 'Serialization failed for concept 5497' })],
157
+ });
158
+ renderWorkspace();
159
+
160
+ expect(screen.getByText('FAILED')).toBeInTheDocument();
161
+ expect(screen.getByText('Serialization failed for concept 5497')).toBeInTheDocument();
162
+ });
163
+
164
+ it('does not show a failure reason for a non-failed build', () => {
165
+ mockBuilds({ builds: [build({ status: 'COMPLETED', errorMessage: 'stale error' })] });
166
+ renderWorkspace();
167
+
168
+ expect(screen.queryByText('stale error')).not.toBeInTheDocument();
169
+ });
170
+ });
@@ -0,0 +1,114 @@
1
+ import React, { useMemo } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { InlineLoading, Link, Tag } from '@carbon/react';
4
+ import { Download } from '@carbon/react/icons';
5
+ import {
6
+ type DefaultWorkspaceProps,
7
+ ErrorState,
8
+ formatDurationBetween,
9
+ makeUrl,
10
+ restBaseUrl,
11
+ useSession,
12
+ userHasAccess,
13
+ } from '@openmrs/esm-framework';
14
+ import { formatDomainLabel } from '../../domain-lookups/domain-lookups.resource';
15
+ import { usePackageBuilds } from '../../packages/packages.resource';
16
+ import { type ExportBuildStatus, type ExportPackage } from '../../types';
17
+ import styles from './view-package.workspace.scss';
18
+
19
+ interface ViewPackageWorkspaceProps extends DefaultWorkspaceProps {
20
+ exportPackage: ExportPackage;
21
+ }
22
+
23
+ const statusTagType: Record<ExportBuildStatus, 'gray' | 'blue' | 'green' | 'red'> = {
24
+ QUEUED: 'gray',
25
+ RUNNING: 'blue',
26
+ COMPLETED: 'green',
27
+ FAILED: 'red',
28
+ };
29
+
30
+ const ViewPackageWorkspace: React.FC<ViewPackageWorkspaceProps> = ({ exportPackage }) => {
31
+ const { t } = useTranslation();
32
+ const session = useSession();
33
+ const { builds, isLoading, error } = usePackageBuilds(exportPackage.uuid);
34
+
35
+ // Downloading a build hits a Manage-gated backend endpoint, so hide it from Get-only viewers.
36
+ const canManage = session.user ? userHasAccess('Manage Metadata Export Packages', session.user) : false;
37
+
38
+ const domainsLabel = useMemo(() => {
39
+ // An empty entries list means the package includes every registered domain.
40
+ const includesEveryDomain = exportPackage.entries.length === 0;
41
+ if (includesEveryDomain) {
42
+ return t('allDomains', 'All domains');
43
+ }
44
+
45
+ return exportPackage.entries.map((entry) => formatDomainLabel(entry.domain)).join(', ');
46
+ }, [exportPackage.entries, t]);
47
+
48
+ return (
49
+ <div className={styles.container}>
50
+ <div className={styles.body}>
51
+ <section className={styles.section}>
52
+ <span className={styles.sectionLabel}>{t('details', 'Details')}</span>
53
+ <dl className={styles.detailList}>
54
+ <dt className={styles.detailKey}>{t('domains', 'Domains')}</dt>
55
+ <dd className={styles.detailValue}>{domainsLabel}</dd>
56
+ </dl>
57
+ </section>
58
+
59
+ <section className={styles.section}>
60
+ <span className={styles.sectionLabel}>{t('builds', 'Builds')}</span>
61
+ {isLoading ? (
62
+ <InlineLoading description={t('loadingBuilds', 'Loading builds…')} />
63
+ ) : error ? (
64
+ <ErrorState error={error} headerTitle={t('builds', 'Builds')} />
65
+ ) : builds.length === 0 ? (
66
+ <p className={styles.emptyBuilds}>{t('noBuildsYet', 'No builds yet')}</p>
67
+ ) : (
68
+ <ul className={styles.buildList}>
69
+ {builds.map((build) => (
70
+ <li key={build.uuid} className={styles.buildItem}>
71
+ <div className={styles.buildRow}>
72
+ <span className={styles.buildVersion}>
73
+ {t('buildVersion', 'Build {{version}}', { version: build.version })}
74
+ </span>
75
+ <Tag type={statusTagType[build.status]} size="sm">
76
+ {t(build.status)}
77
+ </Tag>
78
+ </div>
79
+ <div className={styles.buildRow}>
80
+ <span className={styles.buildMeta}>
81
+ {build.dateStarted
82
+ ? t('startedAgo', 'Started {{time}} ago', { time: formatDurationBetween(build.dateStarted) })
83
+ : t('notStarted', 'Not started')}
84
+ </span>
85
+ {build.dateCompleted && (
86
+ <span className={styles.buildMeta}>
87
+ {t('completedAgo', 'Completed {{time}} ago', {
88
+ time: formatDurationBetween(build.dateCompleted),
89
+ })}
90
+ </span>
91
+ )}
92
+ </div>
93
+ {build.status === 'FAILED' && build.errorMessage && (
94
+ <p className={styles.buildError}>{build.errorMessage}</p>
95
+ )}
96
+ {canManage && build.downloadUrl && (
97
+ <Link
98
+ href={makeUrl(`${restBaseUrl}/metadataexport/builds/${build.uuid}/download`)}
99
+ renderIcon={() => <Download size={16} />}
100
+ >
101
+ {t('download', 'Download')}
102
+ </Link>
103
+ )}
104
+ </li>
105
+ ))}
106
+ </ul>
107
+ )}
108
+ </section>
109
+ </div>
110
+ </div>
111
+ );
112
+ };
113
+
114
+ export default ViewPackageWorkspace;
package/src/index.ts CHANGED
@@ -26,3 +26,8 @@ export const newPackageWorkspace = getAsyncLifecycle(
26
26
  () => import('./components/new-package/new-package.workspace'),
27
27
  options,
28
28
  );
29
+
30
+ export const viewPackageWorkspace = getAsyncLifecycle(
31
+ () => import('./components/view-package/view-package.workspace'),
32
+ options,
33
+ );
@@ -1,6 +1,6 @@
1
1
  import useSWR from 'swr';
2
2
  import { type FetchResponse, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
3
- import { type ExportPackage, type ExportPackageRequest } from '../types/index';
3
+ import type { ExportPackage, ExportPackageRequest, ExportPackageBuild } from '../types';
4
4
 
5
5
  export function useAllPackages(includeRetired = false) {
6
6
  const apiUrl = `${restBaseUrl}/metadataexport/packages?includeRetired=${includeRetired}`;
@@ -29,3 +29,18 @@ export function createPackage(
29
29
  signal: abortController?.signal,
30
30
  });
31
31
  }
32
+
33
+ export function usePackageBuilds(uuid: string) {
34
+ const apiUrl = `${restBaseUrl}/metadataexport/packages/${uuid}/builds`;
35
+ const { data, error, isLoading, isValidating, mutate } = useSWR<FetchResponse<Array<ExportPackageBuild>>, Error>(
36
+ apiUrl,
37
+ openmrsFetch,
38
+ );
39
+ return {
40
+ builds: data?.data ?? [],
41
+ isLoading,
42
+ isValidating,
43
+ error,
44
+ mutate,
45
+ };
46
+ }
package/src/routes.json CHANGED
@@ -26,6 +26,14 @@
26
26
  "type": "form",
27
27
  "canMaximize": true,
28
28
  "width": "narrow"
29
+ },
30
+ {
31
+ "name": "view-package-workspace",
32
+ "component": "viewPackageWorkspace",
33
+ "title": "View package",
34
+ "type": "form",
35
+ "canMaximize": true,
36
+ "width": "narrow"
29
37
  }
30
38
  ]
31
39
  }