@eeacms/volto-bise-policy 1.3.5 → 1.3.7

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.
Files changed (23) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +2 -1
  3. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerEdit.jsx +22 -1
  4. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.jsx +17 -12
  5. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.test.jsx +1 -0
  6. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyFilters.jsx +59 -13
  7. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.jsx +28 -16
  8. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.test.jsx +54 -0
  9. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.jsx +68 -35
  10. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.test.jsx +37 -0
  11. package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.jsx +41 -39
  12. package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.test.jsx +39 -0
  13. package/src/components/manage/Blocks/CaseStudyExplorer/index.js +2 -0
  14. package/src/components/manage/Blocks/CaseStudyExplorer/schema.js +19 -0
  15. package/src/components/manage/Blocks/CaseStudyExplorer/styles.less +33 -0
  16. package/src/components/manage/Blocks/CaseStudyExplorer/utils.js +171 -93
  17. package/src/components/manage/Blocks/CaseStudyExplorer/utils.test.js +96 -1
  18. package/src/components/manage/Blocks/Title/CountryProfileView.jsx +27 -0
  19. package/src/components/manage/Blocks/Title/index.js +19 -0
  20. package/src/components/manage/Blocks/Title/schema.js +42 -0
  21. package/src/components/manage/Blocks/index.js +2 -0
  22. package/src/customizations/volto/reducers/navigation/navigation.js +6 -7
  23. package/src/index.js +11 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,49 @@ 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.7](https://github.com/eea/volto-bise-policy/compare/1.3.6...1.3.7) - 20 August 2026
8
+
9
+ #### :rocket: New Features
10
+
11
+ - feat: add Country Profile variation with CountryFlag banner [Claudia Ifrim - [`baff0b6`](https://github.com/eea/volto-bise-policy/commit/baff0b6d00b7c159b3520b89bcaaa3ba400d8d5b)]
12
+
13
+ #### :bug: Bug Fixes
14
+
15
+ - fix: expand siblings on GET_CONTENT [Claudia Ifrim - [`5e10b2d`](https://github.com/eea/volto-bise-policy/commit/5e10b2d05293f39e9f49673377fd72bb19791435)]
16
+ - fix: preserve extra item fields and store data on direct GET_NAVIGATION calls [Claudia Ifrim - [`d3352d8`](https://github.com/eea/volto-bise-policy/commit/d3352d897102c69155b0740c8f44096b89011630)]
17
+
18
+ #### :house: Internal changes
19
+
20
+ - style: Automated code fix [eea-jenkins - [`8a103af`](https://github.com/eea/volto-bise-policy/commit/8a103af9cf9905de5eeb3ea660a2b076cfa14002)]
21
+
22
+ ### [1.3.6](https://github.com/eea/volto-bise-policy/compare/1.3.5...1.3.6) - 19 August 2026
23
+
24
+ #### :rocket: New Features
25
+
26
+ - feat: add dompurify dependency [laszlocseh - [`8e2eda4`](https://github.com/eea/volto-bise-policy/commit/8e2eda4eba5084e231e8457da1a98a47036bccb7)]
27
+ - feat(CaseStudyExplorer): add path filter and edit form [laszlocseh - [`ef277dc`](https://github.com/eea/volto-bise-policy/commit/ef277dc8a60a8ae746e635e4da48dfefc67685dc)]
28
+ - feat(CaseStudyExplorer): add status filters and map legend [laszlocseh - [`f2c28d2`](https://github.com/eea/volto-bise-policy/commit/f2c28d25d259d3d661cfbb261419ee435f7470d5)]
29
+
30
+ #### :bug: Bug Fixes
31
+
32
+ - fix(CaseStudyMap): properly display the selected case study style on map [laszlocseh - [`0b374a7`](https://github.com/eea/volto-bise-policy/commit/0b374a70803be40421e11de9776155299d1c0e22)]
33
+ - fix(CaseStudyExplorer): sanitize HTML and escape regex input [laszlocseh - [`e6ab8e1`](https://github.com/eea/volto-bise-policy/commit/e6ab8e1bd7a2d4e6b9341b19eda440828715a4d9)]
34
+
35
+ #### :nail_care: Enhancements
36
+
37
+ - refactor(case-study-explorer): replace hardcoded interaction index with helper [laszlocseh - [`fb69ec0`](https://github.com/eea/volto-bise-policy/commit/fb69ec082ff5e468be0f6e872e6a45e9e7c3695f)]
38
+
39
+ #### :house: Internal changes
40
+
41
+ - style: Automated code fix [eea-jenkins - [`66ec7fa`](https://github.com/eea/volto-bise-policy/commit/66ec7faab819bb27a0a786d459b6389a86bde21d)]
42
+ - style(CaseStudyExplorer): update status colors and fix indentation [laszlocseh - [`54cb574`](https://github.com/eea/volto-bise-policy/commit/54cb574a4947580b19a01307484fcedbf23175a3)]
43
+ - style: Automated code fix [eea-jenkins - [`5c742ae`](https://github.com/eea/volto-bise-policy/commit/5c742ae2581275f9c3bd83a8376d848bf5d33ead)]
44
+ - style: Automated code fix [eea-jenkins - [`4441648`](https://github.com/eea/volto-bise-policy/commit/44416480a18e217db2ebaf86bc2aaa4f60d31787)]
45
+ - style: Automated code fix [eea-jenkins - [`5eb0e32`](https://github.com/eea/volto-bise-policy/commit/5eb0e3284d2ef540c2cdc51984839cf50aad8b75)]
46
+
47
+ #### :hammer_and_wrench: Others
48
+
49
+ - test: fix case study utils.test.js [laszlocseh - [`adcffba`](https://github.com/eea/volto-bise-policy/commit/adcffba55f0cab391eef6db2b88880acffcea918)]
7
50
  ### [1.3.5](https://github.com/eea/volto-bise-policy/compare/1.3.4...1.3.5) - 5 August 2026
8
51
 
9
52
  #### :rocket: New Features
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.7",
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({