@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
- * 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.
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) client.
18
+ You need to first install the [release-it](https://github.com/release-it/release-it) client.
19
19
 
20
- ```
21
- npm install -g release-it
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
- export GITHUB_TOKEN=XXX-XXXXXXXXXXXXXXXXXXXXXX
37
- ```
35
+ ```
36
+ export GITHUB_TOKEN=XXX-XXXXXXXXXXXXXXXXXXXXXX
37
+ ```
38
38
 
39
- To configure npm, you can use the `npm login` command or use a configuration file with a TOKEN :
39
+ To configure npm, you can use the `npm login` command or use a configuration file with a TOKEN :
40
40
 
41
- ```
42
- echo "//registry.npmjs.org/:_authToken=YYYYYYYYYYYYYYYYYYYYYYYYYYYYYY" > .npmrc
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.2",
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.30.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++/@bulk-assign${window.location.search}`,
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
- <div>
173
- <a
174
- href={`/marine/++api++${window.location.pathname.replace(
175
- '/marine',
176
- '',
177
- )}/nis-export${window.location.search}`}
178
- title="Download"
179
- target="_blank"
180
- rel="noopener"
181
- className="ui button primary download-as-xls"
182
- >
183
- <i className="ri-file-download-line"></i>
184
- Download search results
185
- </a>
186
- </div>
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
- {items.map((item, index) => (
206
- <tr key={item['@id']}>
207
- <td>{item.nis_species_name_original}</td>
208
- <td>{item.nis_species_name_accepted}</td>
209
- <td>{item.nis_scientificname_accepted}</td>
210
- <td>{item.nis_region}</td>
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
- <td>
227
- <div className="workflow-actions">
228
- <div className="action-buttons">
229
- <UniversalLink
230
- className="ui button secondary mini"
231
- href={`${item['@id']}`}
232
- >
233
- View
234
- </UniversalLink>
235
- <UniversalLink
236
- className="ui button primary mini"
237
- href={`${item['@id']}/edit`}
238
- >
239
- Edit
240
- </UniversalLink>
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
- <div className="workflow-progress">
243
- <ProgressWorkflow
244
- content={item}
245
- pathname={item['@id']}
246
- token={123}
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
- </div>
250
- </td>
251
- </tr>
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
+ });