@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.
- package/CHANGELOG.md +37 -1
- package/package.json +2 -1
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyCard.jsx +404 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyCard.test.jsx +366 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.jsx +192 -91
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.test.jsx +205 -58
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.jsx +329 -178
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.test.jsx +243 -123
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMapLazy.jsx +61 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMapLazy.test.jsx +89 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyResults.jsx +128 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyResults.test.jsx +160 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FacetPopover.jsx +180 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FacetPopover.test.jsx +125 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FilterPanel.jsx +171 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FilterPanel.test.jsx +181 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/Pagination.jsx +81 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/filters.js +350 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/filters.test.js +399 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/hooks.js +28 -4
- package/src/components/manage/Blocks/CaseStudyExplorer/hooks.test.js +73 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/messages.js +138 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/styles.less +1157 -317
- package/src/components/manage/Blocks/CaseStudyExplorer/testUtils.js +44 -46
- package/src/components/manage/Blocks/CaseStudyExplorer/utils.js +68 -184
- package/src/components/manage/Blocks/CaseStudyExplorer/utils.test.js +91 -158
- package/src/index.js +39 -0
- package/src/openlayers.js +51 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyFilters.jsx +0 -383
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyFilters.test.jsx +0 -90
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.jsx +0 -342
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.test.jsx +0 -196
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureDisplay.jsx +0 -24
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureDisplay.test.jsx +0 -49
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.jsx +0 -100
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.test.jsx +0 -189
- package/src/components/manage/Blocks/CaseStudyExplorer/InfoOverlay.jsx +0 -111
- package/src/components/manage/Blocks/CaseStudyExplorer/InfoOverlay.test.jsx +0 -159
- 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 {
|
|
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: (
|
|
12
|
+
withOpenLayers: (Component) => Component,
|
|
10
13
|
}));
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
34
|
-
getSelectInteraction: jest.fn(() => null),
|
|
37
|
+
...jest.requireActual('./utils'),
|
|
35
38
|
centerAndResetMapZoom: jest.fn(),
|
|
36
|
-
|
|
39
|
+
getFeatures: jest.fn(),
|
|
37
40
|
}));
|
|
38
41
|
|
|
39
|
-
const mockOl =
|
|
40
|
-
|
|
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
|
-
|
|
123
|
+
test('still renders the map without features', () => {
|
|
48
124
|
getFeatures.mockReturnValueOnce([]);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
);
|
|
52
|
-
expect(
|
|
125
|
+
renderMap({ items: [] });
|
|
126
|
+
|
|
127
|
+
expect(screen.getByTestId('mock-map')).toBeInTheDocument();
|
|
128
|
+
expect(screen.getByTestId('mock-tile-layer')).toBeInTheDocument();
|
|
53
129
|
});
|
|
54
130
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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.
|
|
137
|
+
expect(screen.getByText('Planned')).toBeInTheDocument();
|
|
138
|
+
expect(screen.getByText('Ongoing')).toBeInTheDocument();
|
|
139
|
+
expect(screen.getByText('Completed')).toBeInTheDocument();
|
|
70
140
|
});
|
|
71
141
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
157
|
+
test('zooms instead of guessing when a cluster is clicked', () => {
|
|
158
|
+
const onSelect = jest.fn();
|
|
159
|
+
renderMap({ onSelect });
|
|
92
160
|
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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(
|
|
171
|
+
expect(onSelect).toHaveBeenCalledWith(null);
|
|
172
|
+
expect(mockMap.getView().fit).toHaveBeenCalled();
|
|
123
173
|
});
|
|
124
174
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
+
mockOl.style.Fill.mockClear();
|
|
279
|
+
styleForPath('/case-peatlands');
|
|
280
|
+
expect(mockOl.style.Fill).not.toHaveBeenCalledWith({ color: '#005248' });
|
|
162
281
|
|
|
163
|
-
|
|
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
|
+
}
|