@eeacms/volto-marine-policy 3.0.3 → 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,7 +4,34 @@ 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
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
8
35
 
9
36
  #### :rocket: New Features
10
37
 
@@ -75,8 +102,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
75
102
  #### :house: Internal changes
76
103
 
77
104
  - style: Automated code fix [eea-jenkins - [`0421290`](https://github.com/eea/volto-marine-policy/commit/0421290d7fdd9fdd0b6d483b53c424a0747137b3)]
78
- - chore: [JENKINSFILE] add package version in sonarqube [valentinab25 - [`fb1d0f0`](https://github.com/eea/volto-marine-policy/commit/fb1d0f083474b3a14b0ec36a329c7efc7499dd3d)]
79
- - chore: [JENKINSFILE] use sonarqube branches [EEA Jenkins - [`40d0b36`](https://github.com/eea/volto-marine-policy/commit/40d0b368836d32544b46ae7c7b070ffb71989b55)]
80
105
 
81
106
  #### :hammer_and_wrench: Others
82
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.3",
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,16 +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);
81
103
  const [duplicateIds, setDuplicateIds] = useState(null);
82
104
  const [duplicateGroups, setDuplicateGroups] = useState([]);
105
+ const [duplicatesLoading, setDuplicatesLoading] = useState(false);
106
+ const [errorMessage, setErrorMessage] = useState(null);
107
+ const [locationTick, setLocationTick] = useState(0);
83
108
 
84
109
  const [users, setUsers] = useState([]);
85
110
  const [assignee, setAssignee] = useState(null);
86
111
  const actions = useSelector((state) => state.actions.actions);
112
+ const token = useSelector((state) => state.userSession.token);
113
+ const currentUserId = token ? jwtDecode(token).sub : '';
87
114
  const canEditPage = actions?.object?.some((action) => action.id === 'edit');
88
115
 
89
116
  const toggleSelection = (id) => {
@@ -112,7 +139,10 @@ const NISListingView = ({ items, isEditMode }) => {
112
139
  const onBulkAssign = async (ids, assignee) => {
113
140
  setIsLoading(true);
114
141
  await fetch(
115
- `${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}`,
116
146
  {
117
147
  method: 'POST',
118
148
  headers: {
@@ -132,10 +162,14 @@ const NISListingView = ({ items, isEditMode }) => {
132
162
  };
133
163
 
134
164
  const handleCopy = async (item) => {
165
+ setErrorMessage(null);
135
166
  setIsLoading(true);
136
167
  try {
137
168
  const res = await fetch(
138
- `${window.env.apiPath}${item['@id']}/@copy-nis-record`,
169
+ `${window.env.apiPath}/++api++${item['@id'].replace(
170
+ /^\/marine/,
171
+ '',
172
+ )}/@copy-nis-record`,
139
173
  {
140
174
  method: 'POST',
141
175
  headers: {
@@ -147,26 +181,96 @@ const NISListingView = ({ items, isEditMode }) => {
147
181
  );
148
182
  if (res.ok) {
149
183
  window.location.reload();
184
+ } else {
185
+ setErrorMessage('Something went wrong. The copy was not successful.');
186
+ setIsLoading(false);
150
187
  }
151
188
  } catch (err) {
152
189
  // eslint-disable-next-line no-console
153
190
  console.error('Copy failed:', err);
191
+ setErrorMessage('Something went wrong. The copy was not successful.');
154
192
  setIsLoading(false);
155
193
  }
156
194
  };
157
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
+
158
243
  useEffect(() => {
159
244
  const parsed = qs.parse(window.location.search);
160
245
  if (parsed['check-duplicates']) {
161
- const containerPath = window.location.pathname.replace('/marine', '');
162
- fetch(`${window.env.apiPath}${containerPath}/@check-nis-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
+ )
163
262
  .then((res) => res.json())
164
263
  .then((data) => {
165
- setDuplicateIds(new Set(data.duplicate_ids));
264
+ setDuplicateIds(new Set(data.duplicate_ids.map(normalizeItemPath)));
166
265
  setDuplicateGroups(data.groups || []);
167
- });
266
+ })
267
+ .catch((err) => {
268
+ // eslint-disable-next-line no-console
269
+ console.error('Check duplicates failed:', err);
270
+ })
271
+ .finally(() => setDuplicatesLoading(false));
168
272
  }
169
- }, []);
273
+ }, [locationTick]);
170
274
 
171
275
  useEffect(() => {
172
276
  const fetchUsers = async () => {
@@ -193,6 +297,108 @@ const NISListingView = ({ items, isEditMode }) => {
193
297
  fetchUsers();
194
298
  }, []);
195
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
+
196
402
  return (
197
403
  <>
198
404
  {isLoading && (
@@ -200,8 +406,28 @@ const NISListingView = ({ items, isEditMode }) => {
200
406
  <Loader>Assigning...</Loader>
201
407
  </Dimmer>
202
408
  )}
203
- {canEditPage && (
409
+ {canEditPage && !duplicateIds && (
204
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>
205
431
  <Button
206
432
  className="primary"
207
433
  size="small"
@@ -209,32 +435,33 @@ const NISListingView = ({ items, isEditMode }) => {
209
435
  >
210
436
  <i className="ri-user-add-line"></i>Assign search results
211
437
  </Button>
212
- <div>
213
- <a
214
- href={`/marine/++api++${window.location.pathname.replace(
215
- '/marine',
216
- '',
217
- )}/nis-export${window.location.search}`}
218
- title="Download"
219
- target="_blank"
220
- rel="noopener"
221
- className="ui button primary download-as-xls"
222
- >
223
- <i className="ri-file-download-line"></i>
224
- Download search results
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>
237
- </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}
238
465
  </div>
239
466
  )}
240
467
  {duplicateIds && (
@@ -247,7 +474,11 @@ const NISListingView = ({ items, isEditMode }) => {
247
474
  borderRadius: '4px',
248
475
  }}
249
476
  >
250
- Showing {items.filter((i) => duplicateIds.has(i['@id'])).length}{' '}
477
+ Showing{' '}
478
+ {duplicateGroups.reduce(
479
+ (sum, g) => sum + (g.items ? g.items.length : 0),
480
+ 0,
481
+ )}{' '}
251
482
  duplicate records across {duplicateGroups.length} groups
252
483
  <a
253
484
  href={(() => {
@@ -262,6 +493,13 @@ const NISListingView = ({ items, isEditMode }) => {
262
493
  </a>
263
494
  </div>
264
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
+ )}
265
503
  <table className="ui table">
266
504
  <thead>
267
505
  <tr>
@@ -279,70 +517,87 @@ const NISListingView = ({ items, isEditMode }) => {
279
517
  </tr>
280
518
  </thead>
281
519
  <tbody>
282
- {(duplicateIds
283
- ? items.filter((item) => duplicateIds.has(item['@id']))
284
- : items
285
- ).map((item, index) => (
286
- <tr key={item['@id']}>
287
- <td>{item.nis_species_name_original}</td>
288
- <td>{item.nis_species_name_accepted}</td>
289
- <td>{item.nis_scientificname_accepted}</td>
290
- <td>{item.nis_region}</td>
291
- <td>{item.nis_subregion}</td>
292
- <td>{item.nis_country}</td>
293
- <td>{item.nis_status}</td>
294
- <td>{item.nis_group}</td>
295
- <td>{item.nis_year}</td>
296
- <td>
297
- <div className="assigned-to-container">
298
- <div>{formatAssignedTo(item.nis_assigned_to)}</div>
299
- {canEditPage && (
300
- <Checkbox
301
- checked={selectedItems.includes(item['@id'])}
302
- onChange={() => toggleSelection(item['@id'])}
303
- />
304
- )}
305
- </div>
520
+ {duplicateIds ? (
521
+ duplicateTableRows
522
+ ) : duplicatesLoading ? (
523
+ <tr>
524
+ <td colSpan="11" style={{ textAlign: 'center', padding: '20px' }}>
525
+ <Loader active inline />
306
526
  </td>
307
- <td>
308
- <div className="workflow-actions">
309
- <div className="action-buttons">
310
- <UniversalLink
311
- className="ui button secondary mini"
312
- href={`${item['@id']}`}
313
- target="_blank"
314
- rel="noopener noreferrer"
315
- >
316
- View
317
- </UniversalLink>
318
- <UniversalLink
319
- className="ui button primary mini"
320
- href={`${item['@id']}/edit`}
321
- target="_blank"
322
- rel="noopener noreferrer"
323
- >
324
- Edit
325
- </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>
326
543
  {canEditPage && (
327
- <Button
328
- className="tertiary mini"
329
- onClick={() => handleCopy(item)}
330
- >
331
- Copy
332
- </Button>
544
+ <Checkbox
545
+ checked={selectedItems.includes(item['@id'])}
546
+ onChange={() => toggleSelection(item['@id'])}
547
+ />
333
548
  )}
334
549
  </div>
335
- <div className="workflow-progress">
336
- <ProgressWorkflow
337
- content={item}
338
- pathname={item['@id']}
339
- token={123}
340
- />
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>
341
596
  </div>
342
- </div>
343
- </td>
344
- </tr>
345
- ))}
597
+ </td>
598
+ </tr>
599
+ ))
600
+ )}
346
601
  </tbody>
347
602
  </table>
348
603
  {selectedItems.length > 0 && (
@@ -5,12 +5,9 @@ import configureStore from 'redux-mock-store';
5
5
  import { Provider } from 'react-redux';
6
6
  import NISListingView from './NISListingView';
7
7
 
8
- const mockUseSelector = jest.fn();
9
-
10
8
  jest.mock('react-redux', () => ({
11
9
  __esModule: true,
12
10
  ...jest.requireActual('react-redux'),
13
- useSelector: (selector) => mockUseSelector(selector),
14
11
  }));
15
12
 
16
13
  jest.mock(
@@ -52,9 +49,16 @@ function buildItems(count = 3) {
52
49
  }
53
50
 
54
51
  function renderComponent(props = {}) {
55
- const { items = buildItems(), ...rest } = props;
52
+ const {
53
+ items = buildItems(),
54
+ actions = { object: [{ id: 'edit' }] },
55
+ token = null,
56
+ ...rest
57
+ } = props;
56
58
  const store = mockStore({
57
59
  intl: { locale: 'en', messages: {} },
60
+ actions: { actions },
61
+ userSession: { token },
58
62
  });
59
63
  return render(
60
64
  <Provider store={store}>
@@ -110,10 +114,6 @@ describe('NISListingView', () => {
110
114
  ok: true,
111
115
  });
112
116
  });
113
-
114
- mockUseSelector.mockReturnValue({
115
- object: [{ id: 'edit' }],
116
- });
117
117
  });
118
118
 
119
119
  describe('table rendering', () => {
@@ -236,22 +236,17 @@ describe('NISListingView', () => {
236
236
 
237
237
  describe('admin controls — canEditPage', () => {
238
238
  it('shows assign, download, and check-duplicates buttons when user can edit', async () => {
239
- mockUseSelector.mockReturnValue({
240
- object: [{ id: 'edit' }],
241
- });
242
239
  renderComponent();
243
240
  await waitFor(() => {
244
241
  expect(screen.getByText('Assign search results')).toBeInTheDocument();
245
242
  });
243
+ expect(screen.getByText('Add NIS record')).toBeInTheDocument();
246
244
  expect(screen.getByText('Download search results')).toBeInTheDocument();
247
245
  expect(screen.getByText('Check duplicates')).toBeInTheDocument();
248
246
  });
249
247
 
250
248
  it('hides admin controls when user cannot edit', async () => {
251
- mockUseSelector.mockReturnValue({
252
- object: [],
253
- });
254
- renderComponent();
249
+ renderComponent({ actions: { object: [] } });
255
250
  await waitFor(() => {
256
251
  expect(screen.getByText('Species name original')).toBeInTheDocument();
257
252
  });
@@ -265,10 +260,7 @@ describe('NISListingView', () => {
265
260
  });
266
261
 
267
262
  it('hides checkboxes and Copy button when user cannot edit', async () => {
268
- mockUseSelector.mockReturnValue({
269
- object: [],
270
- });
271
- renderComponent();
263
+ renderComponent({ actions: { object: [] } });
272
264
  await waitFor(() => {
273
265
  expect(screen.getByText('Species 1')).toBeInTheDocument();
274
266
  });
@@ -308,10 +300,38 @@ describe('NISListingView', () => {
308
300
  });
309
301
  }
310
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
+
311
331
  it('shows the duplicate info banner when check-duplicates param is set', async () => {
312
332
  mockDuplicateFetch(
313
333
  ['/marine/item-1', '/marine/item-3'],
314
- [{ id: 1 }, { id: 2 }],
334
+ [makeGroup(1), makeGroup(3)],
315
335
  );
316
336
  renderComponent();
317
337
  await waitFor(() => {
@@ -324,7 +344,7 @@ describe('NISListingView', () => {
324
344
  it('filters table to show only duplicate items', async () => {
325
345
  mockDuplicateFetch(
326
346
  ['/marine/item-1', '/marine/item-3'],
327
- [{ id: 1 }, { id: 2 }],
347
+ [makeGroup(1), makeGroup(3)],
328
348
  );
329
349
  const items = buildItems(5);
330
350
  renderComponent({ items });