@eeacms/volto-bise-policy 1.3.4 → 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.
@@ -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({
@@ -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';