@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
@@ -31,6 +31,7 @@ jest.mock('./CaseStudyListing', () => () => (
31
31
 
32
32
  jest.mock('./utils', () => ({
33
33
  getFeatures: jest.fn(),
34
+ getSelectInteraction: jest.fn(() => null),
34
35
  centerAndResetMapZoom: jest.fn(),
35
36
  scrollToElement: jest.fn(),
36
37
  }));
@@ -85,6 +86,42 @@ describe('CaseStudyMap', () => {
85
86
  ).not.toBeInTheDocument();
86
87
  });
87
88
 
89
+ it('does not refresh the points source when only the ol wrapper identity changes', () => {
90
+ getFeatures.mockReturnValue([{ id: 1 }]);
91
+ const activeItems = [{ id: 1 }];
92
+
93
+ const { rerender } = render(
94
+ <CaseStudyMap
95
+ items={[{ id: 1 }]}
96
+ activeItems={activeItems}
97
+ ol={mockOl}
98
+ setMap={jest.fn()}
99
+ map={mockMap}
100
+ onSelectedCase={jest.fn()}
101
+ />,
102
+ );
103
+
104
+ const pointsSource = mockOl.source.Vector.mock.results[0].value;
105
+ const clearCount = pointsSource.clear.mock.calls.length;
106
+ expect(clearCount).toBeGreaterThan(0);
107
+
108
+ // withOpenLayers builds a fresh `ol` object literal on every render;
109
+ // refreshing the points source would regenerate the cluster features and
110
+ // drop the currently selected feature from the rendered set.
111
+ rerender(
112
+ <CaseStudyMap
113
+ items={[{ id: 1 }]}
114
+ activeItems={activeItems}
115
+ ol={{ ...mockOl }}
116
+ setMap={jest.fn()}
117
+ map={mockMap}
118
+ onSelectedCase={jest.fn()}
119
+ />,
120
+ );
121
+
122
+ expect(pointsSource.clear.mock.calls.length).toBe(clearCount);
123
+ });
124
+
88
125
  it('clicking Reset map calls utils and clears features', () => {
89
126
  getFeatures.mockReturnValueOnce([{ id: 1 }]);
90
127
  const onSelectedCase = jest.fn();
@@ -18,79 +18,81 @@ export const useStyles = ({ ol }) => {
18
18
  }),
19
19
  }),
20
20
  }),
21
- [ol],
21
+ [ol.style],
22
22
  );
23
23
 
24
- const selectStyle = React.useCallback(
25
- (feature) => {
26
- // const color = feature.values_.features[0].values_['color'] || '#ccc';
27
- const color = '#0A99FF'; // #004B7F #309ebc #0A99FF
28
- // console.log(color);
29
- selected.image_.getFill().setColor(color);
30
- return selected;
31
- },
32
- [selected],
33
- );
24
+ const selectStyle = React.useCallback(() => {
25
+ selected.image_.getFill().setColor('#0A99FF');
26
+ return selected;
27
+ }, [selected]);
34
28
 
35
29
  return { selected, selectStyle };
36
30
  };
37
31
 
