@eeacms/volto-marine-policy 3.0.1 → 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/jest-addon.config.js +1 -1
- package/package.json +3 -3
- package/src/components/Widgets/NISStatusWidget.jsx +13 -0
- package/src/components/theme/NISListingView/NISListingView.jsx +122 -18
- package/src/components/theme/NISListingView/NISListingView.test.jsx +444 -0
- package/src/index.js +2 -0
- package/src/components/Widgets/TextAlign.test.jsx +0 -33
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
### [3.0.3](https://github.com/eea/volto-marine-policy/compare/3.0.2...3.0.3) - 11 June 2026
|
|
8
|
+
|
|
9
|
+
#### :rocket: New Features
|
|
10
|
+
|
|
11
|
+
- feat: added button to show NIS duplicates [laszlocseh - [`591927c`](https://github.com/eea/volto-marine-policy/commit/591927ca76c693d8d33d7b9ab8bdedc10368c0f2)]
|
|
12
|
+
- feat: added button to copy a NIS listing item [laszlocseh - [`588a5fe`](https://github.com/eea/volto-marine-policy/commit/588a5fe1409292a5df742590d615e230dc09ebd9)]
|
|
13
|
+
|
|
14
|
+
#### :hammer_and_wrench: Others
|
|
15
|
+
|
|
16
|
+
- test: fix NISListingView.test.jsx [laszlocseh - [`0ba486d`](https://github.com/eea/volto-marine-policy/commit/0ba486d1058bc71fe73ef53e628fd7c5423688ae)]
|
|
17
|
+
- test: added NISListingView.test.jsx [laszlocseh - [`4c54331`](https://github.com/eea/volto-marine-policy/commit/4c54331dd40f178dfd88dcc0d73d1eb96d2834e6)]
|
|
18
|
+
### [3.0.2](https://github.com/eea/volto-marine-policy/compare/3.0.1...3.0.2) - 10 June 2026
|
|
19
|
+
|
|
20
|
+
#### :rocket: Dependency updates
|
|
21
|
+
|
|
22
|
+
- Release @eeacms/volto-openlayers-map@2.0.1 [EEA Jenkins - [`8c4dbb3`](https://github.com/eea/volto-marine-policy/commit/8c4dbb3f7e95dfa5f1247ad5912ad141a7c3a077)]
|
|
23
|
+
|
|
24
|
+
#### :bug: Bug Fixes
|
|
25
|
+
|
|
26
|
+
- fix: custom NIS status widget to not show 'No value' [laszlocseh - [`7823b63`](https://github.com/eea/volto-marine-policy/commit/7823b63f2f8a042368a7dc2156f92e3ebc5722f1)]
|
|
27
|
+
- fix: format assigned to in NisListingView [laszlocseh - [`702d3fd`](https://github.com/eea/volto-marine-policy/commit/702d3fdbeabad42f270499f29c0c2e8f4212e5dd)]
|
|
28
|
+
|
|
29
|
+
#### :hammer_and_wrench: Others
|
|
30
|
+
|
|
31
|
+
- test: remove TextAlign.test.jsx [laszlocseh - [`3f8a47f`](https://github.com/eea/volto-marine-policy/commit/3f8a47f035e55313e83b9a686dd635eb30e1c012)]
|
|
32
|
+
- test: fix TextAlign.test.jsx snapshot [laszlocseh - [`94b1431`](https://github.com/eea/volto-marine-policy/commit/94b1431295dda5a4a457398521c862a78fa4f5f0)]
|
|
33
|
+
- fix jest-config [nileshgulia1 - [`866cbb9`](https://github.com/eea/volto-marine-policy/commit/866cbb980d2238f74a164047e4660598b31f793f)]
|
|
7
34
|
### [3.0.1](https://github.com/eea/volto-marine-policy/compare/3.0.0...3.0.1) - 28 April 2026
|
|
8
35
|
|
|
9
36
|
#### :house: Internal changes
|
package/jest-addon.config.js
CHANGED
|
@@ -66,7 +66,7 @@ module.exports = {
|
|
|
66
66
|
},
|
|
67
67
|
...(process.env.JEST_USE_SETUP === 'ON' && {
|
|
68
68
|
setupFilesAfterEnv: [
|
|
69
|
-
'<rootDir>/node_modules/@eeacms/volto-
|
|
69
|
+
'<rootDir>/node_modules/@eeacms/volto-marine-policy/jest.setup.js',
|
|
70
70
|
],
|
|
71
71
|
}),
|
|
72
72
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eeacms/volto-marine-policy",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"description": "@eeacms/volto-marine-policy: Volto add-on",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"author": "European Environment Agency: IDM2 A-Team",
|
|
@@ -33,14 +33,14 @@
|
|
|
33
33
|
"@elastic/search-ui": "1.21.2"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
+
"@eeacms/volto-block-style": "9.0.1",
|
|
36
37
|
"@eeacms/volto-eea-design-system": "*",
|
|
37
38
|
"@eeacms/volto-eea-website-theme": "*",
|
|
38
39
|
"@eeacms/volto-embed": "*",
|
|
39
40
|
"@eeacms/volto-globalsearch": "^2.0.0",
|
|
40
41
|
"@eeacms/volto-metadata-block": "*",
|
|
41
|
-
"@eeacms/volto-openlayers-map": "2.0.
|
|
42
|
+
"@eeacms/volto-openlayers-map": "2.0.1",
|
|
42
43
|
"@eeacms/volto-workflow-progress": "*",
|
|
43
|
-
"@eeacms/volto-block-style": "9.0.1",
|
|
44
44
|
"axios": "0.30.0",
|
|
45
45
|
"d3-array": "^2.12.1",
|
|
46
46
|
"jquery": "3.6.0",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import SelectWidget from '@plone/volto/components/manage/Widgets/SelectWidget';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* NISStatusWidget — wraps the default SelectWidget and disables the
|
|
6
|
+
* client-side "No value" option that Volto injects for hardcoded-choice
|
|
7
|
+
* fields when noValueOption=true (the default) and default is nullish.
|
|
8
|
+
*/
|
|
9
|
+
const NISStatusWidget = (props) => (
|
|
10
|
+
<SelectWidget {...props} noValueOption={false} />
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
export default NISStatusWidget;
|
|
@@ -7,6 +7,7 @@ import { useState, useEffect } from 'react';
|
|
|
7
7
|
import { useSelector } from 'react-redux';
|
|
8
8
|
import { Checkbox } from 'semantic-ui-react';
|
|
9
9
|
import { Button, Select, Dimmer, Loader } from 'semantic-ui-react';
|
|
10
|
+
import UniversalLink from '@plone/volto/components/manage/UniversalLink/UniversalLink';
|
|
10
11
|
|
|
11
12
|
function normalizeQueryOperators(query) {
|
|
12
13
|
return query.map((q) => {
|
|
@@ -46,23 +47,40 @@ async function getCurrentSearchItems() {
|
|
|
46
47
|
|
|
47
48
|
// call Plone
|
|
48
49
|
try {
|
|
49
|
-
const response = await fetch(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
const response = await fetch(
|
|
51
|
+
`${window.env.apiPath}/++api++/@querystring-search`,
|
|
52
|
+
{
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify(payload),
|
|
53
58
|
},
|
|
54
|
-
|
|
55
|
-
});
|
|
59
|
+
);
|
|
56
60
|
return response;
|
|
57
61
|
} catch (err) {
|
|
58
62
|
// console.error('Querystring search failed:', err);
|
|
59
63
|
}
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
function formatAssignedTo(assignedTo) {
|
|
67
|
+
if (!assignedTo) return '';
|
|
68
|
+
// Fix Python-style unicode escape sequences (\UXXXXXXXX -> actual char)
|
|
69
|
+
let result = assignedTo.replace(/\\U([0-9A-Fa-f]{8})/g, (_, hex) =>
|
|
70
|
+
String.fromCodePoint(parseInt(hex, 16)),
|
|
71
|
+
);
|
|
72
|
+
// Strip the "(userid)" suffix to show only the display name
|
|
73
|
+
result = result.replace(/\s*\([^)]+\)\s*$/, '').trim();
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
const NISListingView = ({ items, isEditMode }) => {
|
|
63
78
|
const [isLoading, setIsLoading] = useState(false);
|
|
64
79
|
const [selectedItems, setSelectedItems] = useState([]);
|
|
65
80
|
const [itemsTotal, setItemsTotal] = useState(0);
|
|
81
|
+
const [duplicateIds, setDuplicateIds] = useState(null);
|
|
82
|
+
const [duplicateGroups, setDuplicateGroups] = useState([]);
|
|
83
|
+
|
|
66
84
|
const [users, setUsers] = useState([]);
|
|
67
85
|
const [assignee, setAssignee] = useState(null);
|
|
68
86
|
const actions = useSelector((state) => state.actions.actions);
|
|
@@ -94,7 +112,7 @@ const NISListingView = ({ items, isEditMode }) => {
|
|
|
94
112
|
const onBulkAssign = async (ids, assignee) => {
|
|
95
113
|
setIsLoading(true);
|
|
96
114
|
await fetch(
|
|
97
|
-
`${window.
|
|
115
|
+
`${window.env.apiPath}/++api++/@bulk-assign${window.location.search}`,
|
|
98
116
|
{
|
|
99
117
|
method: 'POST',
|
|
100
118
|
headers: {
|
|
@@ -113,10 +131,47 @@ const NISListingView = ({ items, isEditMode }) => {
|
|
|
113
131
|
window.location.reload();
|
|
114
132
|
};
|
|
115
133
|
|
|
134
|
+
const handleCopy = async (item) => {
|
|
135
|
+
setIsLoading(true);
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(
|
|
138
|
+
`${window.env.apiPath}${item['@id']}/@copy-nis-record`,
|
|
139
|
+
{
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: {
|
|
142
|
+
'Content-Type': 'application/json',
|
|
143
|
+
Accept: 'application/json',
|
|
144
|
+
},
|
|
145
|
+
credentials: 'include',
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
if (res.ok) {
|
|
149
|
+
window.location.reload();
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
// eslint-disable-next-line no-console
|
|
153
|
+
console.error('Copy failed:', err);
|
|
154
|
+
setIsLoading(false);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
const parsed = qs.parse(window.location.search);
|
|
160
|
+
if (parsed['check-duplicates']) {
|
|
161
|
+
const containerPath = window.location.pathname.replace('/marine', '');
|
|
162
|
+
fetch(`${window.env.apiPath}${containerPath}/@check-nis-duplicates`)
|
|
163
|
+
.then((res) => res.json())
|
|
164
|
+
.then((data) => {
|
|
165
|
+
setDuplicateIds(new Set(data.duplicate_ids));
|
|
166
|
+
setDuplicateGroups(data.groups || []);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}, []);
|
|
170
|
+
|
|
116
171
|
useEffect(() => {
|
|
117
172
|
const fetchUsers = async () => {
|
|
118
173
|
const res = await fetch(
|
|
119
|
-
`${window.
|
|
174
|
+
`${window.env.apiPath}/++api++/@vocabularies/nis_experts_vocabulary`,
|
|
120
175
|
{
|
|
121
176
|
headers: {
|
|
122
177
|
Accept: 'application/json',
|
|
@@ -129,7 +184,7 @@ const NISListingView = ({ items, isEditMode }) => {
|
|
|
129
184
|
setUsers(
|
|
130
185
|
data.items.map((u) => ({
|
|
131
186
|
key: u.token,
|
|
132
|
-
text: u.title,
|
|
187
|
+
text: formatAssignedTo(u.title),
|
|
133
188
|
value: u.token,
|
|
134
189
|
})),
|
|
135
190
|
);
|
|
@@ -168,9 +223,45 @@ const NISListingView = ({ items, isEditMode }) => {
|
|
|
168
223
|
<i className="ri-file-download-line"></i>
|
|
169
224
|
Download search results
|
|
170
225
|
</a>
|
|
226
|
+
<Button
|
|
227
|
+
className="primary"
|
|
228
|
+
size="small"
|
|
229
|
+
onClick={() => {
|
|
230
|
+
const parsed = qs.parse(window.location.search);
|
|
231
|
+
parsed['check-duplicates'] = '1';
|
|
232
|
+
window.location.search = qs.stringify(parsed);
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
Check duplicates
|
|
236
|
+
</Button>
|
|
171
237
|
</div>
|
|
172
238
|
</div>
|
|
173
239
|
)}
|
|
240
|
+
{duplicateIds && (
|
|
241
|
+
<div
|
|
242
|
+
style={{
|
|
243
|
+
background: '#fff3cd',
|
|
244
|
+
border: '1px solid #ffc107',
|
|
245
|
+
padding: '10px 15px',
|
|
246
|
+
marginBottom: '15px',
|
|
247
|
+
borderRadius: '4px',
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
Showing {items.filter((i) => duplicateIds.has(i['@id'])).length}{' '}
|
|
251
|
+
duplicate records across {duplicateGroups.length} groups
|
|
252
|
+
<a
|
|
253
|
+
href={(() => {
|
|
254
|
+
const p = qs.parse(window.location.search);
|
|
255
|
+
delete p['check-duplicates'];
|
|
256
|
+
const q = qs.stringify(p);
|
|
257
|
+
return `${window.location.pathname}${q ? '?' + q : ''}`;
|
|
258
|
+
})()}
|
|
259
|
+
style={{ marginLeft: '10px', fontSize: '0.9em' }}
|
|
260
|
+
>
|
|
261
|
+
Clear
|
|
262
|
+
</a>
|
|
263
|
+
</div>
|
|
264
|
+
)}
|
|
174
265
|
<table className="ui table">
|
|
175
266
|
<thead>
|
|
176
267
|
<tr>
|
|
@@ -182,24 +273,29 @@ const NISListingView = ({ items, isEditMode }) => {
|
|
|
182
273
|
<th>Country</th>
|
|
183
274
|
<th>Status</th>
|
|
184
275
|
<th>Group</th>
|
|
276
|
+
<th>Year</th>
|
|
185
277
|
<th>Assigned to</th>
|
|
186
278
|
<th></th>
|
|
187
279
|
</tr>
|
|
188
280
|
</thead>
|
|
189
281
|
<tbody>
|
|
190
|
-
{
|
|
282
|
+
{(duplicateIds
|
|
283
|
+
? items.filter((item) => duplicateIds.has(item['@id']))
|
|
284
|
+
: items
|
|
285
|
+
).map((item, index) => (
|
|
191
286
|
<tr key={item['@id']}>
|
|
192
287
|
<td>{item.nis_species_name_original}</td>
|
|
193
288
|
<td>{item.nis_species_name_accepted}</td>
|
|
194
289
|
<td>{item.nis_scientificname_accepted}</td>
|
|
195
290
|
<td>{item.nis_region}</td>
|
|
196
291
|
<td>{item.nis_subregion}</td>
|
|
197
|
-
<td>{item.nis_country
|
|
292
|
+
<td>{item.nis_country}</td>
|
|
198
293
|
<td>{item.nis_status}</td>
|
|
199
294
|
<td>{item.nis_group}</td>
|
|
295
|
+
<td>{item.nis_year}</td>
|
|
200
296
|
<td>
|
|
201
297
|
<div className="assigned-to-container">
|
|
202
|
-
<div>{item.nis_assigned_to}</div>
|
|
298
|
+
<div>{formatAssignedTo(item.nis_assigned_to)}</div>
|
|
203
299
|
{canEditPage && (
|
|
204
300
|
<Checkbox
|
|
205
301
|
checked={selectedItems.includes(item['@id'])}
|
|
@@ -211,22 +307,30 @@ const NISListingView = ({ items, isEditMode }) => {
|
|
|
211
307
|
<td>
|
|
212
308
|
<div className="workflow-actions">
|
|
213
309
|
<div className="action-buttons">
|
|
214
|
-
<
|
|
310
|
+
<UniversalLink
|
|
215
311
|
className="ui button secondary mini"
|
|
216
312
|
href={`${item['@id']}`}
|
|
217
313
|
target="_blank"
|
|
218
|
-
rel="noopener"
|
|
314
|
+
rel="noopener noreferrer"
|
|
219
315
|
>
|
|
220
316
|
View
|
|
221
|
-
</
|
|
222
|
-
<
|
|
317
|
+
</UniversalLink>
|
|
318
|
+
<UniversalLink
|
|
223
319
|
className="ui button primary mini"
|
|
224
320
|
href={`${item['@id']}/edit`}
|
|
225
321
|
target="_blank"
|
|
226
|
-
rel="noopener"
|
|
322
|
+
rel="noopener noreferrer"
|
|
227
323
|
>
|
|
228
324
|
Edit
|
|
229
|
-
</
|
|
325
|
+
</UniversalLink>
|
|
326
|
+
{canEditPage && (
|
|
327
|
+
<Button
|
|
328
|
+
className="tertiary mini"
|
|
329
|
+
onClick={() => handleCopy(item)}
|
|
330
|
+
>
|
|
331
|
+
Copy
|
|
332
|
+
</Button>
|
|
333
|
+
)}
|
|
230
334
|
</div>
|
|
231
335
|
<div className="workflow-progress">
|
|
232
336
|
<ProgressWorkflow
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
3
|
+
import '@testing-library/jest-dom';
|
|
4
|
+
import configureStore from 'redux-mock-store';
|
|
5
|
+
import { Provider } from 'react-redux';
|
|
6
|
+
import NISListingView from './NISListingView';
|
|
7
|
+
|
|
8
|
+
const mockUseSelector = jest.fn();
|
|
9
|
+
|
|
10
|
+
jest.mock('react-redux', () => ({
|
|
11
|
+
__esModule: true,
|
|
12
|
+
...jest.requireActual('react-redux'),
|
|
13
|
+
useSelector: (selector) => mockUseSelector(selector),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
jest.mock(
|
|
17
|
+
'@plone/volto/components/manage/UniversalLink/UniversalLink',
|
|
18
|
+
() =>
|
|
19
|
+
function MockUniversalLink({ href, children, className }) {
|
|
20
|
+
return (
|
|
21
|
+
<a href={href} className={className}>
|
|
22
|
+
{children}
|
|
23
|
+
</a>
|
|
24
|
+
);
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
jest.mock(
|
|
29
|
+
'@eeacms/volto-marine-policy/components/theme/ProgressWorkflow/ProgressWorkflow',
|
|
30
|
+
() => ({
|
|
31
|
+
__esModule: true,
|
|
32
|
+
default: () => <div data-testid="progress-workflow" />,
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const mockStore = configureStore();
|
|
37
|
+
|
|
38
|
+
function buildItems(count = 3) {
|
|
39
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
40
|
+
'@id': `/marine/item-${i + 1}`,
|
|
41
|
+
nis_species_name_original: `Species ${i + 1}`,
|
|
42
|
+
nis_species_name_accepted: `Species ${i + 1} accepted`,
|
|
43
|
+
nis_scientificname_accepted: `Scientificus acceptus ${i + 1}`,
|
|
44
|
+
nis_region: 'Europe',
|
|
45
|
+
nis_subregion: 'Western Europe',
|
|
46
|
+
nis_country: 'France',
|
|
47
|
+
nis_status: 'Established',
|
|
48
|
+
nis_group: 'Fish',
|
|
49
|
+
nis_year: 2020 + i,
|
|
50
|
+
nis_assigned_to: `User ${i + 1} (user${i + 1})`,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderComponent(props = {}) {
|
|
55
|
+
const { items = buildItems(), ...rest } = props;
|
|
56
|
+
const store = mockStore({
|
|
57
|
+
intl: { locale: 'en', messages: {} },
|
|
58
|
+
});
|
|
59
|
+
return render(
|
|
60
|
+
<Provider store={store}>
|
|
61
|
+
<NISListingView items={items} {...rest} />
|
|
62
|
+
</Provider>,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('NISListingView', () => {
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
jest.clearAllMocks();
|
|
69
|
+
|
|
70
|
+
global.fetch = jest.fn(() =>
|
|
71
|
+
Promise.resolve({
|
|
72
|
+
json: () => Promise.resolve({ items: [] }),
|
|
73
|
+
ok: true,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
delete window.location;
|
|
78
|
+
window.location = {
|
|
79
|
+
href: 'http://localhost:3000/marine/test-path',
|
|
80
|
+
pathname: '/marine/test-path',
|
|
81
|
+
search: '',
|
|
82
|
+
reload: jest.fn(),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
window.env = { apiPath: 'http://localhost:8080/Plone' };
|
|
86
|
+
|
|
87
|
+
global.fetch.mockImplementation((url) => {
|
|
88
|
+
if (typeof url === 'string' && url.includes('nis_experts_vocabulary')) {
|
|
89
|
+
return Promise.resolve({
|
|
90
|
+
json: () =>
|
|
91
|
+
Promise.resolve({
|
|
92
|
+
items: [
|
|
93
|
+
{ token: 'jdoe', title: 'John Doe (jdoe)' },
|
|
94
|
+
{ token: 'asmith', title: 'Alice Smith (asmith)' },
|
|
95
|
+
],
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (typeof url === 'string' && url.includes('check-nis-duplicates')) {
|
|
100
|
+
return Promise.resolve({
|
|
101
|
+
json: () =>
|
|
102
|
+
Promise.resolve({
|
|
103
|
+
duplicate_ids: [],
|
|
104
|
+
groups: [],
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return Promise.resolve({
|
|
109
|
+
json: () => Promise.resolve({}),
|
|
110
|
+
ok: true,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
mockUseSelector.mockReturnValue({
|
|
115
|
+
object: [{ id: 'edit' }],
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('table rendering', () => {
|
|
120
|
+
it('renders all column headers', async () => {
|
|
121
|
+
renderComponent();
|
|
122
|
+
await waitFor(() => {
|
|
123
|
+
expect(screen.getByText('Species name original')).toBeInTheDocument();
|
|
124
|
+
});
|
|
125
|
+
expect(screen.getByText('Species name accepted')).toBeInTheDocument();
|
|
126
|
+
expect(screen.getByText('Scientific name accepted')).toBeInTheDocument();
|
|
127
|
+
expect(screen.getByText('Region')).toBeInTheDocument();
|
|
128
|
+
expect(screen.getByText('Subregion')).toBeInTheDocument();
|
|
129
|
+
expect(screen.getByText('Country')).toBeInTheDocument();
|
|
130
|
+
expect(screen.getByText('Status')).toBeInTheDocument();
|
|
131
|
+
expect(screen.getByText('Group')).toBeInTheDocument();
|
|
132
|
+
expect(screen.getByText('Year')).toBeInTheDocument();
|
|
133
|
+
expect(screen.getByText('Assigned to')).toBeInTheDocument();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('renders one row per item', async () => {
|
|
137
|
+
renderComponent({ items: buildItems(3) });
|
|
138
|
+
await waitFor(() => {
|
|
139
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
140
|
+
});
|
|
141
|
+
const rows = screen.getAllByRole('row');
|
|
142
|
+
expect(rows).toHaveLength(4);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('renders item data in correct columns', async () => {
|
|
146
|
+
const items = [
|
|
147
|
+
{
|
|
148
|
+
'@id': '/marine/item-x',
|
|
149
|
+
nis_species_name_original: 'Alienus maximus original',
|
|
150
|
+
nis_species_name_accepted: 'Alienus maximus (Linnaeus)',
|
|
151
|
+
nis_scientificname_accepted: 'Alienus maximus scientific',
|
|
152
|
+
nis_region: 'Asia',
|
|
153
|
+
nis_subregion: 'South Asia',
|
|
154
|
+
nis_country: 'India',
|
|
155
|
+
nis_status: 'Invasive',
|
|
156
|
+
nis_group: 'Plant',
|
|
157
|
+
nis_year: 2023,
|
|
158
|
+
nis_assigned_to: 'Jane Expert (jexpert)',
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
renderComponent({ items });
|
|
162
|
+
await waitFor(() => {
|
|
163
|
+
expect(
|
|
164
|
+
screen.getByText('Alienus maximus original'),
|
|
165
|
+
).toBeInTheDocument();
|
|
166
|
+
});
|
|
167
|
+
expect(
|
|
168
|
+
screen.getByText('Alienus maximus (Linnaeus)'),
|
|
169
|
+
).toBeInTheDocument();
|
|
170
|
+
expect(
|
|
171
|
+
screen.getByText('Alienus maximus scientific'),
|
|
172
|
+
).toBeInTheDocument();
|
|
173
|
+
expect(screen.getByText('Asia')).toBeInTheDocument();
|
|
174
|
+
expect(screen.getByText('South Asia')).toBeInTheDocument();
|
|
175
|
+
expect(screen.getByText('India')).toBeInTheDocument();
|
|
176
|
+
expect(screen.getByText('Invasive')).toBeInTheDocument();
|
|
177
|
+
expect(screen.getByText('Plant')).toBeInTheDocument();
|
|
178
|
+
expect(screen.getByText('2023')).toBeInTheDocument();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('renders an empty table body when items is empty', async () => {
|
|
182
|
+
renderComponent({ items: [] });
|
|
183
|
+
await waitFor(() => {
|
|
184
|
+
expect(screen.getByText('Species name original')).toBeInTheDocument();
|
|
185
|
+
});
|
|
186
|
+
const rows = screen.getAllByRole('row');
|
|
187
|
+
expect(rows).toHaveLength(1);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('formatAssignedTo — rendered output', () => {
|
|
192
|
+
it('strips the (userid) suffix from assigned_to', async () => {
|
|
193
|
+
const items = [
|
|
194
|
+
{
|
|
195
|
+
...buildItems(1)[0],
|
|
196
|
+
nis_assigned_to: 'Jane Expert (jexpert)',
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
renderComponent({ items });
|
|
200
|
+
await waitFor(() => {
|
|
201
|
+
expect(screen.getByText('Jane Expert')).toBeInTheDocument();
|
|
202
|
+
});
|
|
203
|
+
expect(screen.queryByText('Jane Expert (jexpert)')).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('decodes Python \\U escape sequences in assigned_to', async () => {
|
|
207
|
+
const items = [
|
|
208
|
+
{
|
|
209
|
+
...buildItems(1)[0],
|
|
210
|
+
nis_assigned_to:
|
|
211
|
+
'\\U000000d6sterreich User (\\U000000d6sterreichUser)',
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
renderComponent({ items });
|
|
215
|
+
await waitFor(() => {
|
|
216
|
+
expect(screen.getByText('Österreich User')).toBeInTheDocument();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('shows empty string when assigned_to is null', async () => {
|
|
221
|
+
const items = [
|
|
222
|
+
{
|
|
223
|
+
...buildItems(1)[0],
|
|
224
|
+
nis_assigned_to: null,
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
renderComponent({ items });
|
|
228
|
+
await waitFor(() => {
|
|
229
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
230
|
+
});
|
|
231
|
+
const containers = document.querySelectorAll('.assigned-to-container');
|
|
232
|
+
expect(containers.length).toBeGreaterThan(0);
|
|
233
|
+
expect(containers[0].firstChild.textContent).toBe('');
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('admin controls — canEditPage', () => {
|
|
238
|
+
it('shows assign, download, and check-duplicates buttons when user can edit', async () => {
|
|
239
|
+
mockUseSelector.mockReturnValue({
|
|
240
|
+
object: [{ id: 'edit' }],
|
|
241
|
+
});
|
|
242
|
+
renderComponent();
|
|
243
|
+
await waitFor(() => {
|
|
244
|
+
expect(screen.getByText('Assign search results')).toBeInTheDocument();
|
|
245
|
+
});
|
|
246
|
+
expect(screen.getByText('Download search results')).toBeInTheDocument();
|
|
247
|
+
expect(screen.getByText('Check duplicates')).toBeInTheDocument();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('hides admin controls when user cannot edit', async () => {
|
|
251
|
+
mockUseSelector.mockReturnValue({
|
|
252
|
+
object: [],
|
|
253
|
+
});
|
|
254
|
+
renderComponent();
|
|
255
|
+
await waitFor(() => {
|
|
256
|
+
expect(screen.getByText('Species name original')).toBeInTheDocument();
|
|
257
|
+
});
|
|
258
|
+
expect(
|
|
259
|
+
screen.queryByText('Assign search results'),
|
|
260
|
+
).not.toBeInTheDocument();
|
|
261
|
+
expect(
|
|
262
|
+
screen.queryByText('Download search results'),
|
|
263
|
+
).not.toBeInTheDocument();
|
|
264
|
+
expect(screen.queryByText('Check duplicates')).not.toBeInTheDocument();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('hides checkboxes and Copy button when user cannot edit', async () => {
|
|
268
|
+
mockUseSelector.mockReturnValue({
|
|
269
|
+
object: [],
|
|
270
|
+
});
|
|
271
|
+
renderComponent();
|
|
272
|
+
await waitFor(() => {
|
|
273
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
274
|
+
});
|
|
275
|
+
expect(document.querySelector('.ui.checkbox')).toBeNull();
|
|
276
|
+
expect(screen.queryByText('Copy')).not.toBeInTheDocument();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('duplicate checking', () => {
|
|
281
|
+
beforeEach(() => {
|
|
282
|
+
window.location.search = '?check-duplicates=1';
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
function mockDuplicateFetch(duplicateIds, groups) {
|
|
286
|
+
global.fetch.mockImplementation((url) => {
|
|
287
|
+
if (typeof url === 'string' && url.includes('nis_experts_vocabulary')) {
|
|
288
|
+
return Promise.resolve({
|
|
289
|
+
json: () =>
|
|
290
|
+
Promise.resolve({
|
|
291
|
+
items: [{ token: 'jdoe', title: 'John Doe (jdoe)' }],
|
|
292
|
+
}),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (typeof url === 'string' && url.includes('check-nis-duplicates')) {
|
|
296
|
+
return Promise.resolve({
|
|
297
|
+
json: () =>
|
|
298
|
+
Promise.resolve({
|
|
299
|
+
duplicate_ids: duplicateIds,
|
|
300
|
+
groups: groups,
|
|
301
|
+
}),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return Promise.resolve({
|
|
305
|
+
json: () => Promise.resolve({}),
|
|
306
|
+
ok: true,
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
it('shows the duplicate info banner when check-duplicates param is set', async () => {
|
|
312
|
+
mockDuplicateFetch(
|
|
313
|
+
['/marine/item-1', '/marine/item-3'],
|
|
314
|
+
[{ id: 1 }, { id: 2 }],
|
|
315
|
+
);
|
|
316
|
+
renderComponent();
|
|
317
|
+
await waitFor(() => {
|
|
318
|
+
expect(screen.getByText(/duplicate records/)).toBeInTheDocument();
|
|
319
|
+
});
|
|
320
|
+
expect(screen.getByText(/2 duplicate records/)).toBeInTheDocument();
|
|
321
|
+
expect(screen.getByText(/2 groups/)).toBeInTheDocument();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('filters table to show only duplicate items', async () => {
|
|
325
|
+
mockDuplicateFetch(
|
|
326
|
+
['/marine/item-1', '/marine/item-3'],
|
|
327
|
+
[{ id: 1 }, { id: 2 }],
|
|
328
|
+
);
|
|
329
|
+
const items = buildItems(5);
|
|
330
|
+
renderComponent({ items });
|
|
331
|
+
await waitFor(() => {
|
|
332
|
+
expect(screen.getByText(/duplicate records/)).toBeInTheDocument();
|
|
333
|
+
});
|
|
334
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
335
|
+
expect(screen.getByText('Species 3')).toBeInTheDocument();
|
|
336
|
+
expect(screen.queryByText('Species 2')).toBeNull();
|
|
337
|
+
expect(screen.queryByText('Species 4')).toBeNull();
|
|
338
|
+
expect(screen.queryByText('Species 5')).toBeNull();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('renders a Clear link that removes check-duplicates from URL', async () => {
|
|
342
|
+
mockDuplicateFetch([], []);
|
|
343
|
+
renderComponent();
|
|
344
|
+
await waitFor(() => {
|
|
345
|
+
expect(screen.getByText('Clear')).toBeInTheDocument();
|
|
346
|
+
});
|
|
347
|
+
const clearLink = screen.getByText('Clear');
|
|
348
|
+
expect(clearLink.tagName).toBe('A');
|
|
349
|
+
expect(clearLink.getAttribute('href')).not.toContain('check-duplicates');
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe('item selection', () => {
|
|
354
|
+
it('toggles checkbox selection', async () => {
|
|
355
|
+
renderComponent();
|
|
356
|
+
await waitFor(() => {
|
|
357
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
358
|
+
});
|
|
359
|
+
const checkboxes = document.querySelectorAll('.ui.checkbox input');
|
|
360
|
+
expect(checkboxes.length).toBeGreaterThan(0);
|
|
361
|
+
fireEvent.click(checkboxes[0]);
|
|
362
|
+
// Selection panel should appear after clicking a checkbox
|
|
363
|
+
await waitFor(() => {
|
|
364
|
+
expect(screen.getByText(/Assign 1 selected item/)).toBeInTheDocument();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('removes selection when checkbox is unchecked', async () => {
|
|
369
|
+
renderComponent();
|
|
370
|
+
await waitFor(() => {
|
|
371
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
372
|
+
});
|
|
373
|
+
const checkboxes = document.querySelectorAll('.ui.checkbox input');
|
|
374
|
+
fireEvent.click(checkboxes[0]);
|
|
375
|
+
await waitFor(() => {
|
|
376
|
+
expect(screen.getByText(/Assign 1 selected item/)).toBeInTheDocument();
|
|
377
|
+
});
|
|
378
|
+
fireEvent.click(checkboxes[0]);
|
|
379
|
+
await waitFor(() => {
|
|
380
|
+
expect(
|
|
381
|
+
screen.queryByText(/Assign 1 selected item/),
|
|
382
|
+
).not.toBeInTheDocument();
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('updates count when selecting multiple items', async () => {
|
|
387
|
+
renderComponent();
|
|
388
|
+
await waitFor(() => {
|
|
389
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
390
|
+
});
|
|
391
|
+
const checkboxes = document.querySelectorAll('.ui.checkbox input');
|
|
392
|
+
fireEvent.click(checkboxes[0]);
|
|
393
|
+
fireEvent.click(checkboxes[1]);
|
|
394
|
+
await waitFor(() => {
|
|
395
|
+
expect(screen.getByText(/Assign 2 selected items/)).toBeInTheDocument();
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe('bulk assign panel', () => {
|
|
401
|
+
it('shows Cancel and Assign buttons after selection', async () => {
|
|
402
|
+
renderComponent();
|
|
403
|
+
await waitFor(() => {
|
|
404
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
405
|
+
});
|
|
406
|
+
const checkboxes = document.querySelectorAll('.ui.checkbox input');
|
|
407
|
+
fireEvent.click(checkboxes[0]);
|
|
408
|
+
await waitFor(() => {
|
|
409
|
+
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
|
410
|
+
});
|
|
411
|
+
expect(screen.getByText('Assign')).toBeInTheDocument();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('Assign button is disabled when no assignee is selected', async () => {
|
|
415
|
+
renderComponent();
|
|
416
|
+
await waitFor(() => {
|
|
417
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
418
|
+
});
|
|
419
|
+
const checkboxes = document.querySelectorAll('.ui.checkbox input');
|
|
420
|
+
fireEvent.click(checkboxes[0]);
|
|
421
|
+
await waitFor(() => {
|
|
422
|
+
expect(screen.getByText('Assign')).toBeInTheDocument();
|
|
423
|
+
});
|
|
424
|
+
const assignButton = screen.getByText('Assign').closest('button');
|
|
425
|
+
expect(assignButton).toBeDisabled();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('Cancel button clears selection', async () => {
|
|
429
|
+
renderComponent();
|
|
430
|
+
await waitFor(() => {
|
|
431
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
432
|
+
});
|
|
433
|
+
const checkboxes = document.querySelectorAll('.ui.checkbox input');
|
|
434
|
+
fireEvent.click(checkboxes[0]);
|
|
435
|
+
await waitFor(() => {
|
|
436
|
+
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
|
437
|
+
});
|
|
438
|
+
fireEvent.click(screen.getByText('Cancel'));
|
|
439
|
+
await waitFor(() => {
|
|
440
|
+
expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
});
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import installMsfdDataExplorerBlock from './components/Blocks/MsfdDataExplorerBl
|
|
|
16
16
|
import { breadcrumb, localnavigation, workflowProgressPath } from './reducers';
|
|
17
17
|
import customBlockTemplates from '@eeacms/volto-marine-policy/components/Blocks/CustomBlockTemplates/customBlockTemplates';
|
|
18
18
|
import TextAlignWidget from './components/Widgets/TextAlign';
|
|
19
|
+
import NISStatusWidget from './components/Widgets/NISStatusWidget';
|
|
19
20
|
import './slate-styles.less';
|
|
20
21
|
import './less/toc-title-sizes.less';
|
|
21
22
|
|
|
@@ -113,6 +114,7 @@ const applyConfig = (config) => {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
config.widgets.widget.text_align = TextAlignWidget;
|
|
117
|
+
config.widgets.id.nis_status = NISStatusWidget;
|
|
116
118
|
// check if it breaks the 'theme' field in volto-tabs-block in the 'horizontal carousel' layout
|
|
117
119
|
// We have a 'theme' field in the wise catalogue metadata (CatalogueMetadata)
|
|
118
120
|
config.widgets.id.indicator_theme = TokenWidget;
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import renderer from 'react-test-renderer';
|
|
3
|
-
import configureStore from 'redux-mock-store';
|
|
4
|
-
import { Provider } from 'react-intl-redux';
|
|
5
|
-
|
|
6
|
-
import TextAlign from './TextAlign';
|
|
7
|
-
|
|
8
|
-
const mockStore = configureStore();
|
|
9
|
-
|
|
10
|
-
test('renders a text align component', () => {
|
|
11
|
-
const store = mockStore({
|
|
12
|
-
intl: {
|
|
13
|
-
locale: 'en',
|
|
14
|
-
messages: {},
|
|
15
|
-
},
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
const component = renderer.create(
|
|
19
|
-
<Provider store={store}>
|
|
20
|
-
<TextAlign
|
|
21
|
-
id="my-field"
|
|
22
|
-
title="My field"
|
|
23
|
-
value="left"
|
|
24
|
-
fieldSet="default"
|
|
25
|
-
onChange={() => {}}
|
|
26
|
-
onBlur={() => {}}
|
|
27
|
-
onClick={() => {}}
|
|
28
|
-
/>
|
|
29
|
-
</Provider>,
|
|
30
|
-
);
|
|
31
|
-
const json = component.toJSON();
|
|
32
|
-
expect(json).toMatchSnapshot();
|
|
33
|
-
});
|