@openmrs/esm-metadataexport-app 4.4.1-pre.684
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.
- package/.turbo/turbo-build.log +6 -0
- package/README.md +15 -0
- package/dist/105.js +1 -0
- package/dist/105.js.map +1 -0
- package/dist/117.js +1 -0
- package/dist/117.js.map +1 -0
- package/dist/121.js +1 -0
- package/dist/121.js.map +1 -0
- package/dist/192.js +1 -0
- package/dist/192.js.map +1 -0
- package/dist/309.js +1 -0
- package/dist/309.js.map +1 -0
- package/dist/339.js +1 -0
- package/dist/339.js.map +1 -0
- package/dist/359.js +11 -0
- package/dist/359.js.map +1 -0
- package/dist/425.js +32 -0
- package/dist/425.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/529.js +1 -0
- package/dist/529.js.map +1 -0
- package/dist/572.js +1 -0
- package/dist/572.js.map +1 -0
- package/dist/61.js +1 -0
- package/dist/61.js.map +1 -0
- package/dist/689.js +1 -0
- package/dist/689.js.map +1 -0
- package/dist/697.js +1 -0
- package/dist/697.js.map +1 -0
- package/dist/712.js +1 -0
- package/dist/712.js.map +1 -0
- package/dist/720.js +1 -0
- package/dist/720.js.map +1 -0
- package/dist/771.js +1 -0
- package/dist/771.js.map +1 -0
- package/dist/789.js +1 -0
- package/dist/789.js.map +1 -0
- package/dist/913.js +1 -0
- package/dist/913.js.map +1 -0
- package/dist/989.js +1 -0
- package/dist/989.js.map +1 -0
- package/dist/main.js +6 -0
- package/dist/main.js.map +1 -0
- package/dist/openmrs-esm-metadataexport-app.js +6 -0
- package/dist/openmrs-esm-metadataexport-app.js.buildmanifest.json +677 -0
- package/dist/openmrs-esm-metadataexport-app.js.map +1 -0
- package/dist/routes.json +1 -0
- package/package.json +56 -0
- package/rspack.config.js +1 -0
- package/src/admin-card-link.component.tsx +34 -0
- package/src/components/dashboard/dashboard-view.component.tsx +16 -0
- package/src/components/dashboard/home-dashboard.component.tsx +17 -0
- package/src/components/dashboard/home-dashboard.scss +18 -0
- package/src/components/metadata-export-header/metadata-export-header.component.tsx +16 -0
- package/src/components/metadata-export-header/metadata-export-header.scss +21 -0
- package/src/components/metadata-export-header/metadata-export-header.test.tsx +18 -0
- package/src/components/new-package/add-package-action-button/add-package-action-button.component.tsx +34 -0
- package/src/components/new-package/add-package-action-button/add-package-action-button.test.tsx +57 -0
- package/src/components/new-package/new-package-utils.ts +8 -0
- package/src/components/new-package/new-package.workspace.scss +58 -0
- package/src/components/new-package/new-package.workspace.test.tsx +206 -0
- package/src/components/new-package/new-package.workspace.tsx +197 -0
- package/src/components/packages-table/packages-table.component.tsx +122 -0
- package/src/components/packages-table/packages-table.scss +5 -0
- package/src/components/packages-table/packages-table.test.tsx +56 -0
- package/src/config-schema.ts +3 -0
- package/src/constants.ts +1 -0
- package/src/declarations.d.ts +23 -0
- package/src/domain-lookups/domain-lookups.resource.ts +33 -0
- package/src/index.ts +28 -0
- package/src/packages/packages.resource.ts +31 -0
- package/src/root.component.tsx +13 -0
- package/src/root.scss +5 -0
- package/src/root.test.tsx +12 -0
- package/src/routes.json +31 -0
- package/src/types/index.ts +41 -0
- package/translations/en.json +22 -0
- package/tsconfig.json +4 -0
- package/vitest.config.ts +4 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@carbon/type';
|
|
3
|
+
@use '@openmrs/esm-styleguide/src/vars' as *;
|
|
4
|
+
|
|
5
|
+
.form {
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
height: 100%;
|
|
9
|
+
justify-content: space-between;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.grid {
|
|
13
|
+
flex: 1 1 auto;
|
|
14
|
+
overflow-y: auto;
|
|
15
|
+
padding: layout.$spacing-05;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.section {
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: column;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.sectionLabel {
|
|
24
|
+
@include type.type-style('heading-compact-01');
|
|
25
|
+
color: $text-02;
|
|
26
|
+
margin-bottom: layout.$spacing-03;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.selectAll {
|
|
30
|
+
font-weight: 600;
|
|
31
|
+
border-bottom: 1px solid $ui-03;
|
|
32
|
+
padding-bottom: layout.$spacing-03;
|
|
33
|
+
margin-bottom: layout.$spacing-03;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.domainList {
|
|
37
|
+
display: flex;
|
|
38
|
+
flex-direction: column;
|
|
39
|
+
gap: layout.$spacing-03;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.desktopButtonSet {
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-shrink: 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.tabletButtonSet {
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-shrink: 0;
|
|
50
|
+
flex-direction: row-reverse;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.desktopButtonSet > .button,
|
|
54
|
+
.tabletButtonSet > .button {
|
|
55
|
+
flex: 1;
|
|
56
|
+
max-inline-size: none;
|
|
57
|
+
height: layout.$spacing-10;
|
|
58
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { OpenmrsFetchError, showSnackbar } from '@openmrs/esm-framework';
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
|
6
|
+
import { useDomains } from '../../domain-lookups/domain-lookups.resource';
|
|
7
|
+
import { createPackage } from '../../packages/packages.resource';
|
|
8
|
+
import NewPackageWorkspace from './new-package.workspace';
|
|
9
|
+
|
|
10
|
+
vi.mock('../../domain-lookups/domain-lookups.resource', async (importOriginal) => ({
|
|
11
|
+
...(await importOriginal<object>()),
|
|
12
|
+
useDomains: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('../../packages/packages.resource', () => ({
|
|
16
|
+
createPackage: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const mockUseDomains = useDomains as Mock;
|
|
20
|
+
const mockCreatePackage = createPackage as Mock;
|
|
21
|
+
const mockShowSnackbar = showSnackbar as Mock;
|
|
22
|
+
const mockCloseWorkspace = vi.fn();
|
|
23
|
+
const mockCloseWorkspaceWithSavedChanges = vi.fn();
|
|
24
|
+
const mockPromptBeforeClosing = vi.fn();
|
|
25
|
+
|
|
26
|
+
const domains = ['ATTRIBUTE_TYPES', 'CONCEPTS', 'ENCOUNTER_TYPES'];
|
|
27
|
+
|
|
28
|
+
// Builds an OpenmrsFetchError carrying a REST-style response body so we can
|
|
29
|
+
// exercise the component's server-error extraction (fieldErrors / error).
|
|
30
|
+
const fetchError = (responseBody: unknown) =>
|
|
31
|
+
new OpenmrsFetchError(
|
|
32
|
+
'/ws/rest/v1/metadataexport/packages',
|
|
33
|
+
new Response(null, { status: 400, statusText: 'Bad Request' }),
|
|
34
|
+
responseBody as never,
|
|
35
|
+
new Error(),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
function renderWorkspace() {
|
|
39
|
+
render(
|
|
40
|
+
<NewPackageWorkspace
|
|
41
|
+
closeWorkspace={mockCloseWorkspace}
|
|
42
|
+
closeWorkspaceWithSavedChanges={mockCloseWorkspaceWithSavedChanges}
|
|
43
|
+
promptBeforeClosing={mockPromptBeforeClosing}
|
|
44
|
+
setTitle={vi.fn()}
|
|
45
|
+
// @ts-expect-error - the workspace only uses closeWorkspace and promptBeforeClosing from the default props
|
|
46
|
+
additionalProps={{}}
|
|
47
|
+
/>,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('NewPackageWorkspace', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
mockUseDomains.mockReturnValue({ domains, isLoading: false, error: undefined });
|
|
54
|
+
mockCreatePackage.mockResolvedValue({ data: {} });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('renders a checkbox for each domain with a human-readable label', () => {
|
|
58
|
+
renderWorkspace();
|
|
59
|
+
|
|
60
|
+
expect(screen.getByRole('checkbox', { name: 'Attribute types' })).toBeInTheDocument();
|
|
61
|
+
expect(screen.getByRole('checkbox', { name: 'Concepts' })).toBeInTheDocument();
|
|
62
|
+
expect(screen.getByRole('checkbox', { name: 'Encounter types' })).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('shows a loading skeleton while domains are loading', () => {
|
|
66
|
+
mockUseDomains.mockReturnValue({ domains: [], isLoading: true, error: undefined });
|
|
67
|
+
renderWorkspace();
|
|
68
|
+
|
|
69
|
+
expect(screen.queryByRole('checkbox', { name: 'Select all' })).not.toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('shows an error notification when the domains request fails', () => {
|
|
73
|
+
mockUseDomains.mockReturnValue({ domains: [], isLoading: false, error: new Error('Boom') });
|
|
74
|
+
renderWorkspace();
|
|
75
|
+
|
|
76
|
+
expect(screen.getByText('Error loading domains')).toBeInTheDocument();
|
|
77
|
+
expect(screen.getByText('Boom')).toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('disables the submit button until a name and at least one domain are provided', async () => {
|
|
81
|
+
const user = userEvent.setup();
|
|
82
|
+
renderWorkspace();
|
|
83
|
+
|
|
84
|
+
const submitButton = screen.getByRole('button', { name: 'Create package' });
|
|
85
|
+
expect(submitButton).toBeDisabled();
|
|
86
|
+
|
|
87
|
+
await user.type(screen.getByRole('textbox', { name: 'Package name' }), 'Core reference data');
|
|
88
|
+
expect(submitButton).toBeDisabled();
|
|
89
|
+
|
|
90
|
+
await user.click(screen.getByRole('checkbox', { name: 'Concepts' }));
|
|
91
|
+
expect(submitButton).toBeEnabled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('selects and clears every domain via "Select all"', async () => {
|
|
95
|
+
const user = userEvent.setup();
|
|
96
|
+
renderWorkspace();
|
|
97
|
+
|
|
98
|
+
const selectAll = screen.getByRole('checkbox', { name: 'Select all' });
|
|
99
|
+
await user.click(selectAll);
|
|
100
|
+
|
|
101
|
+
for (const label of ['Attribute types', 'Concepts', 'Encounter types']) {
|
|
102
|
+
expect(screen.getByRole('checkbox', { name: label })).toBeChecked();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await user.click(selectAll);
|
|
106
|
+
for (const label of ['Attribute types', 'Concepts', 'Encounter types']) {
|
|
107
|
+
expect(screen.getByRole('checkbox', { name: label })).not.toBeChecked();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('POSTs the selected domains as entries, shows a snackbar, and closes the workspace', async () => {
|
|
112
|
+
const user = userEvent.setup();
|
|
113
|
+
renderWorkspace();
|
|
114
|
+
|
|
115
|
+
await user.type(screen.getByRole('textbox', { name: 'Package name' }), 'Core reference data');
|
|
116
|
+
await user.type(screen.getByRole('textbox', { name: 'Description' }), 'Some notes');
|
|
117
|
+
await user.click(screen.getByRole('checkbox', { name: 'Concepts' }));
|
|
118
|
+
await user.click(screen.getByRole('checkbox', { name: 'Encounter types' }));
|
|
119
|
+
await user.click(screen.getByRole('button', { name: 'Create package' }));
|
|
120
|
+
|
|
121
|
+
expect(mockCreatePackage).toHaveBeenCalledWith({
|
|
122
|
+
name: 'Core reference data',
|
|
123
|
+
description: 'Some notes',
|
|
124
|
+
entries: [{ domain: 'CONCEPTS' }, { domain: 'ENCOUNTER_TYPES' }],
|
|
125
|
+
});
|
|
126
|
+
expect(mockShowSnackbar).toHaveBeenCalledWith(
|
|
127
|
+
expect.objectContaining({ title: 'Package created', kind: 'success' }),
|
|
128
|
+
);
|
|
129
|
+
// Closes without triggering the "unsaved changes" prompt.
|
|
130
|
+
expect(mockCloseWorkspaceWithSavedChanges).toHaveBeenCalled();
|
|
131
|
+
expect(mockCloseWorkspace).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('sends an empty entries array when every domain is selected', async () => {
|
|
135
|
+
const user = userEvent.setup();
|
|
136
|
+
renderWorkspace();
|
|
137
|
+
|
|
138
|
+
await user.type(screen.getByRole('textbox', { name: 'Package name' }), 'Everything');
|
|
139
|
+
await user.click(screen.getByRole('checkbox', { name: 'Select all' }));
|
|
140
|
+
await user.click(screen.getByRole('button', { name: 'Create package' }));
|
|
141
|
+
|
|
142
|
+
expect(mockCreatePackage).toHaveBeenCalledWith(expect.objectContaining({ name: 'Everything', entries: [] }));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
async function submitValidPackage(user: ReturnType<typeof userEvent.setup>) {
|
|
146
|
+
await user.type(screen.getByRole('textbox', { name: 'Package name' }), 'Core reference data');
|
|
147
|
+
await user.click(screen.getByRole('checkbox', { name: 'Concepts' }));
|
|
148
|
+
await user.click(screen.getByRole('button', { name: 'Create package' }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
it('shows a generic error snackbar and keeps the workspace open for an unexpected error', async () => {
|
|
152
|
+
mockCreatePackage.mockRejectedValue(new Error('Server exploded'));
|
|
153
|
+
const user = userEvent.setup();
|
|
154
|
+
renderWorkspace();
|
|
155
|
+
|
|
156
|
+
await submitValidPackage(user);
|
|
157
|
+
|
|
158
|
+
expect(mockShowSnackbar).toHaveBeenCalledWith(
|
|
159
|
+
expect.objectContaining({
|
|
160
|
+
title: 'Failed to create package',
|
|
161
|
+
subtitle: 'An unexpected error occurred',
|
|
162
|
+
kind: 'error',
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
expect(mockCloseWorkspace).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('surfaces the server error message from an OpenmrsFetchError', async () => {
|
|
169
|
+
mockCreatePackage.mockRejectedValue(fetchError({ error: 'A package with that name already exists' }));
|
|
170
|
+
const user = userEvent.setup();
|
|
171
|
+
renderWorkspace();
|
|
172
|
+
|
|
173
|
+
await submitValidPackage(user);
|
|
174
|
+
|
|
175
|
+
expect(mockShowSnackbar).toHaveBeenCalledWith(
|
|
176
|
+
expect.objectContaining({
|
|
177
|
+
title: 'Failed to create package',
|
|
178
|
+
subtitle: 'A package with that name already exists',
|
|
179
|
+
kind: 'error',
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
expect(mockCloseWorkspace).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('surfaces the first field error from an OpenmrsFetchError', async () => {
|
|
186
|
+
mockCreatePackage.mockRejectedValue(fetchError({ fieldErrors: { name: 'Name must be unique' } }));
|
|
187
|
+
const user = userEvent.setup();
|
|
188
|
+
renderWorkspace();
|
|
189
|
+
|
|
190
|
+
await submitValidPackage(user);
|
|
191
|
+
|
|
192
|
+
expect(mockShowSnackbar).toHaveBeenCalledWith(
|
|
193
|
+
expect.objectContaining({ title: 'Failed to create package', subtitle: 'Name must be unique', kind: 'error' }),
|
|
194
|
+
);
|
|
195
|
+
expect(mockCloseWorkspace).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('closes the workspace when Cancel is clicked', async () => {
|
|
199
|
+
const user = userEvent.setup();
|
|
200
|
+
renderWorkspace();
|
|
201
|
+
|
|
202
|
+
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
203
|
+
|
|
204
|
+
expect(mockCloseWorkspace).toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
ButtonSet,
|
|
6
|
+
Checkbox,
|
|
7
|
+
CheckboxSkeleton,
|
|
8
|
+
Form,
|
|
9
|
+
InlineLoading,
|
|
10
|
+
InlineNotification,
|
|
11
|
+
Stack,
|
|
12
|
+
TextArea,
|
|
13
|
+
TextInput,
|
|
14
|
+
} from '@carbon/react';
|
|
15
|
+
import { useSWRConfig } from 'swr';
|
|
16
|
+
import {
|
|
17
|
+
type DefaultWorkspaceProps,
|
|
18
|
+
restBaseUrl,
|
|
19
|
+
showSnackbar,
|
|
20
|
+
useLayoutType,
|
|
21
|
+
OpenmrsFetchError,
|
|
22
|
+
} from '@openmrs/esm-framework';
|
|
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';
|
|
26
|
+
|
|
27
|
+
const packagesUrl = `${restBaseUrl}/metadataexport/packages`;
|
|
28
|
+
const isPackagesCacheKey = (key: unknown) => typeof key === 'string' && key.startsWith(packagesUrl);
|
|
29
|
+
|
|
30
|
+
const NewPackageWorkspace: React.FC<DefaultWorkspaceProps> = ({
|
|
31
|
+
closeWorkspace,
|
|
32
|
+
closeWorkspaceWithSavedChanges,
|
|
33
|
+
promptBeforeClosing,
|
|
34
|
+
}) => {
|
|
35
|
+
const { t } = useTranslation();
|
|
36
|
+
const { mutate } = useSWRConfig();
|
|
37
|
+
const isTablet = useLayoutType() === 'tablet';
|
|
38
|
+
const { domains, isLoading, error } = useDomains();
|
|
39
|
+
|
|
40
|
+
const [packageName, setPackageName] = useState('');
|
|
41
|
+
const [description, setDescription] = useState('');
|
|
42
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
43
|
+
const [selectedDomains, setSelectedDomains] = useState<Set<string>>(new Set());
|
|
44
|
+
|
|
45
|
+
const allSelected = domains.length > 0 && selectedDomains.size === domains.length;
|
|
46
|
+
const someSelected = selectedDomains.size > 0 && !allSelected;
|
|
47
|
+
|
|
48
|
+
const toggleSelectAll = useCallback(
|
|
49
|
+
(checked: boolean) => {
|
|
50
|
+
setSelectedDomains(checked ? new Set(domains) : new Set());
|
|
51
|
+
},
|
|
52
|
+
[domains],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const toggleDomain = useCallback((domain: string, checked: boolean) => {
|
|
56
|
+
setSelectedDomains((previous) => {
|
|
57
|
+
const next = new Set(previous);
|
|
58
|
+
if (checked) {
|
|
59
|
+
next.add(domain);
|
|
60
|
+
} else {
|
|
61
|
+
next.delete(domain);
|
|
62
|
+
}
|
|
63
|
+
return next;
|
|
64
|
+
});
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
const hasUnsavedChanges = packageName.trim().length > 0 || description.trim().length > 0 || selectedDomains.size > 0;
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
promptBeforeClosing(() => hasUnsavedChanges);
|
|
71
|
+
}, [hasUnsavedChanges, promptBeforeClosing]);
|
|
72
|
+
|
|
73
|
+
const handleSubmit = useCallback(
|
|
74
|
+
async (event: React.FormEvent<HTMLFormElement>) => {
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
setIsSubmitting(true);
|
|
77
|
+
|
|
78
|
+
// An explicit empty array tells the server to include every registered domain.
|
|
79
|
+
const entries = allSelected ? [] : Array.from(selectedDomains, (domain) => ({ domain }));
|
|
80
|
+
|
|
81
|
+
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
|
+
});
|
|
91
|
+
// Bypass the "unsaved changes" prompt now that the package is persisted.
|
|
92
|
+
closeWorkspaceWithSavedChanges();
|
|
93
|
+
} catch (submitError) {
|
|
94
|
+
const responseBody = submitError instanceof OpenmrsFetchError ? submitError.responseBody : null;
|
|
95
|
+
const reason =
|
|
96
|
+
typeof responseBody === 'object' && responseBody !== null
|
|
97
|
+
? (Object.values(responseBody.fieldErrors ?? {})[0] ?? responseBody.error)
|
|
98
|
+
: 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
|
+
});
|
|
105
|
+
} finally {
|
|
106
|
+
setIsSubmitting(false);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
[allSelected, closeWorkspaceWithSavedChanges, description, mutate, packageName, selectedDomains, t],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const isSubmitDisabled = packageName.trim().length === 0 || selectedDomains.size === 0 || isSubmitting;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<Form className={styles.form} onSubmit={handleSubmit}>
|
|
116
|
+
<div className={styles.grid}>
|
|
117
|
+
<Stack gap={6}>
|
|
118
|
+
<section className={styles.section}>
|
|
119
|
+
<TextInput
|
|
120
|
+
id="packageName"
|
|
121
|
+
labelText={t('packageName', 'Package name')}
|
|
122
|
+
placeholder={t('packageNamePlaceholder', 'e.g. Core reference data')}
|
|
123
|
+
value={packageName}
|
|
124
|
+
onChange={(event) => setPackageName(event.target.value)}
|
|
125
|
+
/>
|
|
126
|
+
</section>
|
|
127
|
+
|
|
128
|
+
<section className={styles.section}>
|
|
129
|
+
<TextArea
|
|
130
|
+
id="packageDescription"
|
|
131
|
+
labelText={t('description', 'Description')}
|
|
132
|
+
placeholder={t('descriptionPlaceholder', 'Briefly describe what this package contains')}
|
|
133
|
+
value={description}
|
|
134
|
+
onChange={(event) => setDescription(event.target.value)}
|
|
135
|
+
/>
|
|
136
|
+
</section>
|
|
137
|
+
|
|
138
|
+
<section className={styles.section}>
|
|
139
|
+
<span className={styles.sectionLabel}>{t('domains', 'Domains')}</span>
|
|
140
|
+
|
|
141
|
+
{error ? (
|
|
142
|
+
<InlineNotification
|
|
143
|
+
kind="error"
|
|
144
|
+
lowContrast
|
|
145
|
+
title={t('errorLoadingDomains', 'Error loading domains')}
|
|
146
|
+
subtitle={error?.message}
|
|
147
|
+
/>
|
|
148
|
+
) : isLoading ? (
|
|
149
|
+
<div className={styles.domainList}>
|
|
150
|
+
{Array.from({ length: 8 }, (_, index) => (
|
|
151
|
+
<CheckboxSkeleton key={index} />
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
) : (
|
|
155
|
+
<>
|
|
156
|
+
<Checkbox
|
|
157
|
+
id="select-all-domains"
|
|
158
|
+
className={styles.selectAll}
|
|
159
|
+
labelText={t('selectAll', 'Select all')}
|
|
160
|
+
checked={allSelected}
|
|
161
|
+
indeterminate={someSelected}
|
|
162
|
+
onChange={(_event, { checked }) => toggleSelectAll(checked)}
|
|
163
|
+
/>
|
|
164
|
+
<div className={styles.domainList}>
|
|
165
|
+
{domains.map((domain) => (
|
|
166
|
+
<Checkbox
|
|
167
|
+
key={domain}
|
|
168
|
+
id={`domain-${domain}`}
|
|
169
|
+
labelText={formatDomainLabel(domain)}
|
|
170
|
+
checked={selectedDomains.has(domain)}
|
|
171
|
+
onChange={(_event, { checked }) => toggleDomain(domain, checked)}
|
|
172
|
+
/>
|
|
173
|
+
))}
|
|
174
|
+
</div>
|
|
175
|
+
</>
|
|
176
|
+
)}
|
|
177
|
+
</section>
|
|
178
|
+
</Stack>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<ButtonSet className={isTablet ? styles.tabletButtonSet : styles.desktopButtonSet}>
|
|
182
|
+
<Button className={styles.button} kind="secondary" onClick={() => closeWorkspace()}>
|
|
183
|
+
{t('cancel', 'Cancel')}
|
|
184
|
+
</Button>
|
|
185
|
+
<Button className={styles.button} kind="primary" type="submit" disabled={isSubmitDisabled}>
|
|
186
|
+
{isSubmitting ? (
|
|
187
|
+
<InlineLoading description={t('creating', 'Creating') + '…'} />
|
|
188
|
+
) : (
|
|
189
|
+
t('createPackage', 'Create package')
|
|
190
|
+
)}
|
|
191
|
+
</Button>
|
|
192
|
+
</ButtonSet>
|
|
193
|
+
</Form>
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export default NewPackageWorkspace;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
DataTable,
|
|
5
|
+
DataTableSkeleton,
|
|
6
|
+
Table,
|
|
7
|
+
TableBody,
|
|
8
|
+
TableCell,
|
|
9
|
+
TableContainer,
|
|
10
|
+
TableHead,
|
|
11
|
+
TableHeader,
|
|
12
|
+
TableRow,
|
|
13
|
+
} from '@carbon/react';
|
|
14
|
+
import {
|
|
15
|
+
CardHeader,
|
|
16
|
+
EmptyCard,
|
|
17
|
+
ErrorState,
|
|
18
|
+
isDesktop,
|
|
19
|
+
useLayoutType,
|
|
20
|
+
useSession,
|
|
21
|
+
userHasAccess,
|
|
22
|
+
} from '@openmrs/esm-framework';
|
|
23
|
+
import { formatDomainLabel } from '../../domain-lookups/domain-lookups.resource';
|
|
24
|
+
import { useAllPackages } from '../../packages/packages.resource';
|
|
25
|
+
import { launchAddNewPackageWorkspace } from '../new-package/new-package-utils';
|
|
26
|
+
import styles from './packages-table.scss';
|
|
27
|
+
|
|
28
|
+
const PackagesTable: React.FC = () => {
|
|
29
|
+
const { t } = useTranslation();
|
|
30
|
+
const layout = useLayoutType();
|
|
31
|
+
const session = useSession();
|
|
32
|
+
const { packages, isLoading, error } = useAllPackages();
|
|
33
|
+
|
|
34
|
+
const canManage = session.user ? userHasAccess('Manage Metadata Export Packages', session.user) : false;
|
|
35
|
+
|
|
36
|
+
const headerTitle = t('packages', 'Packages');
|
|
37
|
+
|
|
38
|
+
const headers = useMemo(
|
|
39
|
+
() => [
|
|
40
|
+
{ key: 'name', header: t('packageName', 'Package name') },
|
|
41
|
+
{ key: 'domains', header: t('domains', 'Domains') },
|
|
42
|
+
{ key: 'description', header: t('description', 'Description') },
|
|
43
|
+
],
|
|
44
|
+
[t],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const rows = useMemo(
|
|
48
|
+
() =>
|
|
49
|
+
packages.map((exportPackage) => ({
|
|
50
|
+
id: exportPackage.uuid,
|
|
51
|
+
name: exportPackage.name,
|
|
52
|
+
// An empty entries list means the package includes every registered domain.
|
|
53
|
+
domains: exportPackage.entries.length
|
|
54
|
+
? exportPackage.entries.map((entry) => formatDomainLabel(entry.domain)).join(', ')
|
|
55
|
+
: t('allDomains', 'All domains'),
|
|
56
|
+
description: exportPackage.description,
|
|
57
|
+
})),
|
|
58
|
+
[packages, t],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (isLoading) {
|
|
62
|
+
return (
|
|
63
|
+
<div className={styles.container}>
|
|
64
|
+
<DataTableSkeleton role="progressbar" columnCount={headers.length} zebra />
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (error) {
|
|
70
|
+
return (
|
|
71
|
+
<div className={styles.container}>
|
|
72
|
+
<ErrorState error={error} headerTitle={headerTitle} />
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!packages.length) {
|
|
78
|
+
return (
|
|
79
|
+
<div className={styles.container}>
|
|
80
|
+
<EmptyCard
|
|
81
|
+
displayText={t('packages__lower', 'packages')}
|
|
82
|
+
headerTitle={headerTitle}
|
|
83
|
+
launchForm={canManage ? () => launchAddNewPackageWorkspace(t) : undefined}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className={styles.container}>
|
|
91
|
+
<CardHeader title={headerTitle} />
|
|
92
|
+
<DataTable rows={rows} headers={headers} size={isDesktop(layout) ? 'sm' : 'lg'} useZebraStyles>
|
|
93
|
+
{({ rows, headers, getHeaderProps, getRowProps, getTableProps }) => (
|
|
94
|
+
<TableContainer>
|
|
95
|
+
<Table {...getTableProps()}>
|
|
96
|
+
<TableHead>
|
|
97
|
+
<TableRow>
|
|
98
|
+
{headers.map((header) => (
|
|
99
|
+
<TableHeader {...getHeaderProps({ header })} key={header.key}>
|
|
100
|
+
{header.header}
|
|
101
|
+
</TableHeader>
|
|
102
|
+
))}
|
|
103
|
+
</TableRow>
|
|
104
|
+
</TableHead>
|
|
105
|
+
<TableBody>
|
|
106
|
+
{rows.map((row) => (
|
|
107
|
+
<TableRow {...getRowProps({ row })} key={row.id}>
|
|
108
|
+
{row.cells.map((cell) => (
|
|
109
|
+
<TableCell key={cell.id}>{cell.value}</TableCell>
|
|
110
|
+
))}
|
|
111
|
+
</TableRow>
|
|
112
|
+
))}
|
|
113
|
+
</TableBody>
|
|
114
|
+
</Table>
|
|
115
|
+
</TableContainer>
|
|
116
|
+
)}
|
|
117
|
+
</DataTable>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export default PackagesTable;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { type Session, useSession, userHasAccess } from '@openmrs/esm-framework';
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { useAllPackages } from '../../packages/packages.resource';
|
|
7
|
+
import { launchAddNewPackageWorkspace } from '../new-package/new-package-utils';
|
|
8
|
+
import PackagesTable from './packages-table.component';
|
|
9
|
+
|
|
10
|
+
vi.mock('../../packages/packages.resource', async (importOriginal) => ({
|
|
11
|
+
...(await importOriginal<object>()),
|
|
12
|
+
useAllPackages: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('../new-package/new-package-utils', () => ({
|
|
16
|
+
launchAddNewPackageWorkspace: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const mockUseAllPackages = vi.mocked(useAllPackages);
|
|
20
|
+
const mockUseSession = vi.mocked(useSession);
|
|
21
|
+
const mockUserHasAccess = vi.mocked(userHasAccess);
|
|
22
|
+
const mockLaunchAddNewPackageWorkspace = vi.mocked(launchAddNewPackageWorkspace);
|
|
23
|
+
|
|
24
|
+
const sessionWithUser = () => ({ user: { uuid: 'cc8507b8-7c9a-486b-85dc-b8f25ad1e4cc' } }) as unknown as Session;
|
|
25
|
+
|
|
26
|
+
describe('PackagesTable', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
mockUseAllPackages.mockReturnValue({
|
|
29
|
+
packages: [],
|
|
30
|
+
isLoading: false,
|
|
31
|
+
isValidating: false,
|
|
32
|
+
error: undefined,
|
|
33
|
+
mutate: vi.fn(),
|
|
34
|
+
});
|
|
35
|
+
mockUseSession.mockReturnValue(sessionWithUser());
|
|
36
|
+
mockUserHasAccess.mockReturnValue(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('allows privileged users to launch the new package workspace from the empty state', async () => {
|
|
40
|
+
const user = userEvent.setup();
|
|
41
|
+
render(<PackagesTable />);
|
|
42
|
+
|
|
43
|
+
await user.click(screen.getByRole('button', { name: 'Record packages' }));
|
|
44
|
+
|
|
45
|
+
expect(mockUserHasAccess).toHaveBeenCalledWith('Manage Metadata Export Packages', expect.anything());
|
|
46
|
+
expect(mockLaunchAddNewPackageWorkspace).toHaveBeenCalledTimes(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not offer the empty-state action to users without the manage privilege', () => {
|
|
50
|
+
mockUserHasAccess.mockReturnValue(false);
|
|
51
|
+
render(<PackagesTable />);
|
|
52
|
+
|
|
53
|
+
expect(screen.queryByRole('button', { name: 'Record packages' })).not.toBeInTheDocument();
|
|
54
|
+
expect(mockLaunchAddNewPackageWorkspace).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const moduleName = '@openmrs/esm-metadataexport-app';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
declare module '*.scss' {
|
|
2
|
+
const content: { [className: string]: string };
|
|
3
|
+
export default content;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
declare module '*.css' {
|
|
7
|
+
const content: { [className: string]: string };
|
|
8
|
+
export default content;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare interface RequireContext {
|
|
12
|
+
keys(): string[];
|
|
13
|
+
(id: string): unknown;
|
|
14
|
+
<T>(id: string): T;
|
|
15
|
+
resolve(id: string): string;
|
|
16
|
+
id: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
declare namespace NodeJS {
|
|
20
|
+
interface Require {
|
|
21
|
+
context(directory: string, useSubdirectories?: boolean, regExp?: RegExp, mode?: string): RequireContext;
|
|
22
|
+
}
|
|
23
|
+
}
|