38
- function FeatureInteraction({
39
- onFeatureSelect,
40
- hideFilters,
41
- selectedCase,
42
- ol,
43
- }) {
44
- // console.log('featureinteraction', selectedCase);
32
+ function FeatureInteraction({ onFeatureSelect, hideFilters, ol }) {
45
33
  const { map } = useMapContext();
46
34
  const { selectStyle } = useStyles({ ol });
47
35
 
48
- const select = new ol.interaction.Select({
49
- condition: ol.condition.click,
50
- style: hideFilters ? null : selectStyle,
51
- });
36
+ // Keep callbacks current without recreating the OpenLayers interaction.
37
+ const onFeatureSelectRef = React.useRef(onFeatureSelect);
38
+ onFeatureSelectRef.current = onFeatureSelect;
39
+ const hideFiltersRef = React.useRef(hideFilters);
40
+ hideFiltersRef.current = hideFilters;
41
+ const olRef = React.useRef(ol);
42
+ olRef.current = ol;
43
+
44
+ // Select stores the selected rendered cluster feature and applies its style
45
+ // directly to that feature. Recreating it after selectedCase changes clears
46
+ // that collection and restores the feature's original style.
47
+ const selectRef = React.useRef(null);
48
+ if (!selectRef.current) {
49
+ selectRef.current = new ol.interaction.Select({
50
+ condition: ol.condition.click,
51
+ style: hideFilters ? null : selectStyle,
52
+ });
53
+ }
54
+ const select = selectRef.current;
52
55
 
53
56
  React.useEffect(() => {
54
57
  if (!map) return;
55
58
 
56
- select.on('select', function (e) {
59
+ const onSelect = (e) => {
57
60
  const features = e.target.getFeatures().getArray();
58
61
 
59
62
  features.forEach((feature) => {
60
63
  const subfeatures = feature.values_.features;
61
64
  if (subfeatures.length === 1) {
62
65
  const selectedFeature = subfeatures[0].values_;
63
- if (hideFilters) {
66
+ if (hideFiltersRef.current) {
64
67
  const url = window.location.origin + selectedFeature.path;
65
68
  // window.open(url);
66
69
  window.location.href = url;
67
70
  }
68
- onFeatureSelect(selectedFeature);
71
+ onFeatureSelectRef.current(selectedFeature);
69
72
  scrollToElement('ol-map-container');
70
- // map.getView().animate({
71
- // duration: 10,
72
- // center: selectedFeature.geometry.flatCoordinates,
73
- // });
74
73
  } else {
75
- onFeatureSelect(null);
76
- zoomMapToFeatures({ map, features: subfeatures, ol });
74
+ onFeatureSelectRef.current(null);
75
+ zoomMapToFeatures({ map, features: subfeatures, ol: olRef.current });
77
76
  }
78
77
  });
78
+ };
79
79
 
80
- return null;
81
- });
82
-
80
+ select.on('select', onSelect);
83
81
  map.addInteraction(select);
84
82
 
85
- map.on('pointermove', (e) => {
83
+ const onPointerMove = (e) => {
86
84
  const pixel = map.getEventPixel(e.originalEvent);
87
85
  const hit = map.hasFeatureAtPixel(pixel);
88
86
  map.getViewport().style.cursor = hit ? 'pointer' : '';
89
- });
87
+ };
88
+ map.on('pointermove', onPointerMove);
90
89
 
91
- return () => map.removeInteraction(select);
92
- // eslint-disable-next-line react-hooks/exhaustive-deps
93
- }, [map, selectStyle, onFeatureSelect, hideFilters]);
90
+ return () => {
91
+ select.un('select', onSelect);
92
+ map.un('pointermove', onPointerMove);
93
+ map.removeInteraction(select);
94
+ };
95
+ }, [map, select]);
94
96
 
95
97
  return null;
96
98
  }
@@ -35,6 +35,7 @@ const mockOl = createMockOl({
35
35
  mockSelectCb = cb;
36
36
  }
37
37
  }),
38
+ un: jest.fn(),
38
39
  })),
39
40
  },
40
41
  });
