@eeacms/volto-bise-policy 1.3.11 → 1.3.13

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 (39) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/package.json +2 -1
  3. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyCard.jsx +404 -0
  4. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyCard.test.jsx +366 -0
  5. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.jsx +192 -91
  6. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.test.jsx +205 -58
  7. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.jsx +329 -178
  8. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.test.jsx +243 -123
  9. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMapLazy.jsx +61 -0
  10. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMapLazy.test.jsx +89 -0
  11. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyResults.jsx +128 -0
  12. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyResults.test.jsx +160 -0
  13. package/src/components/manage/Blocks/CaseStudyExplorer/FacetPopover.jsx +180 -0
  14. package/src/components/manage/Blocks/CaseStudyExplorer/FacetPopover.test.jsx +125 -0
  15. package/src/components/manage/Blocks/CaseStudyExplorer/FilterPanel.jsx +171 -0
  16. package/src/components/manage/Blocks/CaseStudyExplorer/FilterPanel.test.jsx +181 -0
  17. package/src/components/manage/Blocks/CaseStudyExplorer/Pagination.jsx +81 -0
  18. package/src/components/manage/Blocks/CaseStudyExplorer/filters.js +350 -0
  19. package/src/components/manage/Blocks/CaseStudyExplorer/filters.test.js +399 -0
  20. package/src/components/manage/Blocks/CaseStudyExplorer/hooks.js +28 -4
  21. package/src/components/manage/Blocks/CaseStudyExplorer/hooks.test.js +73 -0
  22. package/src/components/manage/Blocks/CaseStudyExplorer/messages.js +138 -0
  23. package/src/components/manage/Blocks/CaseStudyExplorer/styles.less +1157 -317
  24. package/src/components/manage/Blocks/CaseStudyExplorer/testUtils.js +44 -46
  25. package/src/components/manage/Blocks/CaseStudyExplorer/utils.js +68 -184
  26. package/src/components/manage/Blocks/CaseStudyExplorer/utils.test.js +91 -158
  27. package/src/index.js +39 -0
  28. package/src/openlayers.js +51 -0
  29. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyFilters.jsx +0 -383
  30. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyFilters.test.jsx +0 -90
  31. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.jsx +0 -342
  32. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.test.jsx +0 -196
  33. package/src/components/manage/Blocks/CaseStudyExplorer/FeatureDisplay.jsx +0 -24
  34. package/src/components/manage/Blocks/CaseStudyExplorer/FeatureDisplay.test.jsx +0 -49
  35. package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.jsx +0 -100
  36. package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.test.jsx +0 -189
  37. package/src/components/manage/Blocks/CaseStudyExplorer/InfoOverlay.jsx +0 -111
  38. package/src/components/manage/Blocks/CaseStudyExplorer/InfoOverlay.test.jsx +0 -159
  39. package/src/components/manage/Blocks/CaseStudyExplorer/styles.less_old +0 -201
@@ -1,165 +1,285 @@
1
+ import './mockJsdom';
1
2
  import React from 'react';
2
- import { render, screen, fireEvent } from '@testing-library/react';
3
+ import { render, screen, fireEvent, act } from '@testing-library/react';
3
4
  import '@testing-library/jest-dom';
5
+ import { IntlProvider } from 'react-intl';
6
+
4
7
  import CaseStudyMap from './CaseStudyMap';
5
- import { getFeatures, centerAndResetMapZoom, scrollToElement } from './utils';
8
+ import { centerAndResetMapZoom, getFeatures } from './utils';
6
9
  import { createMockMap, createMockOl } from './testUtils';
7
10
 
8
11
  jest.mock('@eeacms/volto-openlayers-map', () => ({
9
- withOpenLayers: (Comp) => Comp,
12
+ withOpenLayers: (Component) => Component,
10
13
  }));
11
14
 
