@eeacms/volto-marine-policy 3.0.2 → 3.0.4
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
CHANGED
|
@@ -4,6 +4,44 @@ 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.4](https://github.com/eea/volto-marine-policy/compare/3.0.3...3.0.4) - 30 June 2026
|
|
8
|
+
|
|
9
|
+
#### :rocket: New Features
|
|
10
|
+
|
|
11
|
+
- feat(NISListingView): restrict edit/remove buttons to assigned items [laszlocseh - [`4844b47`](https://github.com/eea/volto-marine-policy/commit/4844b47335908c105551c7a2ca652af86e43a491)]
|
|
12
|
+
- feat(NISListingView): add remove functionality and error handling [laszlocseh - [`1121c97`](https://github.com/eea/volto-marine-policy/commit/1121c97f4a6de99994325624edf705a810933958)]
|
|
13
|
+
- feat: added button to NISListingView to add nis records [laszlocseh - [`709763c`](https://github.com/eea/volto-marine-policy/commit/709763c37e8add6cbfb22931005ac2fee5d32e18)]
|
|
14
|
+
|
|
15
|
+
#### :bug: Bug Fixes
|
|
16
|
+
|
|
17
|
+
- fix(theme/NISListingView): prevent download button from rendering when duplicates exist [laszlocseh - [`ae0b788`](https://github.com/eea/volto-marine-policy/commit/ae0b7886535e6e0bf0f36dc2a567e3319be94568)]
|
|
18
|
+
- fix(NISListingView): add currentUserId to useEffect dependency [laszlocseh - [`b9bf4d6`](https://github.com/eea/volto-marine-policy/commit/b9bf4d66cb0d1cbdbaa587ed28460154a2f792ac)]
|
|
19
|
+
- fix(theme/NISListingView): dynamically adjust API path based on current route [laszlocseh - [`6d1d1f5`](https://github.com/eea/volto-marine-policy/commit/6d1d1f58fbe4155833b753990f03940434cbc068)]
|
|
20
|
+
- fix: use POST method and handle errors in duplicate check API call [laszlocseh - [`7a0c62c`](https://github.com/eea/volto-marine-policy/commit/7a0c62c1ef4fda3152ed52a44243c8a4cd638fa7)]
|
|
21
|
+
- fix: NISListingView check for duplicates [laszlocseh - [`52ef865`](https://github.com/eea/volto-marine-policy/commit/52ef865978ce19eefa598b70bd1ccee05152169a)]
|
|
22
|
+
|
|
23
|
+
#### :house: Internal changes
|
|
24
|
+
|
|
25
|
+
- style: Automated code fix [eea-jenkins - [`a733b08`](https://github.com/eea/volto-marine-policy/commit/a733b081f6080ffaa6abddc8f343fba0cbe19ab6)]
|
|
26
|
+
- style: disable eslint no-alert rule [laszlocseh - [`44d0f90`](https://github.com/eea/volto-marine-policy/commit/44d0f901bd80d32c9448791cb4afce8bbb3ffde1)]
|
|
27
|
+
- style: Automated code fix [eea-jenkins - [`0ff6b42`](https://github.com/eea/volto-marine-policy/commit/0ff6b42b5e2d2997feaa67bf9ce25fc2d933074d)]
|
|
28
|
+
- style: Automated code fix [eea-jenkins - [`af3a4b6`](https://github.com/eea/volto-marine-policy/commit/af3a4b6f33c455089ac55198fa561a43747c07cc)]
|
|
29
|
+
- chore(deps): bump axios from 0.30.0 to 0.32.0 [dependabot[bot] - [`2b6a39b`](https://github.com/eea/volto-marine-policy/commit/2b6a39b4d9bddb01c57e5cb58efe5c08664aa6bc)]
|
|
30
|
+
|
|
31
|
+
#### :hammer_and_wrench: Others
|
|
32
|
+
|
|
33
|
+
- test: update NISListingView tests to use props instead of mock selectors [laszlocseh - [`42498c0`](https://github.com/eea/volto-marine-policy/commit/42498c006e5a1ad6dcb23ad2fd25c9c1de1280bc)]
|
|
34
|
+
### [3.0.3](https://github.com/eea/volto-marine-policy/compare/3.0.2...3.0.3) - 12 June 2026
|
|
35
|
+
|
|
36
|
+
#### :rocket: New Features
|
|
37
|
+
|
|
38
|
+
- feat: added button to show NIS duplicates [laszlocseh - [`591927c`](https://github.com/eea/volto-marine-policy/commit/591927ca76c693d8d33d7b9ab8bdedc10368c0f2)]
|
|
39
|
+
- feat: added button to copy a NIS listing item [laszlocseh - [`588a5fe`](https://github.com/eea/volto-marine-policy/commit/588a5fe1409292a5df742590d615e230dc09ebd9)]
|
|
40
|
+
|
|
41
|
+
#### :hammer_and_wrench: Others
|
|
42
|
+
|
|
43
|
+
- test: fix NISListingView.test.jsx [laszlocseh - [`0ba486d`](https://github.com/eea/volto-marine-policy/commit/0ba486d1058bc71fe73ef53e628fd7c5423688ae)]
|
|
44
|
+
- test: added NISListingView.test.jsx [laszlocseh - [`4c54331`](https://github.com/eea/volto-marine-policy/commit/4c54331dd40f178dfd88dcc0d73d1eb96d2834e6)]
|
|
7
45
|
### [3.0.2](https://github.com/eea/volto-marine-policy/compare/3.0.1...3.0.2) - 10 June 2026
|
|
8
46
|
|
|
9
47
|
#### :rocket: Dependency updates
|
|
@@ -64,8 +102,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
|
64
102
|
#### :house: Internal changes
|
|
65
103
|
|
|
66
104
|
- style: Automated code fix [eea-jenkins - [`0421290`](https://github.com/eea/volto-marine-policy/commit/0421290d7fdd9fdd0b6d483b53c424a0747137b3)]
|
|
67
|
-
- chore: [JENKINSFILE] add package version in sonarqube [valentinab25 - [`fb1d0f0`](https://github.com/eea/volto-marine-policy/commit/fb1d0f083474b3a14b0ec36a329c7efc7499dd3d)]
|
|
68
|
-
- chore: [JENKINSFILE] use sonarqube branches [EEA Jenkins - [`40d0b36`](https://github.com/eea/volto-marine-policy/commit/40d0b368836d32544b46ae7c7b070ffb71989b55)]
|
|
69
105
|
|
|
70
106
|
#### :hammer_and_wrench: Others
|
|
71
107
|
|
package/RELEASE.md
CHANGED
|
@@ -2,24 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
### Automatic release using Jenkins
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
- The automatic release is started by creating a [Pull Request](../../compare/master...develop) from `develop` to `master`. The pull request status checks correlated to the branch and PR Jenkins jobs need to be processed successfully. 1 review from a github user with rights is mandatory.
|
|
6
|
+
- It runs on every commit on `master` branch, which is protected from direct commits, only allowing pull request merge commits.
|
|
7
|
+
- The automatic release is done by [Jenkins](https://ci.eionet.europa.eu). The status of the release job can be seen both in the Readme.md badges and the green check/red cross/yellow circle near the last commit information. If you click on the icon, you will have the list of checks that were run. The `continuous-integration/jenkins/branch` link goes to the Jenkins job execution webpage.
|
|
8
|
+
- Automated release scripts are located in the `eeacms/gitflow` docker image, specifically [js-release.sh](https://github.com/eea/eea.docker.gitflow/blob/master/src/js-release.sh) script. It uses the `release-it` tool.
|
|
9
|
+
- As long as a PR request is open from develop to master, the PR Jenkins job will automatically re-create the CHANGELOG.md and package.json files to be production-ready.
|
|
10
|
+
- The version format must be MAJOR.MINOR.PATCH. By default, next release is set to next minor version (with patch 0).
|
|
11
|
+
- You can manually change the version in `package.json`. The new version must not be already present in the tags/releases of the repository, otherwise it will be automatically increased by the script. Any changes to the version will trigger a `CHANGELOG.md` re-generation.
|
|
12
|
+
- Automated commits and commits with [JENKINS] or [YARN] in the commit log are excluded from `CHANGELOG.md` file.
|
|
13
13
|
|
|
14
14
|
### Manual release from the develop branch ( beta release )
|
|
15
15
|
|
|
16
16
|
#### Installation and configuration of release-it
|
|
17
17
|
|
|
18
|
-
You need to first install the [release-it](https://github.com/release-it/release-it)
|
|
18
|
+
You need to first install the [release-it](https://github.com/release-it/release-it) client.
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
```
|
|
21
|
+
npm install -g release-it
|
|
22
|
+
```
|
|
23
23
|
|
|
24
24
|
Release-it uses the configuration written in the [`.release-it.json`](./.release-it.json) file located in the root of the repository.
|
|
25
25
|
|
|
@@ -32,15 +32,15 @@ Release-it is a tool that automates 4 important steps in the release process:
|
|
|
32
32
|
|
|
33
33
|
To configure the authentification, you need to export GITHUB_TOKEN for [GitHub](https://github.com/settings/tokens)
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
```
|
|
36
|
+
export GITHUB_TOKEN=XXX-XXXXXXXXXXXXXXXXXXXXXX
|
|
37
|
+
```
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
To configure npm, you can use the `npm login` command or use a configuration file with a TOKEN :
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
```
|
|
42
|
+
echo "//registry.npmjs.org/:_authToken=YYYYYYYYYYYYYYYYYYYYYYYYYYYYYY" > .npmrc
|
|
43
|
+
```
|
|
44
44
|
|
|
45
45
|
#### Using release-it tool
|
|
46
46
|
|
|
@@ -71,4 +71,3 @@ Generic command, does not automatically add the `beta` to version, but you can s
|
|
|
71
71
|
> Do not use release-it tool on master branch, the commit on CHANGELOG.md file and the version increase in the package.json file can't be done without a PULL REQUEST.
|
|
72
72
|
|
|
73
73
|
> Do not keep Pull Requests from develop to master branches open when you are doing beta releases from the develop branch. As long as a PR to master is open, an automatic script will run on every commit and will update both the version and the changelog to a production-ready state - ( MAJOR.MINOR.PATCH mandatory format for version).
|
|
74
|
-
|
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.4",
|
|
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",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@eeacms/volto-metadata-block": "*",
|
|
42
42
|
"@eeacms/volto-openlayers-map": "2.0.1",
|
|
43
43
|
"@eeacms/volto-workflow-progress": "*",
|
|
44
|
-
"axios": "0.
|
|
44
|
+
"axios": "0.32.0",
|
|
45
45
|
"d3-array": "^2.12.1",
|
|
46
46
|
"jquery": "3.6.0",
|
|
47
47
|
"razzle-plugin-scss": "^4.2.18",
|
|
@@ -3,11 +3,12 @@ import ProgressWorkflow from '@eeacms/volto-marine-policy/components/theme/Progr
|
|
|
3
3
|
import qs from 'query-string';
|
|
4
4
|
import PropTypes from 'prop-types';
|
|
5
5
|
import './style.less';
|
|
6
|
-
import { useState, useEffect } from 'react';
|
|
6
|
+
import { useState, useEffect, useMemo } 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
10
|
import UniversalLink from '@plone/volto/components/manage/UniversalLink/UniversalLink';
|
|
11
|
+
import jwtDecode from 'jwt-decode';
|
|
11
12
|
|
|
12
13
|
function normalizeQueryOperators(query) {
|
|
13
14
|
return query.map((q) => {
|
|
@@ -63,6 +64,21 @@ async function getCurrentSearchItems() {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
function normalizeItemPath(itemId) {
|
|
68
|
+
if (!itemId) return '';
|
|
69
|
+
let path = itemId.replace(/^https?:\/\/[^/]+/, '');
|
|
70
|
+
try {
|
|
71
|
+
const apiURL = new URL(window.env.apiPath);
|
|
72
|
+
const prefix = apiURL.pathname;
|
|
73
|
+
if (prefix && prefix !== '/' && path.startsWith(prefix)) {
|
|
74
|
+
path = path.slice(prefix.length) || '/';
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {
|
|
77
|
+
// apiPath might be relative; ignore
|
|
78
|
+
}
|
|
79
|
+
return path;
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
function formatAssignedTo(assignedTo) {
|
|
67
83
|
if (!assignedTo) return '';
|
|
68
84
|
// Fix Python-style unicode escape sequences (\UXXXXXXXX -> actual char)
|
|
@@ -74,13 +90,27 @@ function formatAssignedTo(assignedTo) {
|
|
|
74
90
|
return result;
|
|
75
91
|
}
|
|
76
92
|
|
|
93
|
+
function isAssignedToMe(item, currentUserId) {
|
|
94
|
+
if (!currentUserId || !item.nis_assigned_to) return false;
|
|
95
|
+
const match = item.nis_assigned_to.match(/\(([^)]+)\)\s*$/);
|
|
96
|
+
return match ? match[1] === currentUserId : false;
|
|
97
|
+
}
|
|
98
|
+
|
|
77
99
|
const NISListingView = ({ items, isEditMode }) => {
|
|
78
100
|
const [isLoading, setIsLoading] = useState(false);
|
|
79
101
|
const [selectedItems, setSelectedItems] = useState([]);
|
|
80
102
|
const [itemsTotal, setItemsTotal] = useState(0);
|
|
103
|
+
const [duplicateIds, setDuplicateIds] = useState(null);
|
|
104
|
+
const [duplicateGroups, setDuplicateGroups] = useState([]);
|
|
105
|
+
const [duplicatesLoading, setDuplicatesLoading] = useState(false);
|
|
106
|
+
const [errorMessage, setErrorMessage] = useState(null);
|
|
107
|
+
const [locationTick, setLocationTick] = useState(0);
|
|
108
|
+
|
|
81
109
|
const [users, setUsers] = useState([]);
|
|
82
110
|
const [assignee, setAssignee] = useState(null);
|
|
83
111
|
const actions = useSelector((state) => state.actions.actions);
|
|
112
|
+
const token = useSelector((state) => state.userSession.token);
|
|
113
|
+
const currentUserId = token ? jwtDecode(token).sub : '';
|
|
84
114
|
const canEditPage = actions?.object?.some((action) => action.id === 'edit');
|
|
85
115
|
|
|
86
116
|
const toggleSelection = (id) => {
|
|
@@ -109,7 +139,10 @@ const NISListingView = ({ items, isEditMode }) => {
|
|
|
109
139
|
const onBulkAssign = async (ids, assignee) => {
|
|
110
140
|
setIsLoading(true);
|
|
111
141
|
await fetch(
|
|
112
|
-
`${window.env.apiPath}/++api
|
|
142
|
+
`${window.env.apiPath}/++api++${window.location.pathname.replace(
|
|
143
|
+
'/marine',
|
|
144
|
+
'',
|
|
145
|
+
)}/@bulk-assign${window.location.search}`,
|
|
113
146
|
{
|
|
114
147
|
method: 'POST',
|
|
115
148
|
headers: {
|
|
@@ -128,6 +161,117 @@ const NISListingView = ({ items, isEditMode }) => {
|
|
|
128
161
|
window.location.reload();
|
|
129
162
|
};
|
|
130
163
|
|
|
164
|
+
const handleCopy = async (item) => {
|
|
165
|
+
setErrorMessage(null);
|
|
166
|
+
setIsLoading(true);
|
|
167
|
+
try {
|
|
168
|
+
const res = await fetch(
|
|
169
|
+
`${window.env.apiPath}/++api++${item['@id'].replace(
|
|
170
|
+
/^\/marine/,
|
|
171
|
+
'',
|
|
172
|
+
)}/@copy-nis-record`,
|
|
173
|
+
{
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: {
|
|
176
|
+
'Content-Type': 'application/json',
|
|
177
|
+
Accept: 'application/json',
|
|
178
|
+
},
|
|
179
|
+
credentials: 'include',
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
if (res.ok) {
|
|
183
|
+
window.location.reload();
|
|
184
|
+
} else {
|
|
185
|
+
setErrorMessage('Something went wrong. The copy was not successful.');
|
|
186
|
+
setIsLoading(false);
|
|
187
|
+
}
|
|
188
|
+
} catch (err) {
|
|
189
|
+
// eslint-disable-next-line no-console
|
|
190
|
+
console.error('Copy failed:', err);
|
|
191
|
+
setErrorMessage('Something went wrong. The copy was not successful.');
|
|
192
|
+
setIsLoading(false);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const handleRemove = async (item) => {
|
|
197
|
+
// eslint-disable-next-line no-alert
|
|
198
|
+
if (!window.confirm('Are you sure you want to remove this item?')) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
setErrorMessage(null);
|
|
202
|
+
try {
|
|
203
|
+
const res = await fetch(
|
|
204
|
+
`${window.env.apiPath}/++api++${item['@id'].replace(/^\/marine/, '')}`,
|
|
205
|
+
{
|
|
206
|
+
method: 'DELETE',
|
|
207
|
+
credentials: 'include',
|
|
208
|
+
headers: { Accept: 'application/json' },
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
if (res.ok) {
|
|
212
|
+
window.location.reload();
|
|
213
|
+
} else {
|
|
214
|
+
setErrorMessage('Something went wrong. The item could not be removed.');
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
// eslint-disable-next-line no-console
|
|
218
|
+
console.error('Remove failed:', err);
|
|
219
|
+
setErrorMessage('Something went wrong. The item could not be removed.');
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
const trigger = () => setLocationTick((t) => t + 1);
|
|
225
|
+
window.addEventListener('popstate', trigger);
|
|
226
|
+
const origPush = window.history.pushState;
|
|
227
|
+
const origReplace = window.history.replaceState;
|
|
228
|
+
window.history.pushState = function (...a) {
|
|
229
|
+
origPush.apply(this, a);
|
|
230
|
+
trigger();
|
|
231
|
+
};
|
|
232
|
+
window.history.replaceState = function (...a) {
|
|
233
|
+
origReplace.apply(this, a);
|
|
234
|
+
trigger();
|
|
235
|
+
};
|
|
236
|
+
return () => {
|
|
237
|
+
window.removeEventListener('popstate', trigger);
|
|
238
|
+
window.history.pushState = origPush;
|
|
239
|
+
window.history.replaceState = origReplace;
|
|
240
|
+
};
|
|
241
|
+
}, []);
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
const parsed = qs.parse(window.location.search);
|
|
245
|
+
if (parsed['check-duplicates']) {
|
|
246
|
+
setDuplicatesLoading(true);
|
|
247
|
+
fetch(
|
|
248
|
+
`${window.env.apiPath}/++api++${window.location.pathname.replace(
|
|
249
|
+
'/marine',
|
|
250
|
+
'',
|
|
251
|
+
)}/@check-nis-duplicates`,
|
|
252
|
+
{
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: {
|
|
255
|
+
'Content-Type': 'application/json',
|
|
256
|
+
Accept: 'application/json',
|
|
257
|
+
},
|
|
258
|
+
credentials: 'include',
|
|
259
|
+
body: JSON.stringify({ search: window.location.search }),
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
.then((res) => res.json())
|
|
263
|
+
.then((data) => {
|
|
264
|
+
setDuplicateIds(new Set(data.duplicate_ids.map(normalizeItemPath)));
|
|
265
|
+
setDuplicateGroups(data.groups || []);
|
|
266
|
+
})
|
|
267
|
+
.catch((err) => {
|
|
268
|
+
// eslint-disable-next-line no-console
|
|
269
|
+
console.error('Check duplicates failed:', err);
|
|
270
|
+
})
|
|
271
|
+
.finally(() => setDuplicatesLoading(false));
|
|
272
|
+
}
|
|
273
|
+
}, [locationTick]);
|
|
274
|
+
|
|
131
275
|
useEffect(() => {
|
|
132
276
|
const fetchUsers = async () => {
|
|
133
277
|
const res = await fetch(
|
|
@@ -153,6 +297,108 @@ const NISListingView = ({ items, isEditMode }) => {
|
|
|
153
297
|
fetchUsers();
|
|
154
298
|
}, []);
|
|
155
299
|
|
|
300
|
+
const duplicateTableRows = useMemo(() => {
|
|
301
|
+
if (!duplicateIds) return items;
|
|
302
|
+
|
|
303
|
+
const rows = [];
|
|
304
|
+
duplicateGroups.forEach((group, gIdx) => {
|
|
305
|
+
(group.items || []).forEach((item) => {
|
|
306
|
+
rows.push(
|
|
307
|
+
<tr key={item['@id']}>
|
|
308
|
+
<td>{item.nis_species_name_original}</td>
|
|
309
|
+
<td>{item.nis_species_name_accepted}</td>
|
|
310
|
+
<td>{item.nis_scientificname_accepted}</td>
|
|
311
|
+
<td>{item.nis_region}</td>
|
|
312
|
+
<td>{item.nis_subregion}</td>
|
|
313
|
+
<td>{item.nis_country}</td>
|
|
314
|
+
<td>{item.nis_status}</td>
|
|
315
|
+
<td>{item.nis_group}</td>
|
|
316
|
+
<td>{item.nis_year}</td>
|
|
317
|
+
<td>
|
|
318
|
+
<div className="assigned-to-container">
|
|
319
|
+
<div>{formatAssignedTo(item.nis_assigned_to)}</div>
|
|
320
|
+
{canEditPage && (
|
|
321
|
+
<Checkbox
|
|
322
|
+
checked={selectedItems.includes(item['@id'])}
|
|
323
|
+
onChange={() => toggleSelection(item['@id'])}
|
|
324
|
+
/>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
</td>
|
|
328
|
+
<td>
|
|
329
|
+
<div className="workflow-actions">
|
|
330
|
+
<div className="action-buttons">
|
|
331
|
+
<UniversalLink
|
|
332
|
+
className="ui button secondary mini"
|
|
333
|
+
href={item['@id']}
|
|
334
|
+
target="_blank"
|
|
335
|
+
rel="noopener noreferrer"
|
|
336
|
+
>
|
|
337
|
+
View
|
|
338
|
+
</UniversalLink>
|
|
339
|
+
{isAssignedToMe(item, currentUserId) && (
|
|
340
|
+
<UniversalLink
|
|
341
|
+
className="ui button primary mini"
|
|
342
|
+
href={`${item['@id']}/edit`}
|
|
343
|
+
target="_blank"
|
|
344
|
+
rel="noopener noreferrer"
|
|
345
|
+
>
|
|
346
|
+
Edit
|
|
347
|
+
</UniversalLink>
|
|
348
|
+
)}
|
|
349
|
+
{isAssignedToMe(item, currentUserId) && (
|
|
350
|
+
<Button
|
|
351
|
+
className="tertiary mini"
|
|
352
|
+
onClick={() => handleCopy(item)}
|
|
353
|
+
>
|
|
354
|
+
Copy
|
|
355
|
+
</Button>
|
|
356
|
+
)}
|
|
357
|
+
{isAssignedToMe(item, currentUserId) && (
|
|
358
|
+
<Button
|
|
359
|
+
className="negative mini"
|
|
360
|
+
onClick={() => handleRemove(item)}
|
|
361
|
+
>
|
|
362
|
+
Remove
|
|
363
|
+
</Button>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
<div className="workflow-progress">
|
|
367
|
+
<ProgressWorkflow
|
|
368
|
+
content={item}
|
|
369
|
+
pathname={item['@id']}
|
|
370
|
+
token={123}
|
|
371
|
+
/>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</td>
|
|
375
|
+
</tr>,
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
if (gIdx < duplicateGroups.length - 1) {
|
|
379
|
+
rows.push(
|
|
380
|
+
<tr key={`sep-${gIdx}`}>
|
|
381
|
+
<td
|
|
382
|
+
colSpan="11"
|
|
383
|
+
style={{
|
|
384
|
+
borderBottom: '2px solid #999',
|
|
385
|
+
padding: 0,
|
|
386
|
+
}}
|
|
387
|
+
/>
|
|
388
|
+
</tr>,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
return rows;
|
|
393
|
+
}, [
|
|
394
|
+
duplicateIds,
|
|
395
|
+
duplicateGroups,
|
|
396
|
+
items,
|
|
397
|
+
canEditPage,
|
|
398
|
+
selectedItems,
|
|
399
|
+
currentUserId,
|
|
400
|
+
]);
|
|
401
|
+
|
|
156
402
|
return (
|
|
157
403
|
<>
|
|
158
404
|
{isLoading && (
|
|
@@ -160,8 +406,28 @@ const NISListingView = ({ items, isEditMode }) => {
|
|
|
160
406
|
<Loader>Assigning...</Loader>
|
|
161
407
|
</Dimmer>
|
|
162
408
|
)}
|
|
163
|
-
{canEditPage && (
|
|
409
|
+
{canEditPage && !duplicateIds && (
|
|
164
410
|
<div className="download-button-wrapper">
|
|
411
|
+
<UniversalLink
|
|
412
|
+
className="ui button primary download-as-xls"
|
|
413
|
+
href={`${window.location.pathname}/add?type=non_indigenous_species`}
|
|
414
|
+
target="_blank"
|
|
415
|
+
rel="noopener noreferrer"
|
|
416
|
+
>
|
|
417
|
+
<i className="ri-add-line"></i>Add NIS record
|
|
418
|
+
</UniversalLink>
|
|
419
|
+
<Button
|
|
420
|
+
className="primary"
|
|
421
|
+
size="small"
|
|
422
|
+
onClick={() => {
|
|
423
|
+
const parsed = qs.parse(window.location.search);
|
|
424
|
+
parsed['check-duplicates'] = '1';
|
|
425
|
+
window.location.search = qs.stringify(parsed);
|
|
426
|
+
}}
|
|
427
|
+
>
|
|
428
|
+
<i className="ri-file-copy-line"></i>
|
|
429
|
+
Check duplicates
|
|
430
|
+
</Button>
|
|
165
431
|
<Button
|
|
166
432
|
className="primary"
|
|
167
433
|
size="small"
|
|
@@ -169,23 +435,71 @@ const NISListingView = ({ items, isEditMode }) => {
|
|
|
169
435
|
>
|
|
170
436
|
<i className="ri-user-add-line"></i>Assign search results
|
|
171
437
|
</Button>
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
>
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
438
|
+
<a
|
|
439
|
+
href={`/marine/++api++${window.location.pathname.replace(
|
|
440
|
+
'/marine',
|
|
441
|
+
'',
|
|
442
|
+
)}/nis-export${window.location.search}`}
|
|
443
|
+
title="Download"
|
|
444
|
+
target="_blank"
|
|
445
|
+
rel="noopener"
|
|
446
|
+
className="ui button primary download-as-xls"
|
|
447
|
+
>
|
|
448
|
+
<i className="ri-file-download-line"></i>
|
|
449
|
+
Download search results
|
|
450
|
+
</a>
|
|
451
|
+
</div>
|
|
452
|
+
)}
|
|
453
|
+
{errorMessage && (
|
|
454
|
+
<div
|
|
455
|
+
style={{
|
|
456
|
+
background: '#f8d7da',
|
|
457
|
+
border: '1px solid #f5c6cb',
|
|
458
|
+
color: '#721c24',
|
|
459
|
+
padding: '10px 15px',
|
|
460
|
+
marginBottom: '15px',
|
|
461
|
+
borderRadius: '4px',
|
|
462
|
+
}}
|
|
463
|
+
>
|
|
464
|
+
{errorMessage}
|
|
465
|
+
</div>
|
|
466
|
+
)}
|
|
467
|
+
{duplicateIds && (
|
|
468
|
+
<div
|
|
469
|
+
style={{
|
|
470
|
+
background: '#fff3cd',
|
|
471
|
+
border: '1px solid #ffc107',
|
|
472
|
+
padding: '10px 15px',
|
|
473
|
+
marginBottom: '15px',
|
|
474
|
+
borderRadius: '4px',
|
|
475
|
+
}}
|
|
476
|
+
>
|
|
477
|
+
Showing{' '}
|
|
478
|
+
{duplicateGroups.reduce(
|
|
479
|
+
(sum, g) => sum + (g.items ? g.items.length : 0),
|
|
480
|
+
0,
|
|
481
|
+
)}{' '}
|
|
482
|
+
duplicate records across {duplicateGroups.length} groups
|
|
483
|
+
<a
|
|
484
|
+
href={(() => {
|
|
485
|
+
const p = qs.parse(window.location.search);
|
|
486
|
+
delete p['check-duplicates'];
|
|
487
|
+
const q = qs.stringify(p);
|
|
488
|
+
return `${window.location.pathname}${q ? '?' + q : ''}`;
|
|
489
|
+
})()}
|
|
490
|
+
style={{ marginLeft: '10px', fontSize: '0.9em' }}
|
|
491
|
+
>
|
|
492
|
+
Clear
|
|
493
|
+
</a>
|
|
187
494
|
</div>
|
|
188
495
|
)}
|
|
496
|
+
{duplicateIds && (
|
|
497
|
+
<style
|
|
498
|
+
dangerouslySetInnerHTML={{
|
|
499
|
+
__html: `.listing-pagination,.pagination-wrapper,nav[aria-label="Pagination Navigation"],.ui.pagination{display:none!important}`,
|
|
500
|
+
}}
|
|
501
|
+
/>
|
|
502
|
+
)}
|
|
189
503
|
<table className="ui table">
|
|
190
504
|
<thead>
|
|
191
505
|
<tr>
|
|
@@ -197,59 +511,93 @@ const NISListingView = ({ items, isEditMode }) => {
|
|
|
197
511
|
<th>Country</th>
|
|
198
512
|
<th>Status</th>
|
|
199
513
|
<th>Group</th>
|
|
514
|
+
<th>Year</th>
|
|
200
515
|
<th>Assigned to</th>
|
|
201
516
|
<th></th>
|
|
202
517
|
</tr>
|
|
203
518
|
</thead>
|
|
204
519
|
<tbody>
|
|
205
|
-
{
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
<td
|
|
210
|
-
|
|
211
|
-
<td>{item.nis_subregion}</td>
|
|
212
|
-
<td>{item.nis_country}</td>
|
|
213
|
-
<td>{item.nis_status}</td>
|
|
214
|
-
<td>{item.nis_group}</td>
|
|
215
|
-
<td>
|
|
216
|
-
<div className="assigned-to-container">
|
|
217
|
-
<div>{formatAssignedTo(item.nis_assigned_to)}</div>
|
|
218
|
-
{canEditPage && (
|
|
219
|
-
<Checkbox
|
|
220
|
-
checked={selectedItems.includes(item['@id'])}
|
|
221
|
-
onChange={() => toggleSelection(item['@id'])}
|
|
222
|
-
/>
|
|
223
|
-
)}
|
|
224
|
-
</div>
|
|
520
|
+
{duplicateIds ? (
|
|
521
|
+
duplicateTableRows
|
|
522
|
+
) : duplicatesLoading ? (
|
|
523
|
+
<tr>
|
|
524
|
+
<td colSpan="11" style={{ textAlign: 'center', padding: '20px' }}>
|
|
525
|
+
<Loader active inline />
|
|
225
526
|
</td>
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
527
|
+
</tr>
|
|
528
|
+
) : (
|
|
529
|
+
items.map((item) => (
|
|
530
|
+
<tr key={item['@id']}>
|
|
531
|
+
<td>{item.nis_species_name_original}</td>
|
|
532
|
+
<td>{item.nis_species_name_accepted}</td>
|
|
533
|
+
<td>{item.nis_scientificname_accepted}</td>
|
|
534
|
+
<td>{item.nis_region}</td>
|
|
535
|
+
<td>{item.nis_subregion}</td>
|
|
536
|
+
<td>{item.nis_country}</td>
|
|
537
|
+
<td>{item.nis_status}</td>
|
|
538
|
+
<td>{item.nis_group}</td>
|
|
539
|
+
<td>{item.nis_year}</td>
|
|
540
|
+
<td>
|
|
541
|
+
<div className="assigned-to-container">
|
|
542
|
+
<div>{formatAssignedTo(item.nis_assigned_to)}</div>
|
|
543
|
+
{canEditPage && (
|
|
544
|
+
<Checkbox
|
|
545
|
+
checked={selectedItems.includes(item['@id'])}
|
|
546
|
+
onChange={() => toggleSelection(item['@id'])}
|
|
547
|
+
/>
|
|
548
|
+
)}
|
|
241
549
|
</div>
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
550
|
+
</td>
|
|
551
|
+
<td>
|
|
552
|
+
<div className="workflow-actions">
|
|
553
|
+
<div className="action-buttons">
|
|
554
|
+
<UniversalLink
|
|
555
|
+
className="ui button secondary mini"
|
|
556
|
+
href={`${item['@id']}`}
|
|
557
|
+
target="_blank"
|
|
558
|
+
rel="noopener noreferrer"
|
|
559
|
+
>
|
|
560
|
+
View
|
|
561
|
+
</UniversalLink>
|
|
562
|
+
{isAssignedToMe(item, currentUserId) && (
|
|
563
|
+
<UniversalLink
|
|
564
|
+
className="ui button primary mini"
|
|
565
|
+
href={`${item['@id']}/edit`}
|
|
566
|
+
target="_blank"
|
|
567
|
+
rel="noopener noreferrer"
|
|
568
|
+
>
|
|
569
|
+
Edit
|
|
570
|
+
</UniversalLink>
|
|
571
|
+
)}
|
|
572
|
+
{isAssignedToMe(item, currentUserId) && (
|
|
573
|
+
<Button
|
|
574
|
+
className="tertiary mini"
|
|
575
|
+
onClick={() => handleCopy(item)}
|
|
576
|
+
>
|
|
577
|
+
Copy
|
|
578
|
+
</Button>
|
|
579
|
+
)}
|
|
580
|
+
{isAssignedToMe(item, currentUserId) && (
|
|
581
|
+
<Button
|
|
582
|
+
className="negative mini"
|
|
583
|
+
onClick={() => handleRemove(item)}
|
|
584
|
+
>
|
|
585
|
+
Remove
|
|
586
|
+
</Button>
|
|
587
|
+
)}
|
|
588
|
+
</div>
|
|
589
|
+
<div className="workflow-progress">
|
|
590
|
+
<ProgressWorkflow
|
|
591
|
+
content={item}
|
|
592
|
+
pathname={item['@id']}
|
|
593
|
+
token={123}
|
|
594
|
+
/>
|
|
595
|
+
</div>
|
|
248
596
|
</div>
|
|
249
|
-
</
|
|
250
|
-
</
|
|
251
|
-
|
|
252
|
-
)
|
|
597
|
+
</td>
|
|
598
|
+
</tr>
|
|
599
|
+
))
|
|
600
|
+
)}
|
|
253
601
|
</tbody>
|
|
254
602
|
</table>
|
|
255
603
|
{selectedItems.length > 0 && (
|
|
@@ -0,0 +1,464 @@
|
|
|
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
|
+
jest.mock('react-redux', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
...jest.requireActual('react-redux'),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
jest.mock(
|
|
14
|
+
'@plone/volto/components/manage/UniversalLink/UniversalLink',
|
|
15
|
+
() =>
|
|
16
|
+
function MockUniversalLink({ href, children, className }) {
|
|
17
|
+
return (
|
|
18
|
+
<a href={href} className={className}>
|
|
19
|
+
{children}
|
|
20
|
+
</a>
|
|
21
|
+
);
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
jest.mock(
|
|
26
|
+
'@eeacms/volto-marine-policy/components/theme/ProgressWorkflow/ProgressWorkflow',
|
|
27
|
+
() => ({
|
|
28
|
+
__esModule: true,
|
|
29
|
+
default: () => <div data-testid="progress-workflow" />,
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const mockStore = configureStore();
|
|
34
|
+
|
|
35
|
+
function buildItems(count = 3) {
|
|
36
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
37
|
+
'@id': `/marine/item-${i + 1}`,
|
|
38
|
+
nis_species_name_original: `Species ${i + 1}`,
|
|
39
|
+
nis_species_name_accepted: `Species ${i + 1} accepted`,
|
|
40
|
+
nis_scientificname_accepted: `Scientificus acceptus ${i + 1}`,
|
|
41
|
+
nis_region: 'Europe',
|
|
42
|
+
nis_subregion: 'Western Europe',
|
|
43
|
+
nis_country: 'France',
|
|
44
|
+
nis_status: 'Established',
|
|
45
|
+
nis_group: 'Fish',
|
|
46
|
+
nis_year: 2020 + i,
|
|
47
|
+
nis_assigned_to: `User ${i + 1} (user${i + 1})`,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderComponent(props = {}) {
|
|
52
|
+
const {
|
|
53
|
+
items = buildItems(),
|
|
54
|
+
actions = { object: [{ id: 'edit' }] },
|
|
55
|
+
token = null,
|
|
56
|
+
...rest
|
|
57
|
+
} = props;
|
|
58
|
+
const store = mockStore({
|
|
59
|
+
intl: { locale: 'en', messages: {} },
|
|
60
|
+
actions: { actions },
|
|
61
|
+
userSession: { token },
|
|
62
|
+
});
|
|
63
|
+
return render(
|
|
64
|
+
<Provider store={store}>
|
|
65
|
+
<NISListingView items={items} {...rest} />
|
|
66
|
+
</Provider>,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('NISListingView', () => {
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
jest.clearAllMocks();
|
|
73
|
+
|
|
74
|
+
global.fetch = jest.fn(() =>
|
|
75
|
+
Promise.resolve({
|
|
76
|
+
json: () => Promise.resolve({ items: [] }),
|
|
77
|
+
ok: true,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
delete window.location;
|
|
82
|
+
window.location = {
|
|
83
|
+
href: 'http://localhost:3000/marine/test-path',
|
|
84
|
+
pathname: '/marine/test-path',
|
|
85
|
+
search: '',
|
|
86
|
+
reload: jest.fn(),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
window.env = { apiPath: 'http://localhost:8080/Plone' };
|
|
90
|
+
|
|
91
|
+
global.fetch.mockImplementation((url) => {
|
|
92
|
+
if (typeof url === 'string' && url.includes('nis_experts_vocabulary')) {
|
|
93
|
+
return Promise.resolve({
|
|
94
|
+
json: () =>
|
|
95
|
+
Promise.resolve({
|
|
96
|
+
items: [
|
|
97
|
+
{ token: 'jdoe', title: 'John Doe (jdoe)' },
|
|
98
|
+
{ token: 'asmith', title: 'Alice Smith (asmith)' },
|
|
99
|
+
],
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (typeof url === 'string' && url.includes('check-nis-duplicates')) {
|
|
104
|
+
return Promise.resolve({
|
|
105
|
+
json: () =>
|
|
106
|
+
Promise.resolve({
|
|
107
|
+
duplicate_ids: [],
|
|
108
|
+
groups: [],
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return Promise.resolve({
|
|
113
|
+
json: () => Promise.resolve({}),
|
|
114
|
+
ok: true,
|
|
115
|
+
});
|
|
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
|
+
renderComponent();
|
|
240
|
+
await waitFor(() => {
|
|
241
|
+
expect(screen.getByText('Assign search results')).toBeInTheDocument();
|
|
242
|
+
});
|
|
243
|
+
expect(screen.getByText('Add NIS record')).toBeInTheDocument();
|
|
244
|
+
expect(screen.getByText('Download search results')).toBeInTheDocument();
|
|
245
|
+
expect(screen.getByText('Check duplicates')).toBeInTheDocument();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('hides admin controls when user cannot edit', async () => {
|
|
249
|
+
renderComponent({ actions: { object: [] } });
|
|
250
|
+
await waitFor(() => {
|
|
251
|
+
expect(screen.getByText('Species name original')).toBeInTheDocument();
|
|
252
|
+
});
|
|
253
|
+
expect(
|
|
254
|
+
screen.queryByText('Assign search results'),
|
|
255
|
+
).not.toBeInTheDocument();
|
|
256
|
+
expect(
|
|
257
|
+
screen.queryByText('Download search results'),
|
|
258
|
+
).not.toBeInTheDocument();
|
|
259
|
+
expect(screen.queryByText('Check duplicates')).not.toBeInTheDocument();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('hides checkboxes and Copy button when user cannot edit', async () => {
|
|
263
|
+
renderComponent({ actions: { object: [] } });
|
|
264
|
+
await waitFor(() => {
|
|
265
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
266
|
+
});
|
|
267
|
+
expect(document.querySelector('.ui.checkbox')).toBeNull();
|
|
268
|
+
expect(screen.queryByText('Copy')).not.toBeInTheDocument();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('duplicate checking', () => {
|
|
273
|
+
beforeEach(() => {
|
|
274
|
+
window.location.search = '?check-duplicates=1';
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
function mockDuplicateFetch(duplicateIds, groups) {
|
|
278
|
+
global.fetch.mockImplementation((url) => {
|
|
279
|
+
if (typeof url === 'string' && url.includes('nis_experts_vocabulary')) {
|
|
280
|
+
return Promise.resolve({
|
|
281
|
+
json: () =>
|
|
282
|
+
Promise.resolve({
|
|
283
|
+
items: [{ token: 'jdoe', title: 'John Doe (jdoe)' }],
|
|
284
|
+
}),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
if (typeof url === 'string' && url.includes('check-nis-duplicates')) {
|
|
288
|
+
return Promise.resolve({
|
|
289
|
+
json: () =>
|
|
290
|
+
Promise.resolve({
|
|
291
|
+
duplicate_ids: duplicateIds,
|
|
292
|
+
groups: groups,
|
|
293
|
+
}),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return Promise.resolve({
|
|
297
|
+
json: () => Promise.resolve({}),
|
|
298
|
+
ok: true,
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function makeGroup(itemNumber) {
|
|
304
|
+
const itemId = `/marine/item-${itemNumber}`;
|
|
305
|
+
const itemData = {
|
|
306
|
+
'@id': itemId,
|
|
307
|
+
review_state: 'draft',
|
|
308
|
+
nis_species_name_original: `Species ${itemNumber}`,
|
|
309
|
+
nis_species_name_accepted: `Species ${itemNumber} accepted`,
|
|
310
|
+
nis_scientificname_accepted: `Scientificus acceptus ${itemNumber}`,
|
|
311
|
+
nis_region: 'Europe',
|
|
312
|
+
nis_subregion: 'Western Europe',
|
|
313
|
+
nis_country: 'France',
|
|
314
|
+
nis_status: 'Established',
|
|
315
|
+
nis_group: 'Fish',
|
|
316
|
+
nis_year: 2020 + itemNumber,
|
|
317
|
+
nis_assigned_to: `User ${itemNumber} (user${itemNumber})`,
|
|
318
|
+
};
|
|
319
|
+
return {
|
|
320
|
+
species_name_original: `Species ${itemNumber}`,
|
|
321
|
+
species_name_accepted: `Species ${itemNumber} accepted`,
|
|
322
|
+
scientificname_accepted: `Scientificus acceptus ${itemNumber}`,
|
|
323
|
+
region: 'Europe',
|
|
324
|
+
subregion: 'Western Europe',
|
|
325
|
+
country: 'France',
|
|
326
|
+
year: 2020 + itemNumber,
|
|
327
|
+
items: [itemData],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
it('shows the duplicate info banner when check-duplicates param is set', async () => {
|
|
332
|
+
mockDuplicateFetch(
|
|
333
|
+
['/marine/item-1', '/marine/item-3'],
|
|
334
|
+
[makeGroup(1), makeGroup(3)],
|
|
335
|
+
);
|
|
336
|
+
renderComponent();
|
|
337
|
+
await waitFor(() => {
|
|
338
|
+
expect(screen.getByText(/duplicate records/)).toBeInTheDocument();
|
|
339
|
+
});
|
|
340
|
+
expect(screen.getByText(/2 duplicate records/)).toBeInTheDocument();
|
|
341
|
+
expect(screen.getByText(/2 groups/)).toBeInTheDocument();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('filters table to show only duplicate items', async () => {
|
|
345
|
+
mockDuplicateFetch(
|
|
346
|
+
['/marine/item-1', '/marine/item-3'],
|
|
347
|
+
[makeGroup(1), makeGroup(3)],
|
|
348
|
+
);
|
|
349
|
+
const items = buildItems(5);
|
|
350
|
+
renderComponent({ items });
|
|
351
|
+
await waitFor(() => {
|
|
352
|
+
expect(screen.getByText(/duplicate records/)).toBeInTheDocument();
|
|
353
|
+
});
|
|
354
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
355
|
+
expect(screen.getByText('Species 3')).toBeInTheDocument();
|
|
356
|
+
expect(screen.queryByText('Species 2')).toBeNull();
|
|
357
|
+
expect(screen.queryByText('Species 4')).toBeNull();
|
|
358
|
+
expect(screen.queryByText('Species 5')).toBeNull();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('renders a Clear link that removes check-duplicates from URL', async () => {
|
|
362
|
+
mockDuplicateFetch([], []);
|
|
363
|
+
renderComponent();
|
|
364
|
+
await waitFor(() => {
|
|
365
|
+
expect(screen.getByText('Clear')).toBeInTheDocument();
|
|
366
|
+
});
|
|
367
|
+
const clearLink = screen.getByText('Clear');
|
|
368
|
+
expect(clearLink.tagName).toBe('A');
|
|
369
|
+
expect(clearLink.getAttribute('href')).not.toContain('check-duplicates');
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe('item selection', () => {
|
|
374
|
+
it('toggles checkbox selection', async () => {
|
|
375
|
+
renderComponent();
|
|
376
|
+
await waitFor(() => {
|
|
377
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
378
|
+
});
|
|
379
|
+
const checkboxes = document.querySelectorAll('.ui.checkbox input');
|
|
380
|
+
expect(checkboxes.length).toBeGreaterThan(0);
|
|
381
|
+
fireEvent.click(checkboxes[0]);
|
|
382
|
+
// Selection panel should appear after clicking a checkbox
|
|
383
|
+
await waitFor(() => {
|
|
384
|
+
expect(screen.getByText(/Assign 1 selected item/)).toBeInTheDocument();
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('removes selection when checkbox is unchecked', async () => {
|
|
389
|
+
renderComponent();
|
|
390
|
+
await waitFor(() => {
|
|
391
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
392
|
+
});
|
|
393
|
+
const checkboxes = document.querySelectorAll('.ui.checkbox input');
|
|
394
|
+
fireEvent.click(checkboxes[0]);
|
|
395
|
+
await waitFor(() => {
|
|
396
|
+
expect(screen.getByText(/Assign 1 selected item/)).toBeInTheDocument();
|
|
397
|
+
});
|
|
398
|
+
fireEvent.click(checkboxes[0]);
|
|
399
|
+
await waitFor(() => {
|
|
400
|
+
expect(
|
|
401
|
+
screen.queryByText(/Assign 1 selected item/),
|
|
402
|
+
).not.toBeInTheDocument();
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('updates count when selecting multiple items', async () => {
|
|
407
|
+
renderComponent();
|
|
408
|
+
await waitFor(() => {
|
|
409
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
410
|
+
});
|
|
411
|
+
const checkboxes = document.querySelectorAll('.ui.checkbox input');
|
|
412
|
+
fireEvent.click(checkboxes[0]);
|
|
413
|
+
fireEvent.click(checkboxes[1]);
|
|
414
|
+
await waitFor(() => {
|
|
415
|
+
expect(screen.getByText(/Assign 2 selected items/)).toBeInTheDocument();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe('bulk assign panel', () => {
|
|
421
|
+
it('shows Cancel and Assign buttons after selection', async () => {
|
|
422
|
+
renderComponent();
|
|
423
|
+
await waitFor(() => {
|
|
424
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
425
|
+
});
|
|
426
|
+
const checkboxes = document.querySelectorAll('.ui.checkbox input');
|
|
427
|
+
fireEvent.click(checkboxes[0]);
|
|
428
|
+
await waitFor(() => {
|
|
429
|
+
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
|
430
|
+
});
|
|
431
|
+
expect(screen.getByText('Assign')).toBeInTheDocument();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('Assign button is disabled when no assignee is selected', async () => {
|
|
435
|
+
renderComponent();
|
|
436
|
+
await waitFor(() => {
|
|
437
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
438
|
+
});
|
|
439
|
+
const checkboxes = document.querySelectorAll('.ui.checkbox input');
|
|
440
|
+
fireEvent.click(checkboxes[0]);
|
|
441
|
+
await waitFor(() => {
|
|
442
|
+
expect(screen.getByText('Assign')).toBeInTheDocument();
|
|
443
|
+
});
|
|
444
|
+
const assignButton = screen.getByText('Assign').closest('button');
|
|
445
|
+
expect(assignButton).toBeDisabled();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('Cancel button clears selection', async () => {
|
|
449
|
+
renderComponent();
|
|
450
|
+
await waitFor(() => {
|
|
451
|
+
expect(screen.getByText('Species 1')).toBeInTheDocument();
|
|
452
|
+
});
|
|
453
|
+
const checkboxes = document.querySelectorAll('.ui.checkbox input');
|
|
454
|
+
fireEvent.click(checkboxes[0]);
|
|
455
|
+
await waitFor(() => {
|
|
456
|
+
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
|
457
|
+
});
|
|
458
|
+
fireEvent.click(screen.getByText('Cancel'));
|
|
459
|
+
await waitFor(() => {
|
|
460
|
+
expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
});
|