@@ -43,6 +44,7 @@ const mockMap = {
43
44
  addInteraction,
44
45
  removeInteraction,
45
46
  on: onMap,
47
+ un: jest.fn(),
46
48
  getEventPixel,
47
49
  hasFeatureAtPixel,
48
50
  getViewport,
@@ -66,6 +68,43 @@ describe('FeatureInteraction', () => {
66
68
  mockSelectCb = null;
67
69
  });
68
70
 
71
+ it('configures the selection style when filters are visible', () => {
72
+ render(
73
+ <FeatureInteraction
74
+ onFeatureSelect={jest.fn()}
75
+ hideFilters={false}
76
+ selectedCase={null}
77
+ ol={mockOl}
78
+ />,
79
+ );
80
+
81
+ const options = mockOl.interaction.Select.mock.calls[0][0];
82
+ expect(options.style).toEqual(expect.any(Function));
83
+ });
84
+
85
+ it('keeps one Select interaction across rerenders', () => {
86
+ const { rerender } = render(
87
+ <FeatureInteraction
88
+ onFeatureSelect={jest.fn()}
89
+ hideFilters={false}
90
+ selectedCase={null}
91
+ ol={mockOl}
92
+ />,
93
+ );
94
+
95
+ rerender(
96
+ <FeatureInteraction
97
+ onFeatureSelect={jest.fn()}
98
+ hideFilters={false}
99
+ selectedCase={{ path: '/selected' }}
100
+ ol={{ ...mockOl }}
101
+ />,
102
+ );
103
+
104
+ expect(mockOl.interaction.Select).toHaveBeenCalledTimes(1);
105
+ expect(addInteraction).toHaveBeenCalledTimes(1);
106
+ });
107
+
69
108
  it('selects a single feature and calls onFeatureSelect + scrollToElement (hideFilters=false)', () => {
70
109
  const onFeatureSelect = jest.fn();
71
110
  const singleFeature = {
@@ -1,6 +1,7 @@
1
1
  import worldSVG from '@plone/volto/icons/world.svg';
2
2
  import CaseStudyExplorerEdit from './CaseStudyExplorerEdit';
3
3
  import CaseStudyExplorerView from './CaseStudyExplorerView';
4
+ import schema from './schema';
4
5
 
5
6
  export default function installCaseStudyExplorerBlock(config) {
6
7
  config.blocks.blocksConfig.caseStudyExplorer = {
@@ -10,6 +11,7 @@ export default function installCaseStudyExplorerBlock(config) {
10
11
  group: 'custom_blocks',
11
12
  edit: CaseStudyExplorerEdit,
12
13
  view: CaseStudyExplorerView,
14
+ schema,
13
15
  };
14
16
 
15
17
  return config;
@@ -0,0 +1,19 @@
1
+ const schema = {
2
+ title: 'NRR Case Study Explorer',
3
+ fieldsets: [
4
+ {
5
+ id: 'default',
6
+ title: 'Default',
7
+ fields: ['filter_path'],
8
+ },
9
+ ],
10
+ properties: {
11
+ filter_path: {
12
+ title: 'Filter path',
13
+ type: 'string',
14
+ },
15
+ },
16
+ required: [],
17
+ };
18
+
19
+ export default schema;
@@ -2,6 +2,39 @@
2
2
  padding-top: 2em;
3
3
  }
4
4
 
5
+ .nrr-case-study-map-container .case-study-status-legend {
6
+ display: flex;
7
+ flex-wrap: wrap;
8
+ align-items: center;
9
+ justify-content: flex-end;
10
+ margin: 0.75rem 0;
11
+ font-size: 0.9rem;
12
+ gap: 0.75rem;
13
+ }
14
+
15
+ .nrr-case-study-map-container .legend-title {
16
+ font-weight: 600;
17
+ }
18
+
19
+ .nrr-case-study-map-container .legend-item {
20
+ display: inline-flex;
21
+ align-items: center;
22
+ gap: 0.3rem;
23
+ text-transform: capitalize;
24
+ }
25
+
26
+ .nrr-case-study-map-container .legend-dot {
27
+ display: inline-block;
28
+ width: 0.75rem;
29
+ height: 0.75rem;
30
+ border-radius: 50%;
31
+ }
32
+
33
+ .nrr-case-study-map-container .filter-wrapper .facet-btn:disabled {
34
+ cursor: not-allowed;
35
+ opacity: 0.55;
36
+ }
37
+
5
38
  #csepopup > span {
6
39
  display: 'block';
7
40
  margin-bottom: '10px';
@@ -1,3 +1,50 @@
1
+ export const STATUS_COLORS = {
2
+ planned: '#006bb8',
3
+ ongoing: '#ff9933',
4
+ completed: '#00a390',
5
+ };
6
+
7
+ export const FALLBACK_STATUS_COLOR = '#6B7280';
8
+ export const CLUSTER_COLOR = '#007B6C';
9
+
10
+ const FIELD_ALIASES = {
11
+ habitat_ecosystem_type: ['habitat_ecosystem_type', 'ecosystem_typology'],
12
+ };
13
+
14
+ export function getCaseProperty(properties, fieldName) {
15
+ if (properties?.[fieldName] !== undefined) return properties[fieldName];
16
+ return (FIELD_ALIASES[fieldName] || [])
17
+ .map((field) => properties?.[field])
18
+ .find((value) => value !== undefined);
19
+ }
20
+
21
+ export function asValues(value) {
22
+ if (value === undefined || value === null || value === '') return [];
23
+ return Array.isArray(value) ? value : [value];
24
+ }
25
+
26
+ export function valueCode(value) {
27
+ if (value && typeof value === 'object') {
28
+ return String(
29
+ value.id ?? value.value ?? value.token ?? value.title ?? value,
30
+ );
31
+ }
32
+ return String(value);
33
+ }
34
+
35
+ export function valueLabel(value) {
36
+ if (value && typeof value === 'object') {
37
+ return String(
38
+ value.title ?? value.name ?? value.label ?? value.value ?? value.id,
39
+ );
40
+ }
41
+ return String(value);
42
+ }
43
+
44
+ export function getStatusColor(status) {
45
+ return STATUS_COLORS[status] || FALLBACK_STATUS_COLOR;
46
+ }
47
+
1
48
  export function centerAndResetMapZoom({ map, ol }) {
2
49
  map.getView().animate({
3
50
  zoom: 4,
@@ -8,9 +55,17 @@ export function centerAndResetMapZoom({ map, ol }) {
8
55
 
9
56
  export function scrollToElement(elementId) {
10
57
  const element = document.getElementById(elementId);
11
- element.scrollIntoView({
12
- behavior: 'smooth',
13
- });
58
+ element?.scrollIntoView({ behavior: 'smooth' });
59
+ }
60
+
61
+ export function getSelectInteraction(map) {
62
+ const interactions = map?.getInteractions?.();
63
+ const interactionArray =
64
+ interactions?.getArray?.() || interactions?.array_ || [];
65
+
66
+ return interactionArray.find(
67
+ (interaction) => typeof interaction?.getFeatures === 'function',
68
+ );
14
69
  }
15
70
 
16
71
  export function getExtentOfFeatures({ features, ol }) {
@@ -31,24 +86,30 @@ export function getFeatures({ cases, ol }) {
31
86
  const Feature = ol.ol.Feature;
32
87
 
33
88
  return cases.map((c, index) => {
34
- const {
35
- geometry: { coordinates },
36
- } = c;
37
89
  const point = new Feature(
38
- new ol.geom.Point(ol.proj.fromLonLat(coordinates)),
90
+ new ol.geom.Point(ol.proj.fromLonLat(c.geometry.coordinates)),
39
91
  );
92
+ const properties = c.properties || {};
93
+ const status = properties.current_status;
40
94
  point.setId(index);
41
95
  point.setProperties(
42
96
  {
43
- title: c.properties.title,
44
- image: c.properties.image,
45
- nwrm_type: c.properties.nwrm_type,
46
- measures_implemented: c.properties.measures,
47
- typology_of_measures: c.properties.typology_of_measures,
48
- description: c.properties.description,
49
- index: index,
50
- path: c.properties.path,
51
- color: c.properties.nwrm_type === 'Light' ? '#50B0A4' : '#0083E0',
97
+ title: properties.title,
98
+ image: properties.image,
99
+ nwrm_type: properties.nwrm_type,
100
+ measures_implemented: properties.measures,
101
+ typology_of_measures: properties.typology_of_measures,
102
+ current_status: status,
103
+ habitat_ecosystem_type: getCaseProperty(
104
+ properties,
105
+ 'habitat_ecosystem_type',
106
+ ),
107
+ nrr_article: properties.nrr_article,
108
+ scale_of_planning: properties.scale_of_planning,
109
+ description: properties.description,
110
+ index,
111
+ path: properties.path,
112
+ color: getStatusColor(status),
52
113
  },
53
114
  false,
54
115
  );
@@ -56,89 +117,106 @@ export function getFeatures({ cases, ol }) {
56
117
  });
57
118
  }
58
119
 
59
- export function filterCases(cases, activeFilters, caseStudiesIds, searchInput) {
60
- const data = cases.filter((_case) => {
61
- let flag_searchInput = false;
62
- let flag_implemented = false;
63
- let flag_typology_of_measures = false;
64
- let flag_case = caseStudiesIds
65
- ? caseStudiesIds.includes(_case.properties.url.split('/').pop())
66
- : true;
67
-
68
- if (!searchInput) {
69
- flag_searchInput = true;
70
- } else {
71
- if (_case.properties.title.toLowerCase().match(searchInput)) {
72
- flag_searchInput = true;
73
- } else if (
74
- _case.properties.description.toLowerCase().match(searchInput)
75
- ) {
76
- flag_searchInput = true;
77
- }
78
- }
79
-
80
- if (!activeFilters.measures_implemented.length) {
81
- flag_implemented = true;
82
- } else {
83
- let measures_implemented = _case.properties.measures?.map((item) => {
84
- return item['id'].toString();
85
- });
120
+ // Escape a string so it can be embedded in a RegExp pattern as a literal.
121
+ // Prevents user-provided input from being interpreted as regex syntax
122
+ // (avoids ReDoS via crafted patterns and SyntaxError from malformed ones).
123
+ export function escapeRegExp(str) {
124
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
125
+ }
86
126
 
87
- activeFilters.measures_implemented.forEach((filter) => {
88
- if (measures_implemented?.includes(filter)) flag_implemented = true;
89
- });
90
- }
91
-
92
- if (!activeFilters.typology_of_measures.length) {
93
- flag_typology_of_measures = true;
94
- } else {
95
- let typology_of_measures = _case.properties.typology_of_measures?.map(
96
- (item) => {
97
- return item.toString();
98
- },
99
- );
100
-
101
- activeFilters.typology_of_measures.forEach((filter) => {
102
- if (typology_of_measures?.includes(filter))
103
- flag_typology_of_measures = true;
104
- });
105
- }
106
-
107
- return flag_case &&
108
- flag_implemented &&
109
- flag_typology_of_measures &&
110
- flag_searchInput
111
- ? _case
112
- : false;
113
- });
127
+ const WORD_CHAR = /[A-Za-z0-9_]/;
128
+
129
+ // Case-insensitive whole-word substring match implemented without regular
130
+ // expressions, so the needle is always treated as literal text. Equivalent
131
+ // to /\bneedle\b/i but runs in linear time (indexOf) and is immune to
132
+ // catastrophic backtracking.
133
+ function hasWholeWord(text, needle) {
134
+ let index = text.indexOf(needle);
135
+ while (index !== -1) {
136
+ const before = index === 0 ? '' : text[index - 1];
137
+ const after =
138
+ index + needle.length >= text.length ? '' : text[index + needle.length];
139
+ if (!WORD_CHAR.test(before) && !WORD_CHAR.test(after)) return true;
140
+ index = text.indexOf(needle, index + 1);
141
+ }
142
+ return false;
143
+ }
114
144
 
115
- return data;
145
+ const FILTER_FIELDS = [
146
+ 'measures_implemented',
147
+ 'typology_of_measures',
148
+ 'current_status',
149
+ 'habitat_ecosystem_type',
150
+ 'nrr_article',
151
+ 'scale_of_planning',
152
+ ];
153
+
154
+ export function filterCasesByPath(cases, filterPath) {
155
+ const path = filterPath?.trim();
156
+ if (!path) return cases;
157
+
158
+ return cases.filter((_case) =>
159
+ String(_case.properties?.path || '').includes(path),
160
+ );
116
161
  }
117
162
 
118
- export function getFilters(cases) {
119
- let _filters = {
120
- measures_implemented: {},
121
- typology_of_measures: {},
122
- };
123
-
124
- for (let key of Object.keys(cases)) {
125
- const _case = cases[key];
126
- let measures_implemented = _case.properties.measures;
127
- measures_implemented.map((item) => {
128
- if (!_filters.measures_implemented.hasOwnProperty(item['id'])) {
129
- _filters.measures_implemented[item['id']] = item['title'];
130
- }
131
- return [];
163
+ export function filterCases(
164
+ cases,
165
+ activeFilters,
166
+ caseStudiesIds,
167
+ searchInput,
168
+ filterPath,
169
+ ) {
170
+ return filterCasesByPath(cases, filterPath).filter((_case) => {
171
+ const properties = _case.properties || {};
172
+ const flagCase = caseStudiesIds
173
+ ? caseStudiesIds.includes(properties.url?.split('/').pop())
174
+ : true;
175
+ const searchable = `${properties.title || ''} ${
176
+ properties.description || ''
177
+ }`;
178
+ const flagSearch =
179
+ !searchInput ||
180
+ hasWholeWord(searchable.toLowerCase(), searchInput.toLowerCase());
181
+
182
+ const matchesFilters = FILTER_FIELDS.every((filterName) => {
183
+ const selected = activeFilters[filterName] || [];
184
+ if (!selected.length) return true;
185
+ const values =
186
+ filterName === 'measures_implemented'
187
+ ? asValues(properties.measures).map((item) => valueCode(item))
188
+ : filterName === 'typology_of_measures'
189
+ ? asValues(properties.typology_of_measures).map((item) =>
190
+ valueCode(item),
191
+ )
192
+ : asValues(getCaseProperty(properties, filterName)).map((item) =>
193
+ valueCode(item),
194
+ );
195
+ return selected.some((filter) => values.includes(String(filter)));
132
196
  });
133
197
 
134
- let typology_of_measures = _case.properties.typology_of_measures;
135
- typology_of_measures.map((item) => {
136
- if (!_filters.typology_of_measures.hasOwnProperty(item)) {
137
- _filters.typology_of_measures[item] = item;
138
- }
139
- return [];
198
+ return flagCase && flagSearch && matchesFilters;
199
+ });
200
+ }
201
+
202
+ export function getFilters(cases) {
203
+ const filters = Object.fromEntries(FILTER_FIELDS.map((field) => [field, {}]));
204
+
205
+ Object.values(cases || {}).forEach((_case) => {
206
+ const properties = _case.properties || {};
207
+ FILTER_FIELDS.forEach((field) => {
208
+ const source =
209
+ field === 'measures_implemented'
210
+ ? properties.measures
211
+ : field === 'typology_of_measures'
212
+ ? properties.typology_of_measures
213
+ : getCaseProperty(properties, field);
214
+ asValues(source).forEach((item) => {
215
+ const code = valueCode(item);
216
+ if (!filters[field][code]) filters[field][code] = valueLabel(item);
217
+ });
140
218
  });
141
- }
219
+ });
142
220
 
143
- return _filters;
221
+ return filters;
144
222
  }