@eeacms/volto-bise-policy 1.3.5 → 1.3.6

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,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
+ ### [1.3.6](https://github.com/eea/volto-bise-policy/compare/1.3.5...1.3.6) - 19 August 2026
8
+
9
+ #### :rocket: New Features
10
+
11
+ - feat: add dompurify dependency [laszlocseh - [`8e2eda4`](https://github.com/eea/volto-bise-policy/commit/8e2eda4eba5084e231e8457da1a98a47036bccb7)]
12
+ - feat(CaseStudyExplorer): add path filter and edit form [laszlocseh - [`ef277dc`](https://github.com/eea/volto-bise-policy/commit/ef277dc8a60a8ae746e635e4da48dfefc67685dc)]
13
+ - feat(CaseStudyExplorer): add status filters and map legend [laszlocseh - [`f2c28d2`](https://github.com/eea/volto-bise-policy/commit/f2c28d25d259d3d661cfbb261419ee435f7470d5)]
14
+
15
+ #### :bug: Bug Fixes
16
+
17
+ - fix(CaseStudyMap): properly display the selected case study style on map [laszlocseh - [`0b374a7`](https://github.com/eea/volto-bise-policy/commit/0b374a70803be40421e11de9776155299d1c0e22)]
18
+ - fix(CaseStudyExplorer): sanitize HTML and escape regex input [laszlocseh - [`e6ab8e1`](https://github.com/eea/volto-bise-policy/commit/e6ab8e1bd7a2d4e6b9341b19eda440828715a4d9)]
19
+
20
+ #### :nail_care: Enhancements
21
+
22
+ - refactor(case-study-explorer): replace hardcoded interaction index with helper [laszlocseh - [`fb69ec0`](https://github.com/eea/volto-bise-policy/commit/fb69ec082ff5e468be0f6e872e6a45e9e7c3695f)]
23
+
24
+ #### :house: Internal changes
25
+
26
+ - style: Automated code fix [eea-jenkins - [`66ec7fa`](https://github.com/eea/volto-bise-policy/commit/66ec7faab819bb27a0a786d459b6389a86bde21d)]
27
+ - style(CaseStudyExplorer): update status colors and fix indentation [laszlocseh - [`54cb574`](https://github.com/eea/volto-bise-policy/commit/54cb574a4947580b19a01307484fcedbf23175a3)]
28
+ - style: Automated code fix [eea-jenkins - [`5c742ae`](https://github.com/eea/volto-bise-policy/commit/5c742ae2581275f9c3bd83a8376d848bf5d33ead)]
29
+ - style: Automated code fix [eea-jenkins - [`4441648`](https://github.com/eea/volto-bise-policy/commit/44416480a18e217db2ebaf86bc2aaa4f60d31787)]
30
+ - style: Automated code fix [eea-jenkins - [`5eb0e32`](https://github.com/eea/volto-bise-policy/commit/5eb0e3284d2ef540c2cdc51984839cf50aad8b75)]
31
+
32
+ #### :hammer_and_wrench: Others
33
+
34
+ - test: fix case study utils.test.js [laszlocseh - [`adcffba`](https://github.com/eea/volto-bise-policy/commit/adcffba55f0cab391eef6db2b88880acffcea918)]
7
35
  ### [1.3.5](https://github.com/eea/volto-bise-policy/compare/1.3.4...1.3.5) - 5 August 2026
8
36
 
9
37
  #### :rocket: New Features
@@ -23,7 +51,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
23
51
 
24
52
  #### :house: Internal changes
25
53
 
26
- - chore: [JENKINSFILE] sanitize sonarqube command [valentinab25 - [`8e2dc1a`](https://github.com/eea/volto-bise-policy/commit/8e2dc1a14f3230f197f009a76bf87587724b6e3a)]
27
54
 
28
55
  #### :hammer_and_wrench: Others
29
56
 
@@ -127,8 +154,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
127
154
 
128
155
  #### :house: Internal changes
129
156
 
130
- - chore: [JENKINSFILE] add package version in sonarqube [valentinab25 - [`4c2d75b`](https://github.com/eea/volto-bise-policy/commit/4c2d75bbf6dca47929c6613749169b40ed483985)]
131
- - chore: [JENKINSFILE] use sonarqube branches [EEA Jenkins - [`f99c622`](https://github.com/eea/volto-bise-policy/commit/f99c622726cfd711704f50218a2f1fe833974ec3)]
132
157
 
133
158
  #### :hammer_and_wrench: Others
134
159
 
@@ -391,7 +416,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
391
416
  - chore: customize root theme to use volto-spotlight - refs #259794 [ana-oprea - [`347f96e`](https://github.com/eea/volto-bise-policy/commit/347f96e335139f9656abd34c308c531b05de85e7)]
392
417
  - chore: change the theme and alias to volto-spotlight and add volto-spotlight to package.json - refs #259794 [ana-oprea - [`52732a1`](https://github.com/eea/volto-bise-policy/commit/52732a17b3247a04ffae0a3f5118e173afa98157)]
393
418
  - chore: change theme to eea-volto-theme, change config.alias - refs #259794 [ana-oprea - [`c2d08ea`](https://github.com/eea/volto-bise-policy/commit/c2d08ea08a7f0e5f6f7d32149e60ea3f3e901923)]
394
- - chore: [JENKINS] Refactor automated testing [valentinab25 - [`a3c9488`](https://github.com/eea/volto-bise-policy/commit/a3c9488c5d09ba97fe5976675524d436080a2460)]
395
419
  - chore: husky, lint-staged use fixed versions [valentinab25 - [`fddc30f`](https://github.com/eea/volto-bise-policy/commit/fddc30f11a1d8ec290edaca2d924282f5e113bc6)]
396
420
  - chore:volto 16 in tests, update docs, fix stylelint overrides [valentinab25 - [`005ce74`](https://github.com/eea/volto-bise-policy/commit/005ce742dbd014fde27e417059251233365766dd)]
397
421
 
@@ -404,17 +428,9 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
404
428
  - test: Update jest,Jenkinsfile,lint to volto-addons-template PR30 [valentinab25 - [`c1277ba`](https://github.com/eea/volto-bise-policy/commit/c1277bacf840ee9feccbfd6a2c85f0b25a9a9125)]
405
429
  - test: Update jest,Jenkinsfile,lint to volto-addons-template PR30 [valentinab25 - [`c944153`](https://github.com/eea/volto-bise-policy/commit/c944153710fd53c5d48863cf363464273e25f52e)]
406
430
  - test: Update jest,Jenkinsfile,lint to volto-addons-template PR30 [valentinab25 - [`d648d52`](https://github.com/eea/volto-bise-policy/commit/d648d525a2fc5076577fe0f75ff9782bf0d1d4e9)]
407
- - test: [JENKINS] fix jest config, fail with logs on coverage [valentinab25 - [`2a7affe`](https://github.com/eea/volto-bise-policy/commit/2a7affe33d737704bdba117c4a5c8c6748ef7a47)]
408
- - test: [JENKINS] fix jest config, fail with logs on coverage [valentinab25 - [`5929642`](https://github.com/eea/volto-bise-policy/commit/59296420105d0e8c1f1547aa0cd17a019e478c2b)]
409
- - test: [JENKINS] fix jest config, fail with logs on coverage [valentinab25 - [`b1142f1`](https://github.com/eea/volto-bise-policy/commit/b1142f1da3667a9f56720f65bebce897a5dd8005)]
410
431
  - test: cypress should not wait for navigation, actions, types - refs #259794 [ana-oprea - [`188637c`](https://github.com/eea/volto-bise-policy/commit/188637c78a0cae71b5ee48022507d559dfaceb3e)]
411
- - test: [JENKINS] Use java17 for sonarqube scanner [valentinab25 - [`fa4e473`](https://github.com/eea/volto-bise-policy/commit/fa4e473018386ec3f09d7300d655c8cccdc19a0e)]
412
- - test: [JENKINS] Run cypress in started frontend container [valentinab25 - [`1d9135b`](https://github.com/eea/volto-bise-policy/commit/1d9135bb878b3c7a1b828085b99b9d27d78ce60e)]
413
432
  - remove cypress tests as it doesn t work on bise [Dobricean Ioan Dorian - [`ba7e2de`](https://github.com/eea/volto-bise-policy/commit/ba7e2defea2dcfd01537c3abb41fa9515ff0a3e3)]
414
433
  - add mega menu layout settings as #259794 said [dobri1408 - [`b9a3683`](https://github.com/eea/volto-bise-policy/commit/b9a3683e7d85f8c88ad8feac975c47c434f62c4a)]
415
- - test: [JENKINS] Add cpu limit on cypress docker [valentinab25 - [`9181908`](https://github.com/eea/volto-bise-policy/commit/9181908c7f4b51bdf83a34f8c7f25827de9b1cc6)]
416
- - test: [JENKINS] Increase shm-size to cypress docker [valentinab25 - [`099d796`](https://github.com/eea/volto-bise-policy/commit/099d79669471e6924bf1e0bc51a79d54379c3a1a)]
417
- - test: [JENKINS] Improve cypress time [valentinab25 - [`6d5d8e2`](https://github.com/eea/volto-bise-policy/commit/6d5d8e29b04ddb92628245570055067ca3dcbb6c)]
418
434
  - test: fix comment [valentinab25 - [`4f27d5a`](https://github.com/eea/volto-bise-policy/commit/4f27d5af2e8895c7af4f1157521ca42682b96e23)]
419
435
  - test: EN locales, pre-commit fix, feature PRs checks Refs #257193 [valentinab25 - [`4c2fcb0`](https://github.com/eea/volto-bise-policy/commit/4c2fcb01065e1ef962317ba7be788fd1df079618)]
420
436
  - test: Update Makefile and docker-compose to align it with Jenkinsfile [valentinab25 - [`89afaa1`](https://github.com/eea/volto-bise-policy/commit/89afaa15892930452027ef8c8593fc6d193cda70)]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-bise-policy",
3
- "version": "1.3.5",
3
+ "version": "1.3.6",
4
4
  "description": "@eeacms/volto-bise-policy: Volto add-on",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -33,6 +33,7 @@
33
33
  "@eeacms/volto-openlayers-map": "2.0.1",
34
34
  "@eeacms/volto-resize-helper": "*",
35
35
  "@plone-collective/volto-authomatic": "2.0.1",
36
+ "dompurify": "^3.3.1",
36
37
  "esri-loader": "3.7.0",
37
38
  "plotly.js": "^2.28.0",
38
39
  "querystring": "0.2.1",
@@ -1,5 +1,26 @@
1
+ import React from 'react';
2
+ import SidebarPortal from '@plone/volto/components/manage/Sidebar/SidebarPortal';
3
+ import InlineForm from '@plone/volto/components/manage/Form/InlineForm';
1
4
  import CaseStudyExplorerView from './CaseStudyExplorerView';
5
+ import schema from './schema';
2
6
 
3
7
  export default function CaseStudyExplorerEdit(props) {
4
- return <CaseStudyExplorerView {...props} mode="edit" />;
8
+ return (
9
+ <>
10
+ <CaseStudyExplorerView {...props} mode="edit" />
11
+ <SidebarPortal selected={props.selected}>
12
+ <InlineForm
13
+ schema={schema}
14
+ title={schema.title}
15
+ onChangeField={(id, value) => {
16
+ props.onChangeBlock(props.block, {
17
+ ...props.data,
18
+ [id]: value,
19
+ });
20
+ }}
21
+ formData={props.data}
22
+ />
23
+ </SidebarPortal>
24
+ </>
25
+ );
5
26
  }
@@ -6,7 +6,7 @@ import CaseStudyMap from './CaseStudyMap';
6
6
  import { ActiveFilters, CaseStudyFilters, SearchBox } from './CaseStudyFilters';
7
7
  // import CaseStudyList from './CaseStudyListing';
8
8
 
9
- import { filterCases, getFilters } from './utils';
9
+ import { filterCases, filterCasesByPath, getFilters } from './utils';
10
10
  import { useCases } from './hooks';
11
11
 
12
12
  import './styles.less';
@@ -14,7 +14,8 @@ import './styles.less';
14
14
  export default function CaseStudyExplorerView(props) {
15
15
  const cases_url = '/@@case-studies-map.arcgis.json';
16
16
  let cases = useCases(addAppURL(cases_url));
17
- const { caseStudiesIds } = props; // case studies from measure view
17
+ const { caseStudiesIds, data } = props; // case studies from measure view
18
+ const { filter_path } = data || {};
18
19
  const [selectedCase, onSelectedCase] = React.useState(null);
19
20
  const [searchInput, setSearchInput] = React.useState('');
20
21
  const hideFilters = caseStudiesIds ? true : false;
@@ -22,21 +23,24 @@ export default function CaseStudyExplorerView(props) {
22
23
  const [activeFilters, setActiveFilters] = React.useState({
23
24
  measures_implemented: [],
24
25
  typology_of_measures: [],
26
+ current_status: [],
27
+ habitat_ecosystem_type: [],
28
+ nrr_article: [],
29
+ scale_of_planning: [],
25
30
  });
26
31
 
27
32
  const [activeItems, setActiveItems] = React.useState(cases);
28
33
  const [filters, setFilters] = React.useState([]);
29
34
  const [map, setMap] = React.useState();
35
+ const pathFilteredCases = React.useMemo(
36
+ () => filterCasesByPath(cases, filter_path),
37
+ [cases, filter_path],
38
+ );
30
39
 
31
40
  React.useEffect(() => {
32
- const _filters = getFilters(cases);
41
+ const _filters = getFilters(pathFilteredCases);
33
42
  setFilters(_filters);
34
- }, [
35
- cases,
36
- activeFilters.measures_implemented,
37
- activeFilters.typology_of_measures,
38
- activeItems.length,
39
- ]);
43
+ }, [pathFilteredCases, activeFilters, activeItems.length]);
40
44
 
41
45
  React.useEffect(() => {
42
46
  let activeItems = filterCases(
@@ -44,10 +48,11 @@ export default function CaseStudyExplorerView(props) {
44
48
  activeFilters,
45
49
  caseStudiesIds,
46
50
  searchInput,
51
+ filter_path,
47
52
  );
48
53
 
49
54
  setActiveItems(activeItems);
50
- }, [caseStudiesIds, activeFilters, cases, searchInput]);
55
+ }, [caseStudiesIds, activeFilters, cases, searchInput, filter_path]);
51
56
 
52
57
  if (__SERVER__) return '';
53
58
 
@@ -85,11 +90,11 @@ export default function CaseStudyExplorerView(props) {
85
90
  )}
86
91
  </Grid.Row>
87
92
  <Grid.Row>
88
- {cases.length ? (
93
+ {pathFilteredCases.length ? (
89
94
  <Grid columns={12}>
90
95
  <Grid.Column mobile={12} tablet={12} computer={12}>
91
96
  <CaseStudyMap
92
- items={cases}
97
+ items={pathFilteredCases}
93
98
  activeItems={activeItems}
94
99
  hideFilters={hideFilters}
95
100
  selectedCase={selectedCase}
@@ -31,6 +31,7 @@ jest.mock('./CaseStudyFilters', () => ({
31
31
 
32
32
  jest.mock('./utils', () => ({
33
33
  filterCases: jest.fn(() => [{ id: 1, title: 'Filtered Case' }]),
34
+ filterCasesByPath: jest.fn((cases) => cases),
34
35
  getFilters: jest.fn(() => ['filter1', 'filter2']),
35
36
  }));
36
37
 
@@ -3,16 +3,7 @@ import React from 'react';
3
3
  import { centerAndResetMapZoom, scrollToElement } from './utils';
4
4
  import { withOpenLayers } from '@eeacms/volto-openlayers-map';
5
5
 
6
- const normalizeSearchInput = (searchInput) => {
7
- let normInput = searchInput
8
- .toLowerCase()
9
- .replace('(', '')
10
- .replace(')', '')
11
- .replace('?', '.')
12
- .replace('*', '[^\\s]+');
13
-
14
- return '\\b' + normInput + '\\b';
15
- };
6
+ const normalizeSearchInput = (searchInput) => searchInput.trim().toLowerCase();
16
7
 
17
8
  export function CaseStudyFilter(props) {
18
9
  const {
@@ -24,6 +15,7 @@ export function CaseStudyFilter(props) {
24
15
  map,
25
16
  ol,
26
17
  } = props;
18
+ const filterEntries = Object.entries(filters?.[filterName] || {});
27
19
 
28
20
  const showInputs = (event) => {
29
21
  event.currentTarget.parentElement.classList.add('active');
@@ -33,6 +25,7 @@ export function CaseStudyFilter(props) {
33
25
  <div className="filter-wrapper">
34
26
  <button
35
27
  className="ui basic button facet-btn"
28
+ disabled={!filterEntries.length}
36
29
  onClick={(e) => showInputs(e)}
37
30
  >
38
31
  <span>
@@ -41,7 +34,9 @@ export function CaseStudyFilter(props) {
41
34
  </span>
42
35
  </button>
43
36
  <div className="filter-inputs-wrapper">
44
- {Object.entries(filters?.[filterName] || {}).length > 7 ? (
37
+ {!filterEntries.length ? (
38
+ <div className="filter-empty">No options available</div>
39
+ ) : filterEntries.length > 7 ? (
45
40
  <input
46
41
  type="text"
47
42
  className="filterInputText"
@@ -66,7 +61,7 @@ export function CaseStudyFilter(props) {
66
61
  )}
67
62
 
68
63
  <div className="filter-inputs">
69
- {Object.entries(filters?.[filterName] || {})
64
+ {filterEntries
70
65
  .sort((item1, item2) => item1[1].localeCompare(item2[1]))
71
66
  .map(([value, label], index) => (
72
67
  <label
@@ -138,6 +133,27 @@ function CaseStudyFiltersComponent(props) {
138
133
  map={map}
139
134
  ol={ol}
140
135
  />
136
+
137
+ <CaseStudyFilter
138
+ filterTitle="Current status"
139
+ filterName="current_status"
140
+ {...props}
141
+ />
142
+ <CaseStudyFilter
143
+ filterTitle="Habitat/ecosystem type"
144
+ filterName="habitat_ecosystem_type"
145
+ {...props}
146
+ />
147
+ <CaseStudyFilter
148
+ filterTitle="NRR Article"
149
+ filterName="nrr_article"
150
+ {...props}
151
+ />
152
+ <CaseStudyFilter
153
+ filterTitle="Scale of Planning"
154
+ filterName="scale_of_planning"
155
+ {...props}
156
+ />
141
157
  </>
142
158
  );
143
159
  }
@@ -266,7 +282,9 @@ function ActiveFiltersComponent(props) {
266
282
  for (let i = 0; i < filterInputs.length; i++) {
267
283
  filterInputs[i].checked = false;
268
284
  }
269
- setActiveFilters({ measures_implemented: [], typology_of_measures: [] });
285
+ setActiveFilters(
286
+ Object.fromEntries(Object.keys(activeFilters).map((key) => [key, []])),
287
+ );
270
288
  scrollToElement('search-input');
271
289
  };
272
290
 
@@ -314,6 +332,34 @@ function ActiveFiltersComponent(props) {
314
332
  filters={filters}
315
333
  onRemoveFilter={removeFilter}
316
334
  />
335
+ <FilterGroup
336
+ filterName="current_status"
337
+ filterLabel="Current status:"
338
+ activeFilterCodes={activeFilters.current_status}
339
+ filters={filters}
340
+ onRemoveFilter={removeFilter}
341
+ />
342
+ <FilterGroup
343
+ filterName="habitat_ecosystem_type"
344
+ filterLabel="Habitat/ecosystem type:"
345
+ activeFilterCodes={activeFilters.habitat_ecosystem_type}
346
+ filters={filters}
347
+ onRemoveFilter={removeFilter}
348
+ />
349
+ <FilterGroup
350
+ filterName="nrr_article"
351
+ filterLabel="NRR Article:"
352
+ activeFilterCodes={activeFilters.nrr_article}
353
+ filters={filters}
354
+ onRemoveFilter={removeFilter}
355
+ />
356
+ <FilterGroup
357
+ filterName="scale_of_planning"
358
+ filterLabel="Scale of Planning:"
359
+ activeFilterCodes={activeFilters.scale_of_planning}
360
+ filters={filters}
361
+ onRemoveFilter={removeFilter}
362
+ />
317
363
  </div>
318
364
  </div>
319
365
  </div>
@@ -1,8 +1,11 @@
1
1
  import React from 'react';
2
+ import DOMPurify from 'dompurify';
2
3
  import { withOpenLayers } from '@eeacms/volto-openlayers-map';
3
4
 
4
5
  import {
5
6
  centerAndResetMapZoom,
7
+ escapeRegExp,
8
+ getSelectInteraction,
6
9
  scrollToElement,
7
10
  zoomMapToFeatures,
8
11
  } from './utils';
@@ -33,7 +36,9 @@ const showPageNr = (pageNr, currentPage, numberOfPages) => {
33
36
  function CaseStudyList(props) {
34
37
  const { selectedCase, onSelectedCase, pointsSource, map, searchInput, ol } =
35
38
  props;
36
- const reSearch = new RegExp(`\\b(${searchInput})\\b`, 'gi');
39
+ const reSearch = searchInput
40
+ ? new RegExp(`\\b(${escapeRegExp(searchInput)})\\b`, 'gi')
41
+ : null;
37
42
  const [currentPage, setCurrentPage] = React.useState(1);
38
43
 
39
44
  const features = pointsSource
@@ -132,7 +137,7 @@ function CaseStudyList(props) {
132
137
  // reset map zoom
133
138
  onSelectedCase(null);
134
139
  centerAndResetMapZoom({ map, ol });
135
- map.getInteractions().array_[9].getFeatures().clear();
140
+ getSelectInteraction(map)?.getFeatures().clear();
136
141
  }}
137
142
  >
138
143
  <span
@@ -167,12 +172,17 @@ function CaseStudyList(props) {
167
172
  <p
168
173
  className="listing-description"
169
174
  dangerouslySetInnerHTML={{
170
- __html: searchInput
171
- ? item.values_.description.replaceAll(
172
- reSearch,
173
- '<b>$1</b>',
174
- )
175
- : item.values_.description,
175
+ // description is server data and may contain HTML;
176
+ // sanitize the highlighted result so no scripts or
177
+ // event handlers can reach the DOM
178
+ __html: DOMPurify.sanitize(
179
+ searchInput && reSearch && item.values_.description
180
+ ? item.values_.description.replaceAll(
181
+ reSearch,
182
+ '<b>$1</b>',
183
+ )
184
+ : item.values_.description || '',
185
+ ),
176
186
  }}
177
187
  ></p>
178
188
  <div className="slot-bottom">
@@ -212,10 +222,9 @@ function CaseStudyList(props) {
212
222
  role="button"
213
223
  onKeyDown={() => {}}
214
224
  onClick={() => {
215
- map
216
- .getInteractions()
217
- .array_[9].getFeatures()
218
- .clear();
225
+ const selectInteraction = getSelectInteraction(map);
226
+ selectInteraction?.getFeatures().clear();
227
+
219
228
  // scroll to the map
220
229
  scrollToElement('ol-map-container');
221
230
 
@@ -231,10 +240,13 @@ function CaseStudyList(props) {
231
240
  const coords =
232
241
  item.values_.geometry.flatCoordinates;
233
242
  const pixel = map.getPixelFromCoordinate(coords);
234
- map
235
- .getInteractions()
236
- .array_[9].getFeatures()
237
- .push(map.getFeaturesAtPixel(pixel)[0]);
243
+ const selectedFeature =
244
+ map.getFeaturesAtPixel(pixel)[0];
245
+ if (selectedFeature) {
246
+ selectInteraction
247
+ ?.getFeatures()
248
+ .push(selectedFeature);
249
+ }
238
250
  }, 1100);
239
251
  }}
240
252
  >
@@ -14,6 +14,7 @@ jest.mock('@eeacms/volto-openlayers-map', () => ({
14
14
  }));
15
15
 
16
16
  jest.mock('./utils', () => ({
17
+ ...jest.requireActual('./utils'),
17
18
  centerAndResetMapZoom: jest.fn(),
18
19
  scrollToElement: jest.fn(),
19
20
  zoomMapToFeatures: jest.fn(),
@@ -119,6 +120,59 @@ describe('CaseStudyList', () => {
119
120
  expect(getByText('Feature 1').getAttribute('href')).toBe('/feature1');
120
121
  });
121
122
 
123
+ it('does not crash on regex metacharacters in search input', () => {
124
+ const { getByText } = render(
125
+ <CaseStudyList
126
+ selectedCase={null}
127
+ onSelectedCase={jest.fn()}
128
+ pointsSource={mockPointsSource}
129
+ map={mockMap}
130
+ searchInput="("
131
+ ol={{}}
132
+ />,
133
+ );
134
+
135
+ // must not throw (previously: SyntaxError from invalid RegExp)
136
+ expect(getByText('Feature 1')).toBeInTheDocument();
137
+ expect(getByText('Description 1')).toBeInTheDocument();
138
+ });
139
+
140
+ it('sanitizes description HTML and keeps search highlights', () => {
141
+ const evilPointsSource = createMockPointsSource([
142
+ {
143
+ values_: {
144
+ title: 'Evil Case',
145
+ path: '/evil',
146
+ description:
147
+ '<img src=x onerror=alert(1)> <script>alert(2)</script> a river bed',
148
+ typology_of_measures: ['SectorA'],
149
+ measures_implemented: [{ title: 'M1', path: '/m1' }],
150
+ geometry: { flatCoordinates: [0, 0] },
151
+ },
152
+ },
153
+ ]);
154
+
155
+ const { container } = render(
156
+ <CaseStudyList
157
+ selectedCase={null}
158
+ onSelectedCase={jest.fn()}
159
+ pointsSource={evilPointsSource}
160
+ map={mockMap}
161
+ searchInput="river"
162
+ ol={{}}
163
+ />,
164
+ );
165
+
166
+ // XSS payloads must not survive into the DOM
167
+ expect(container.querySelector('img[onerror]')).toBeNull();
168
+ expect(container.querySelector('script')).toBeNull();
169
+ // harmless text still renders and the highlight survives sanitization
170
+ expect(container.textContent).toContain('a river bed');
171
+ const highlight = container.querySelector('b');
172
+ expect(highlight).not.toBeNull();
173
+ expect(highlight.textContent).toBe('river');
174
+ });
175
+
122
176
  it('calls zoomMapToFeatures and onSelectedCase when clicking Show on map', async () => {
123
177
  const onSelectedCase = jest.fn();
124
178
 
@@ -16,7 +16,13 @@ import InfoOverlay from './InfoOverlay';
16
16
  import FeatureInteraction from './FeatureInteraction';
17
17
  import CaseStudyList from './CaseStudyListing';
18
18
 
19
- import { centerAndResetMapZoom, getFeatures, scrollToElement } from './utils';
19
+ import {
20
+ centerAndResetMapZoom,
21
+ CLUSTER_COLOR,
22
+ getFeatures,
23
+ getSelectInteraction,
24
+ scrollToElement,
25
+ } from './utils';
20
26
 
21
27
  const styleCache = {};
22
28
  const MapContextGateway = ({ setMap }) => {
@@ -69,32 +75,43 @@ function CaseStudyMap(props) {
69
75
  }),
70
76
  );
71
77
 
78
+ // `ol` is a fresh object literal on every render (see withOpenLayers); keep
79
+ // the latest reference in a ref so effects can depend only on values that
80
+ // actually change. Refreshing the points source regenerates the cluster
81
+ // features, which drops the currently selected feature from the rendered set.
82
+ const olRef = React.useRef(ol);
83
+ olRef.current = ol;
84
+
72
85
  React.useEffect(() => {
73
86
  if (activeItems) {
74
87
  pointsSource.clear();
75
- pointsSource.addFeatures(getFeatures({ cases: activeItems, ol }));
88
+ pointsSource.addFeatures(
89
+ getFeatures({ cases: activeItems, ol: olRef.current }),
90
+ );
76
91
  }
77
- }, [activeItems, pointsSource, ol]);
92
+ }, [activeItems, pointsSource]);
78
93
 
79
94
  React.useEffect(() => {
80
95
  if (!map) return;
81
96
 
82
- const moveendListener = (e) => {
83
- // console.log('map.getView()', map.getView());
84
- // console.log('selectedCase', selectedCase);
97
+ const moveendListener = () => {
85
98
  const mapZoom = Math.round(map.getView().getZoom() * 10) / 10;
86
99
  const mapCenter = map.getView().getCenter();
100
+ const selectInteraction = getSelectInteraction(map);
87
101
 
88
- if (selectedCase) {
89
- const coords = selectedCase.geometry.flatCoordinates;
90
- const pixel = map.getPixelFromCoordinate(coords);
91
- map.getInteractions().array_[9].getFeatures().clear();
92
- map
93
- .getInteractions()
94
- .array_[9].getFeatures()
95
- .push(map.getFeaturesAtPixel(pixel)[0]);
96
- } else {
97
- map.getInteractions().array_[9].getFeatures().clear();
102
+ if (selectInteraction) {
103
+ if (selectedCase) {
104
+ const coords = selectedCase.geometry.flatCoordinates;
105
+ const pixel = map.getPixelFromCoordinate(coords);
106
+ const selectedFeature = map.getFeaturesAtPixel(pixel)[0];
107
+
108
+ selectInteraction.getFeatures().clear();
109
+ if (selectedFeature) {
110
+ selectInteraction.getFeatures().push(selectedFeature);
111
+ }
112
+ } else {
113
+ selectInteraction.getFeatures().clear();
114
+ }
98
115
  }
99
116
 
100
117
  if (
@@ -113,12 +130,9 @@ function CaseStudyMap(props) {
113
130
  return () => {
114
131
  map.un('moveend', moveendListener);
115
132
  };
116
- }, [map, selectedCase, resetMapButtonClass, setResetMapButtonClass, ol]);
133
+ }, [map, selectedCase, ol.proj, setResetMapButtonClass]);
117
134
 
118
- const clusterStyle = React.useMemo(
119
- () => selectedClusterStyle({ selectedCase, ol }),
120
- [selectedCase, ol],
121
- );
135
+ const clusterStyle = React.useMemo(() => selectedClusterStyle({ ol }), [ol]);
122
136
 
123
137
  const MapWithSelection = React.useMemo(() => Map, []);
124
138
  // console.log('render');
@@ -148,7 +162,7 @@ function CaseStudyMap(props) {
148
162
  scrollToElement('search-input');
149
163
  onSelectedCase(null);
150
164
  centerAndResetMapZoom({ map, ol });
151
- map.getInteractions().array_[9].getFeatures().clear();
165
+ getSelectInteraction(map)?.getFeatures().clear();
152
166
  }}
153
167
  >
154
168
  <span className="result-info-title">Reset map</span>
@@ -164,7 +178,6 @@ function CaseStudyMap(props) {
164
178
  <FeatureInteraction
165
179
  onFeatureSelect={onSelectedCase}
166
180
  hideFilters={hideFilters}
167
- selectedCase={selectedCase}
168
181
  />
169
182
  <Layer.Tile source={tileWMSSources[0]} zIndex={0} />
170
183
  <Layer.Vector
@@ -176,20 +189,41 @@ function CaseStudyMap(props) {
176
189
  </Layers>
177
190
  </MapWithSelection>
178
191
  {hideFilters ? null : (
179
- <CaseStudyList
180
- map={map}
181
- activeItems={activeItems}
182
- selectedCase={selectedCase}
183
- onSelectedCase={onSelectedCase}
184
- pointsSource={pointsSource}
185
- searchInput={searchInput}
186
- />
192
+ <>
193
+ <div
194
+ className="case-study-status-legend"
195
+ aria-label="Case study status legend"
196
+ >
197
+ <span className="legend-title">Current status</span>
198
+ {[
199
+ ['planned', '#006bb8'],
200
+ ['ongoing', '#ff9933'],
201
+ ['completed', '#00a390'],
202
+ ].map(([status, color]) => (
203
+ <span className="legend-item" key={status}>
204
+ <span
205
+ className="legend-dot"
206
+ style={{ backgroundColor: color }}
207
+ />
208
+ {status}
209
+ </span>
210
+ ))}
211
+ </div>
212
+ <CaseStudyList
213
+ map={map}
214
+ activeItems={activeItems}
215
+ selectedCase={selectedCase}
216
+ onSelectedCase={onSelectedCase}
217
+ pointsSource={pointsSource}
218
+ searchInput={searchInput}
219
+ />
220
+ </>
187
221
  )}
188
222
  </div>
189
223
  ) : null;
190
224
  }
191
225
 
192
- const selectedClusterStyle = ({ selectedFeature, ol }) => {
226
+ const selectedClusterStyle = ({ ol }) => {
193
227
  function _clusterStyle(feature) {
194
228
  const size = feature.get('features').length;
195
229
  let style = styleCache[size];
@@ -202,7 +236,7 @@ const selectedClusterStyle = ({ selectedFeature, ol }) => {
202
236
  color: '#fff',
203
237
  }),
204
238
  fill: new ol.style.Fill({
205
- color: '#007B6C', // #006BB8 #309ebc
239
+ color: CLUSTER_COLOR,
206
240
  }),
207
241
  }),
208
242
  text: new ol.style.Text({
@@ -216,8 +250,7 @@ const selectedClusterStyle = ({ selectedFeature, ol }) => {
216
250
  }
217
251
 
218
252
  if (size === 1) {
219
- // let color = feature.values_.features[0].values_['color'];
220
- let color = '#289588'; // #0083E0 #50B0A4
253
+ const color = feature.values_.features[0].get('color');
221
254
 
222
255
  return new ol.style.Style({
223
256
  image: new ol.style.Circle({