12
- jest.mock('@eeacms/volto-openlayers-map/api', () => ({
13
- Map: ({ children }) => <div data-testid="mock-map">{children}</div>,
14
- Layer: {
15
- Tile: ({ source }) => <div data-testid="mock-tile-layer" />,
16
- Vector: ({ source }) => <div data-testid="mock-vector-layer" />,
17
- },
18
- Layers: ({ children }) => <div data-testid="mock-layers">{children}</div>,
19
- Controls: () => <div data-testid="mock-controls" />,
20
- useMapContext: () => ({ map: mockMap }),
21
- }));
15
+ const mockMap = createMockMap();
16
+
17
+ jest.mock('@eeacms/volto-openlayers-map/api', () => {
18
+ let vectorStyle = null;
22
19
 
23
- // Mock child components so we don't render full DOM
24
- jest.mock('./InfoOverlay', () => () => <div data-testid="mock-info-overlay" />);
25
- jest.mock('./FeatureInteraction', () => () => (
26
- <div data-testid="mock-feature-interaction" />
27
- ));
28
- jest.mock('./CaseStudyListing', () => () => (
29
- <div data-testid="mock-case-study-list" />
30
- ));
20
+ return {
21
+ __getVectorStyle: () => vectorStyle,
22
+ Map: ({ children }) => <div data-testid="mock-map">{children}</div>,
23
+ Layer: {
24
+ Tile: () => <div data-testid="mock-tile-layer" />,
25
+ Vector: ({ style }) => {
26
+ vectorStyle = style;
27
+ return <div data-testid="mock-vector-layer" />;
28
+ },
29
+ },
30
+ Layers: ({ children }) => <div data-testid="mock-layers">{children}</div>,
31
+ Controls: () => <div data-testid="mock-controls" />,
32
+ useMapContext: () => ({ map: mockMap }),
33
+ };
34
+ });
31
35
 
32
36
  jest.mock('./utils', () => ({
33
- getFeatures: jest.fn(),
34
- getSelectInteraction: jest.fn(() => null),
37
+ ...jest.requireActual('./utils'),
35
38
  centerAndResetMapZoom: jest.fn(),
36
- scrollToElement: jest.fn(),
39
+ getFeatures: jest.fn(),
37
40
  }));
38
41
 
39
- const mockOl = createMockOl();
40
- const mockMap = createMockMap();
42
+ const mockOl = {
43
+ ...createMockOl(),
44
+ extent: {
45
+ createEmpty: () => [Infinity, Infinity, -Infinity, -Infinity],
46
+ extend: jest.fn(),
47
+ },
48
+ };
49
+
50
+ const items = [
51
+ {
52
+ geometry: { type: 'Point', coordinates: [14.4, 50.1] },
53
+ properties: {
54
+ title: 'Peatlands',
55
+ path: '/case-peatlands',
56
+ current_status: 'ongoing',
57
+ },
58
+ },
59
+ {
60
+ geometry: { type: 'Point', coordinates: [10.4, 51.1] },
61
+ properties: {
62
+ title: 'Rivers',
63
+ path: '/case-rivers',
64
+ current_status: 'completed',
65
+ },
66
+ },
67
+ ];
68
+
69
+ const renderMap = (props = {}) =>
70
+ render(
71
+ <IntlProvider locale="en">
72
+ <CaseStudyMap
73
+ items={items}
74
+ setMap={jest.fn()}
75
+ map={mockMap}
76
+ ol={mockOl}
77
+ onSelect={jest.fn()}
78
+ {...props}
79
+ />
80
+ </IntlProvider>,
81
+ );
82
+
83
+ const rerenderMap = (rerender, props = {}) =>
84
+ rerender(
85
+ <IntlProvider locale="en">
86
+ <CaseStudyMap
87
+ items={items}
88
+ setMap={jest.fn()}
89
+ map={mockMap}
90
+ ol={mockOl}
91
+ onSelect={jest.fn()}
92
+ {...props}
93
+ />
94
+ </IntlProvider>,
95
+ );
96
+
97
+ /* The points source the component creates with `new ol.source.Vector()`. */
98
+ const pointsSource = () => mockOl.source.Vector.mock.results[0].value;
99
+
100
+ const mapClickHandler = () => {
101
+ const calls = mockMap.on.mock.calls.filter(([event]) => event === 'click');
102
+ return calls[calls.length - 1][1];
103
+ };
104
+
105
+ const vectorStyle = () =>
106
+ require('@eeacms/volto-openlayers-map/api').__getVectorStyle();
107
+
108
+ const styleForPath = (path) =>
109
+ vectorStyle()({
110
+ get: (key) =>
111
+ key === 'features'
112
+ ? [{ get: (prop) => ({ path, color: '#FF9933' })[prop] }]
113
+ : null,
114
+ });
41
115
 
