@eeacms/volto-marine-policy 3.0.1 → 3.0.3

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