@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.
- package/CHANGELOG.md +43 -0
- package/package.json +2 -1
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerEdit.jsx +22 -1
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.jsx +17 -12
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.test.jsx +1 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyFilters.jsx +59 -13
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.jsx +28 -16
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.test.jsx +54 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.jsx +68 -35
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.test.jsx +37 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.jsx +41 -39
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.test.jsx +39 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/index.js +2 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/schema.js +19 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/styles.less +33 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/utils.js +171 -93
- package/src/components/manage/Blocks/CaseStudyExplorer/utils.test.js +96 -1
- package/src/components/manage/Blocks/Title/CountryProfileView.jsx +27 -0
- package/src/components/manage/Blocks/Title/index.js +19 -0
- package/src/components/manage/Blocks/Title/schema.js +42 -0
- package/src/components/manage/Blocks/index.js +2 -0
- package/src/customizations/volto/reducers/navigation/navigation.js +6 -7
- 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.
|
|
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
|
|
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(
|
|
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
|
-
{
|
|
93
|
+
{pathFilteredCases.length ? (
|
|
89
94
|
<Grid columns={12}>
|
|
90
95
|
<Grid.Column mobile={12} tablet={12} computer={12}>
|
|
91
96
|
<CaseStudyMap
|
|
92
|
-
items={
|
|
97
|
+
items={pathFilteredCases}
|
|
93
98
|
activeItems={activeItems}
|
|
94
99
|
hideFilters={hideFilters}
|
|
95
100
|
selectedCase={selectedCase}
|
|
@@ -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
|
-
{
|
|
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
|
-
{
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
235
|
-
.
|
|
236
|
-
|
|
237
|
-
|
|
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 {
|
|
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(
|
|
88
|
+
pointsSource.addFeatures(
|
|
89
|
+
getFeatures({ cases: activeItems, ol: olRef.current }),
|
|
90
|
+
);
|
|
76
91
|
}
|
|
77
|
-
}, [activeItems, pointsSource
|
|
92
|
+
}, [activeItems, pointsSource]);
|
|
78
93
|
|
|
79
94
|
React.useEffect(() => {
|
|
80
95
|
if (!map) return;
|
|
81
96
|
|
|
82
|
-
const moveendListener = (
|
|
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 (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 = ({
|
|
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:
|
|
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
|
-
|
|
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({
|