42
116
  describe('CaseStudyMap', () => {
43
117
  beforeEach(() => {
44
118
  jest.clearAllMocks();
119
+ getFeatures.mockReturnValue([{ id: 1 }]);
120
+ mockMap.getPixelFromCoordinate = jest.fn(() => [100, 200]);
45
121
  });
46
122
 
47
- it('renders nothing when no features', () => {
123
+ test('still renders the map without features', () => {
48
124
  getFeatures.mockReturnValueOnce([]);
49
- const { container } = render(
50
- <CaseStudyMap items={[]} ol={mockOl} setMap={jest.fn()} />,
51
- );
52
- expect(container.firstChild).toBeNull();
125
+ renderMap({ items: [] });
126
+
127
+ expect(screen.getByTestId('mock-map')).toBeInTheDocument();
128
+ expect(screen.getByTestId('mock-tile-layer')).toBeInTheDocument();
53
129
  });
54
130
 
55
- it('renders map and layers when features exist', () => {
56
- getFeatures.mockReturnValueOnce([{ id: 1 }]);
57
- render(
58
- <CaseStudyMap
59
- items={[{ id: 1 }]}
60
- ol={mockOl}
61
- setMap={jest.fn()}
62
- map={mockMap}
63
- onSelectedCase={jest.fn()}
64
- />,
65
- );
131
+ test('renders the map, layers and the status legend', () => {
132
+ renderMap();
133
+
66
134
  expect(screen.getByTestId('mock-map')).toBeInTheDocument();
67
135
  expect(screen.getByTestId('mock-tile-layer')).toBeInTheDocument();
68
136
  expect(screen.getByTestId('mock-vector-layer')).toBeInTheDocument();
69
- expect(screen.getByTestId('mock-case-study-list')).toBeInTheDocument();
137
+ expect(screen.getByText('Planned')).toBeInTheDocument();
138
+ expect(screen.getByText('Ongoing')).toBeInTheDocument();
139
+ expect(screen.getByText('Completed')).toBeInTheDocument();
70
140
  });
71
141
 
72
- it('does not render CaseStudyList when hideFilters is true', () => {
73
- getFeatures.mockReturnValueOnce([{ id: 1 }]);
74
- render(
75
- <CaseStudyMap
76
- items={[{ id: 1 }]}
77
- ol={mockOl}
78
- hideFilters
79
- setMap={jest.fn()}
80
- map={mockMap}
81
- onSelectedCase={jest.fn()}
82
- />,
83
- );
84
- expect(
85
- screen.queryByTestId('mock-case-study-list'),
86
- ).not.toBeInTheDocument();
142
+ test('selects a case study when its pin is clicked', () => {
143
+ const onSelect = jest.fn();
144
+ renderMap({ onSelect });
145
+
146
+ const member = {
147
+ get: (key) => (key === 'path' ? '/case-peatlands' : null),
148
+ };
149
+ const cluster = { get: (key) => (key === 'features' ? [member] : null) };
150
+ mockMap.forEachFeatureAtPixel = jest.fn(() => cluster);
151
+
152
+ mapClickHandler()({ pixel: [10, 20] });
153
+
154
+ expect(onSelect).toHaveBeenCalledWith('/case-peatlands');
87
155
  });
88
156
 
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 }];
157
+ test('zooms instead of guessing when a cluster is clicked', () => {
158
+ const onSelect = jest.fn();
159
+ renderMap({ onSelect });
92
160
 
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
- );
161
+ const member = {
162
+ getGeometry: () => ({ getExtent: () => [0, 0, 1, 1] }),
163
+ };
164
+ const cluster = {
165
+ get: (key) => (key === 'features' ? [member, member] : null),
166
+ };
167
+ mockMap.forEachFeatureAtPixel = jest.fn(() => cluster);
168
+
169
+ mapClickHandler()({ pixel: [10, 20] });
121
170
 
122
- expect(pointsSource.clear.mock.calls.length).toBe(clearCount);
171
+ expect(onSelect).toHaveBeenCalledWith(null);
172
+ expect(mockMap.getView().fit).toHaveBeenCalled();
123
173
  });
124
174
 
125
- it('clicking Reset map calls utils and clears features', () => {
126
- getFeatures.mockReturnValueOnce([{ id: 1 }]);
127
- const onSelectedCase = jest.fn();
128
- render(
129
- <CaseStudyMap
130
- items={[{ id: 1 }]}
131
- ol={mockOl}
132
- setMap={jest.fn()}
133
- map={mockMap}
134
- onSelectedCase={onSelectedCase}
135
- />,
136
- );
137
-
138
- fireEvent.click(screen.getByText('Reset map'));
139
- expect(scrollToElement).toHaveBeenCalledWith('search-input');
140
- expect(onSelectedCase).toHaveBeenCalledWith(null);
141
- expect(centerAndResetMapZoom).toHaveBeenCalledWith({
142
- map: mockMap,
143
- ol: mockOl,
175
+ test('clears the selection when empty map space is clicked', () => {
176
+ const onSelect = jest.fn();
177
+ renderMap({ onSelect });
178
+
179
+ mockMap.forEachFeatureAtPixel = jest.fn(() => null);
180
+ mapClickHandler()({ pixel: [10, 20] });
181
+
182
+ expect(onSelect).toHaveBeenCalledWith(null);
183
+ });
184
+
185
+ test('keeps the loader up until OpenLayers finishes rendering', () => {
186
+ renderMap();
187
+
188
+ expect(screen.getByRole('status')).toHaveTextContent('Loading map');
189
+
190
+ const renderComplete = mockMap.on.mock.calls
191
+ .filter(([event]) => event === 'rendercomplete')
192
+ .pop()[1];
193
+ act(() => renderComplete());
194
+
195
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
196
+ });
197
+
198
+ test('removes the render listener once the map is ready', () => {
199
+ renderMap();
200
+
201
+ const renderComplete = mockMap.on.mock.calls
202
+ .filter(([event]) => event === 'rendercomplete')
203
+ .pop()[1];
204
+ act(() => renderComplete());
205
+
206
+ expect(mockMap.un).toHaveBeenCalledWith('rendercomplete', renderComplete);
207
+ });
208
+
209
+ test('resets the map and clears the selection', () => {
210
+ const onSelect = jest.fn();
211
+ renderMap({ onSelect });
212
+
213
+ fireEvent.click(screen.getByRole('button', { name: /Reset map/ }));
214
+
215
+ expect(onSelect).toHaveBeenCalledWith(null);
216
+ expect(centerAndResetMapZoom).toHaveBeenCalled();
217
+ });
218
+
219
+ test('shows the title of the selected case study in a popup over its pin', () => {
220
+ const { container } = renderMap({ selectedPath: '/case-peatlands' });
221
+
222
+ expect(screen.getByText('Peatlands')).toBeInTheDocument();
223
+ expect(container.querySelector('.cse-map-popup-anchor')).toHaveStyle({
224
+ transform: 'translate(100px, 200px)',
225
+ visibility: 'visible',
144
226
  });
145
227
  });
146
228
 
147
- it('map moveend listener toggles resetMapButtonClass', () => {
148
- getFeatures.mockReturnValueOnce([{ id: 1 }]);
149
- render(
150
- <CaseStudyMap
151
- items={[{ id: 1 }]}
152
- ol={mockOl}
153
- setMap={jest.fn()}
154
- map={mockMap}
155
- onSelectedCase={jest.fn()}
156
- />,
157
- );
229
+ test('has no popup without a selection', () => {
230
+ const { container } = renderMap({ selectedPath: null });
231
+
232
+ expect(container.querySelector('.cse-map-popup-anchor')).toBeNull();
233
+ });
234
+
235
+ test('keeps the popup over the pin while the map moves', () => {
236
+ mockMap.getPixelFromCoordinate = jest
237
+ .fn()
238
+ .mockReturnValueOnce([10, 20])
239
+ .mockReturnValueOnce([30, 40]);
240
+
241
+ const { container } = renderMap({ selectedPath: '/case-peatlands' });
242
+ const anchor = container.querySelector('.cse-map-popup-anchor');
243
+ expect(anchor).toHaveStyle({ transform: 'translate(10px, 20px)' });
244
+
245
+ const place = mockMap.on.mock.calls
246
+ .filter(([event]) => event === 'postrender')
247
+ .pop()[1];
248
+ act(() => place());
249
+
250
+ expect(anchor).toHaveStyle({ transform: 'translate(30px, 40px)' });
251
+ });
252
+
253
+ test('paints the selected pin dark green and leaves the others as they are', () => {
254
+ renderMap({ selectedPath: '/case-peatlands' });
255
+
256
+ styleForPath('/case-peatlands');
257
+ expect(mockOl.style.Fill).toHaveBeenCalledWith({ color: '#005248' });
258
+
259
+ mockOl.style.Fill.mockClear();
260
+ styleForPath('/case-rivers');
261
+ expect(mockOl.style.Fill).not.toHaveBeenCalledWith({ color: '#005248' });
262
+ });
263
+
264
+ test('repaints the pins when the selection changes', () => {
265
+ const { rerender } = renderMap({ selectedPath: null });
266
+ const source = pointsSource();
267
+ source.changed.mockClear();
268
+
269
+ rerenderMap(rerender, { selectedPath: '/case-peatlands' });
270
+
271
+ expect(source.changed).toHaveBeenCalled();
272
+ });
158
273
 
159
- const moveendCb = mockMap.on.mock.calls.find((c) => c[0] === 'moveend')[1];
274
+ test('paints the newly selected pin dark green after the selection moves', () => {
275
+ const { rerender } = renderMap({ selectedPath: '/case-peatlands' });
276
+ rerenderMap(rerender, { selectedPath: '/case-rivers' });
160
277
 
161
- moveendCb();
278
+ mockOl.style.Fill.mockClear();
279
+ styleForPath('/case-peatlands');
280
+ expect(mockOl.style.Fill).not.toHaveBeenCalledWith({ color: '#005248' });
162
281
 
163
- expect(mockMap.getView).toHaveBeenCalled();
282
+ styleForPath('/case-rivers');
283
+ expect(mockOl.style.Fill).toHaveBeenCalledWith({ color: '#005248' });
164
284
  });
165
285
  });
@@ -0,0 +1,61 @@
1
+ import React from 'react';
2
+
3
+ import CaseStudyMap from './CaseStudyMap';
4
+
5
+ const ROOT_MARGIN = '600px';
6
+
7
+ /**
8
+ * True once the element is within `ROOT_MARGIN` of the viewport. Stays true
9
+ * afterwards so scrolling away does not tear the map down.
10
+ *
11
+ * `enabled` delays observing until the element is actually rendered (the
12
+ * explorer renders nothing while its data is still loading).
13
+ */
14
+ export const useNearViewport = (elementRef, enabled = true) => {
15
+ const [near, setNear] = React.useState(false);
16
+
17
+ React.useEffect(() => {
18
+ if (!enabled || near) return undefined;
19
+
20
+ const element = elementRef.current;
21
+ if (!element) return undefined;
22
+
23
+ if (typeof IntersectionObserver === 'undefined') {
24
+ setNear(true);
25
+ return undefined;
26
+ }
27
+
28
+ const observer = new IntersectionObserver(
29
+ (entries) => {
30
+ if (entries.some((entry) => entry.isIntersecting)) {
31
+ setNear(true);
32
+ observer.disconnect();
33
+ }
34
+ },
35
+ { rootMargin: ROOT_MARGIN },
36
+ );
37
+
38
+ observer.observe(element);
39
+
40
+ return () => observer.disconnect();
41
+ }, [elementRef, enabled, near]);
42
+
43
+ return near;
44
+ };
45
+
46
+ /**
47
+ * OpenLayers is roughly 1MB of JavaScript and the explorer usually sits far
48
+ * below the fold, so `<CaseStudyMap>` (and with it the OpenLayers chunk) is
49
+ * only mounted once the visitor is about to reach it. The slot keeps the
50
+ * map's height so deferring does not move the rest of the page.
51
+ */
52
+ export default function CaseStudyMapLazy(props) {
53
+ const slotRef = React.useRef(null);
54
+ const near = useNearViewport(slotRef);
55
+
56
+ return (
57
+ <div className="cse-map-slot" ref={slotRef}>
58
+ {near ? <CaseStudyMap {...props} /> : null}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,89 @@
1
+ import './mockJsdom';
2
+ import React from 'react';
3
+ import { render, screen, act } from '@testing-library/react';
4
+ import '@testing-library/jest-dom';
5
+
6
+ import CaseStudyMapLazy from './CaseStudyMapLazy';
7
+
8
+ jest.mock('./CaseStudyMap', () => () => <div data-testid="case-study-map" />);
9
+
10
+ const items = [
11
+ {
12
+ geometry: { type: 'Point', coordinates: [14.4, 50.1] },
13
+ properties: { title: 'Peatlands', path: '/case-peatlands' },
14
+ },
15
+ ];
16
+
17
+ const installIntersectionObserver = () => {
18
+ const observers = [];
19
+ class MockIntersectionObserver {
20
+ constructor(callback) {
21
+ this.callback = callback;
22
+ this.observe = jest.fn();
23
+ this.disconnect = jest.fn();
24
+ observers.push(this);
25
+ }
26
+ }
27
+ global.IntersectionObserver = MockIntersectionObserver;
28
+ return observers;
29
+ };
30
+
31
+ describe('CaseStudyMapLazy', () => {
32
+ const originalObserver = global.IntersectionObserver;
33
+
34
+ afterEach(() => {
35
+ global.IntersectionObserver = originalObserver;
36
+ jest.clearAllMocks();
37
+ });
38
+
39
+ test('renders the map slot without case studies', () => {
40
+ const observers = installIntersectionObserver();
41
+ const { container } = render(<CaseStudyMapLazy items={[]} />);
42
+
43
+ expect(container.querySelector('.cse-map-slot')).toBeInTheDocument();
44
+ expect(observers[0].observe).toHaveBeenCalledWith(
45
+ container.querySelector('.cse-map-slot'),
46
+ );
47
+ });
48
+
49
+ test('mounts the map without case studies once it is near the viewport', () => {
50
+ const observers = installIntersectionObserver();
51
+ render(<CaseStudyMapLazy items={[]} />);
52
+
53
+ act(() => {
54
+ observers[0].callback([{ isIntersecting: true }]);
55
+ });
56
+
57
+ expect(screen.getByTestId('case-study-map')).toBeInTheDocument();
58
+ });
59
+
60
+ test('defers the map until it is near the viewport', () => {
61
+ const observers = installIntersectionObserver();
62
+ const { container } = render(<CaseStudyMapLazy items={items} />);
63
+
64
+ expect(container.querySelector('.cse-map-slot')).toBeInTheDocument();
65
+ expect(screen.queryByTestId('case-study-map')).not.toBeInTheDocument();
66
+ expect(observers[0].observe).toHaveBeenCalledWith(
67
+ container.querySelector('.cse-map-slot'),
68
+ );
69
+ });
70
+
71
+ test('mounts the map once the slot scrolls into view', () => {
72
+ const observers = installIntersectionObserver();
73
+ render(<CaseStudyMapLazy items={items} />);
74
+
75
+ act(() => {
76
+ observers[0].callback([{ isIntersecting: true }]);
77
+ });
78
+
79
+ expect(screen.getByTestId('case-study-map')).toBeInTheDocument();
80
+ expect(observers[0].disconnect).toHaveBeenCalled();
81
+ });
82
+
83
+ test('mounts the map immediately without IntersectionObserver support', () => {
84
+ delete global.IntersectionObserver;
85
+ render(<CaseStudyMapLazy items={items} />);
86
+
87
+ expect(screen.getByTestId('case-study-map')).toBeInTheDocument();
88
+ });
89
+ });
@@ -0,0 +1,128 @@
1
+ import React from 'react';
2
+ import { useIntl } from 'react-intl';
3
+
4
+ import CaseStudyCard from './CaseStudyCard';
5
+ import Pagination from './Pagination';
6
+ import messages from './messages';
7
+
8
+ export const SORT_OPTIONS = [
9
+ { value: 'title', label: messages.sortTitle },
10
+ { value: 'newest', label: messages.sortNewest },
11
+ ];
12
+
13
+ export const PER_PAGE_OPTIONS = [4, 8, 16];
14
+
15
+ /**
16
+ * Results pane: count line, sort and page-size controls, the cards and the
17
+ * pagination. Slicing happens here, filtering and ordering do not.
18
+ */
19
+ export default function CaseStudyResults({
20
+ items = [],
21
+ selectedPath,
22
+ onShowOnMap,
23
+ onResetMap,
24
+ sortBy = 'title',
25
+ onSortChange,
26
+ perPage = 4,
27
+ onPerPageChange,
28
+ page = 1,
29
+ onPageChange,
30
+ onResetFilters,
31
+ }) {
32
+ const intl = useIntl();
33
+ const pageCount = Math.max(1, Math.ceil(items.length / perPage));
34
+ const currentPage = Math.min(page, pageCount);
35
+ const visible = items.slice(
36
+ perPage * (currentPage - 1),
37
+ perPage * currentPage,
38
+ );
39
+
40
+ return (
41
+ <div className="cse-results">
42
+ <h2 className="cse-visually-hidden">
43
+ {intl.formatMessage(messages.resultsHeading)}
44
+ </h2>
45
+
46
+ <div className="cse-results-header">
47
+ <p className="cse-results-count" role="status">
48
+ {items.length === 1
49
+ ? intl.formatMessage(messages.resultsShowingOne)
50
+ : intl.formatMessage(messages.resultsShowing, {
51
+ count: items.length,
52
+ })}
53
+ </p>
54
+
55
+ <div className="cse-results-controls">
56
+ <label className="cse-results-control">
57
+ <span className="cse-results-control-label">
58
+ {intl.formatMessage(messages.sortBy)}
59
+ </span>
60
+ <select
61
+ value={sortBy}
62
+ onChange={(event) => onSortChange(event.target.value)}
63
+ >
64
+ {SORT_OPTIONS.map((option) => (
65
+ <option value={option.value} key={option.value}>
66
+ {intl.formatMessage(option.label)}
67
+ </option>
68
+ ))}
69
+ </select>
70
+ </label>
71
+
72
+ <label className="cse-results-control">
73
+ <span className="cse-results-control-label">
74
+ {intl.formatMessage(messages.perPage)}
75
+ </span>
76
+ <select
77
+ value={perPage}
78
+ onChange={(event) => onPerPageChange(Number(event.target.value))}
79
+ >
80
+ {PER_PAGE_OPTIONS.map((option) => (
81
+ <option value={option} key={option}>
82
+ {option}
83
+ </option>
84
+ ))}
85
+ </select>
86
+ </label>
87
+ </div>
88
+ </div>
89
+
90
+ {visible.length === 0 ? (
91
+ <div className="cse-results-empty">
92
+ <h3>{intl.formatMessage(messages.noResultsTitle)}</h3>
93
+ <p>{intl.formatMessage(messages.noResultsHint)}</p>
94
+ {onResetFilters ? (
95
+ <button
96
+ type="button"
97
+ className="cse-reset-filters"
98
+ onClick={onResetFilters}
99
+ >
100
+ {intl.formatMessage(messages.resetFilters)}
101
+ </button>
102
+ ) : null}
103
+ </div>
104
+ ) : (
105
+ <ul className="cse-results-list">
106
+ {visible.map((item) => (
107
+ <li key={item.properties?.path || item.properties?.url}>
108
+ <CaseStudyCard
109
+ item={item}
110
+ selected={selectedPath === item.properties?.path}
111
+ onShowOnMap={onShowOnMap}
112
+ onResetMap={onResetMap}
113
+ />
114
+ </li>
115
+ ))}
116
+ </ul>
117
+ )}
118
+
119
+ {visible.length > 0 ? (
120
+ <Pagination
121
+ page={currentPage}
122
+ pageCount={pageCount}
123
+ onPageChange={onPageChange}
124
+ />
125
+ ) : null}
126
+ </div>
127
+ );
128
+ }