@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
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import './mockJsdom';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import '@testing-library/jest-dom';
|
|
4
|
+
import { render, screen, within, fireEvent } from '@testing-library/react';
|
|
5
|
+
import { IntlProvider } from 'react-intl';
|
|
6
|
+
|
|
7
|
+
import CaseStudyCard from './CaseStudyCard';
|
|
8
|
+
|
|
9
|
+
const item = {
|
|
10
|
+
geometry: { type: 'Point', coordinates: [14.4, 50.1] },
|
|
11
|
+
properties: {
|
|
12
|
+
title: 'Restored peatlands in the Bohemian Forest',
|
|
13
|
+
path: '/catalogue/peatlands-bohemian-forest',
|
|
14
|
+
image: 'https://example.com/image.jpg',
|
|
15
|
+
current_status: 'ongoing',
|
|
16
|
+
nrr_legislation: [
|
|
17
|
+
'Article 4 - Restoration of terrestrial ecosystems',
|
|
18
|
+
'Article 11 - Restoration of agricultural ecosystems',
|
|
19
|
+
'Habitats Directive (HD)',
|
|
20
|
+
],
|
|
21
|
+
habitat_ecosystem_type: [
|
|
22
|
+
'Inland wetlands',
|
|
23
|
+
'Forest and woodland',
|
|
24
|
+
'Grassland (pastures, semi-natural and natural grasslands)',
|
|
25
|
+
],
|
|
26
|
+
measures: [
|
|
27
|
+
{
|
|
28
|
+
id: 'measure-a',
|
|
29
|
+
title: 'Restore degraded peatlands',
|
|
30
|
+
path: '/catalogue/measures/restore-degraded-peatlands',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'measure-b',
|
|
34
|
+
title: 'Rewetting of drained soils',
|
|
35
|
+
path: '/catalogue/measures/re-wetting',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'measure-c',
|
|
39
|
+
title: 'Restore rivers and floodplains',
|
|
40
|
+
path: '/catalogue/measures/restore-rivers',
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const renderCard = (props = {}) =>
|
|
47
|
+
render(
|
|
48
|
+
<IntlProvider locale="en">
|
|
49
|
+
<CaseStudyCard item={item} {...props} />
|
|
50
|
+
</IntlProvider>,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* jsdom does not lay anything out: fake a 300px wide list where 90px chips
|
|
55
|
+
* wrap every three per row and the "+N" trigger is 40px wide, so the two-row
|
|
56
|
+
* fit has something real to measure.
|
|
57
|
+
*/
|
|
58
|
+
function mockChipLayout({
|
|
59
|
+
width = 300,
|
|
60
|
+
chipWidth = 90,
|
|
61
|
+
moreWidth = 40,
|
|
62
|
+
perRow = 3,
|
|
63
|
+
gap = 8,
|
|
64
|
+
rowHeight = 24,
|
|
65
|
+
} = {}) {
|
|
66
|
+
const indexOf = (element) =>
|
|
67
|
+
Array.prototype.indexOf.call(element.parentNode.children, element);
|
|
68
|
+
const descriptors = {
|
|
69
|
+
clientWidth: { get: () => width },
|
|
70
|
+
offsetWidth: {
|
|
71
|
+
get() {
|
|
72
|
+
if (!this.classList?.contains('cse-card-chip')) return 0;
|
|
73
|
+
return this.textContent.startsWith('+') ? moreWidth : chipWidth;
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
offsetTop: {
|
|
77
|
+
get() {
|
|
78
|
+
return Math.floor(indexOf(this) / perRow) * rowHeight;
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
offsetLeft: {
|
|
82
|
+
get() {
|
|
83
|
+
return (indexOf(this) % perRow) * (chipWidth + gap);
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const original = {};
|
|
88
|
+
|
|
89
|
+
Object.entries(descriptors).forEach(([property, descriptor]) => {
|
|
90
|
+
original[property] = Object.getOwnPropertyDescriptor(
|
|
91
|
+
HTMLElement.prototype,
|
|
92
|
+
property,
|
|
93
|
+
);
|
|
94
|
+
Object.defineProperty(HTMLElement.prototype, property, {
|
|
95
|
+
configurable: true,
|
|
96
|
+
...descriptor,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
Object.entries(original).forEach(([property, descriptor]) => {
|
|
102
|
+
if (descriptor) {
|
|
103
|
+
Object.defineProperty(HTMLElement.prototype, property, descriptor);
|
|
104
|
+
} else {
|
|
105
|
+
delete HTMLElement.prototype[property];
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('CaseStudyCard', () => {
|
|
112
|
+
test('renders the title as a link to the case study, opening a new tab', () => {
|
|
113
|
+
renderCard();
|
|
114
|
+
|
|
115
|
+
const link = screen.getByRole('link', {
|
|
116
|
+
name: /Restored peatlands in the Bohemian Forest/,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(link).toHaveAttribute('href', item.properties.path);
|
|
120
|
+
expect(link).toHaveAttribute('target', '_blank');
|
|
121
|
+
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('renders the image with an empty alt (the title carries the meaning)', () => {
|
|
125
|
+
const { container } = renderCard();
|
|
126
|
+
|
|
127
|
+
const image = container.querySelector('.cse-card-media img');
|
|
128
|
+
|
|
129
|
+
expect(image).toHaveAttribute('src', item.properties.image);
|
|
130
|
+
expect(image).toHaveAttribute('alt', '');
|
|
131
|
+
// An empty alt makes the image decorative, so it is not exposed as an image
|
|
132
|
+
// in the accessibility tree: the title carries the meaning instead.
|
|
133
|
+
expect(screen.queryByRole('img')).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('renders the status as a colored dot next to the title', () => {
|
|
137
|
+
renderCard();
|
|
138
|
+
|
|
139
|
+
const dot = document.querySelector('.cse-card-status-dot');
|
|
140
|
+
|
|
141
|
+
expect(dot).toHaveStyle({ backgroundColor: '#ff9933' });
|
|
142
|
+
expect(dot.closest('.cse-card-title')).not.toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('does not render the measure count in the meta row', () => {
|
|
146
|
+
renderCard();
|
|
147
|
+
|
|
148
|
+
expect(
|
|
149
|
+
screen.getByRole('button', { name: 'Show on map' }),
|
|
150
|
+
).toBeInTheDocument();
|
|
151
|
+
expect(
|
|
152
|
+
within(document.querySelector('.cse-card-meta')).queryByText(
|
|
153
|
+
/NRR measures?$/,
|
|
154
|
+
),
|
|
155
|
+
).toBeNull();
|
|
156
|
+
expect(screen.getAllByText('3', { exact: true })).toHaveLength(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('shows every ecosystem when the card cannot be measured', () => {
|
|
160
|
+
renderCard();
|
|
161
|
+
|
|
162
|
+
const list = document.querySelector(
|
|
163
|
+
'.cse-card-ecosystems:not(.cse-card-ecosystems-measure)',
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
item.properties.habitat_ecosystem_type.forEach((ecosystem) => {
|
|
167
|
+
expect(within(list).getByText(ecosystem)).toBeInTheDocument();
|
|
168
|
+
});
|
|
169
|
+
expect(within(list).queryByText(/^\+\d+$/)).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('collapses the ecosystems that overflow two rows into a +N popup', () => {
|
|
173
|
+
const restore = mockChipLayout();
|
|
174
|
+
const ecosystems = [
|
|
175
|
+
'Coastal beaches, dunes and wetlands',
|
|
176
|
+
'Cropland',
|
|
177
|
+
'Forest and woodland',
|
|
178
|
+
'Grassland',
|
|
179
|
+
'Heathland and shrub',
|
|
180
|
+
'Inland wetlands',
|
|
181
|
+
'Rivers and lakes',
|
|
182
|
+
'Sparsely vegetated habitats',
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
render(
|
|
187
|
+
<IntlProvider locale="en">
|
|
188
|
+
<CaseStudyCard
|
|
189
|
+
item={{
|
|
190
|
+
...item,
|
|
191
|
+
properties: {
|
|
192
|
+
...item.properties,
|
|
193
|
+
habitat_ecosystem_type: ecosystems,
|
|
194
|
+
},
|
|
195
|
+
}}
|
|
196
|
+
/>
|
|
197
|
+
</IntlProvider>,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const list = document.querySelector(
|
|
201
|
+
'.cse-card-ecosystems:not(.cse-card-ecosystems-measure)',
|
|
202
|
+
);
|
|
203
|
+
const visible = [...list.querySelectorAll('.cse-card-chip')].map(
|
|
204
|
+
(chip) => chip.textContent,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// Five chips plus the trigger still fit in two rows of three.
|
|
208
|
+
expect(visible.slice(0, 5)).toEqual(ecosystems.slice(0, 5));
|
|
209
|
+
expect(visible[5]).toMatch(/^\+3/);
|
|
210
|
+
expect(list.querySelectorAll('.cse-card-chip')).toHaveLength(6);
|
|
211
|
+
|
|
212
|
+
const trigger = screen.getByRole('button', { name: /^\+3/ });
|
|
213
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
214
|
+
|
|
215
|
+
fireEvent.click(trigger);
|
|
216
|
+
|
|
217
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
218
|
+
const dialog = screen.getByRole('dialog', {
|
|
219
|
+
name: 'More habitat/ecosystem values',
|
|
220
|
+
});
|
|
221
|
+
// Only the hidden values: the visible chips already speak for themselves.
|
|
222
|
+
ecosystems.slice(0, 5).forEach((ecosystem) => {
|
|
223
|
+
expect(within(dialog).queryByText(ecosystem)).toBeNull();
|
|
224
|
+
});
|
|
225
|
+
ecosystems.slice(5).forEach((ecosystem) => {
|
|
226
|
+
expect(within(dialog).getByText(ecosystem)).toBeInTheDocument();
|
|
227
|
+
});
|
|
228
|
+
expect(dialog.querySelectorAll('.cse-card-chip')).toHaveLength(3);
|
|
229
|
+
|
|
230
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
231
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
232
|
+
} finally {
|
|
233
|
+
restore();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('collapses the related measures behind a bar owning the count', () => {
|
|
238
|
+
renderCard();
|
|
239
|
+
|
|
240
|
+
const toggle = screen.getByRole('button', {
|
|
241
|
+
name: /Related NRR measure\(s\)/,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(toggle).toHaveTextContent('Related NRR measure(s)');
|
|
245
|
+
expect(toggle).toHaveTextContent('3');
|
|
246
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('keeps the collapsed measures panel inert and out of the tab order', () => {
|
|
250
|
+
renderCard();
|
|
251
|
+
|
|
252
|
+
const toggle = screen.getByRole('button', {
|
|
253
|
+
name: /Related NRR measure\(s\)/,
|
|
254
|
+
});
|
|
255
|
+
const panel = document.getElementById(toggle.getAttribute('aria-controls'));
|
|
256
|
+
|
|
257
|
+
expect(panel).toHaveAttribute('inert');
|
|
258
|
+
expect(panel).toHaveAttribute('data-open', 'false');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('expands to every related measure, with no truncation', () => {
|
|
262
|
+
renderCard();
|
|
263
|
+
|
|
264
|
+
const toggle = screen.getByRole('button', {
|
|
265
|
+
name: /Related NRR measure\(s\)/,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
fireEvent.click(toggle);
|
|
269
|
+
|
|
270
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
|
271
|
+
expect(
|
|
272
|
+
document.getElementById(toggle.getAttribute('aria-controls')),
|
|
273
|
+
).not.toHaveAttribute('inert');
|
|
274
|
+
|
|
275
|
+
item.properties.measures.forEach((measure) => {
|
|
276
|
+
expect(
|
|
277
|
+
screen.getByRole('link', { name: new RegExp(measure.title) }),
|
|
278
|
+
).toHaveAttribute('href', measure.path);
|
|
279
|
+
});
|
|
280
|
+
expect(screen.queryByText(/\+\d+ more/)).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('collapses again on a second click', () => {
|
|
284
|
+
renderCard();
|
|
285
|
+
|
|
286
|
+
const toggle = screen.getByRole('button', {
|
|
287
|
+
name: /Related NRR measure\(s\)/,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
fireEvent.click(toggle);
|
|
291
|
+
fireEvent.click(toggle);
|
|
292
|
+
|
|
293
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
|
294
|
+
expect(
|
|
295
|
+
document.getElementById(toggle.getAttribute('aria-controls')),
|
|
296
|
+
).toHaveAttribute('inert');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('renders the open-in-new-tab affordance next to the title', () => {
|
|
300
|
+
renderCard();
|
|
301
|
+
|
|
302
|
+
const open = screen.getByRole('link', { name: 'Open the case study' });
|
|
303
|
+
|
|
304
|
+
expect(open).toHaveAttribute('href', item.properties.path);
|
|
305
|
+
expect(open.closest('.cse-card-head')).not.toBeNull();
|
|
306
|
+
expect(
|
|
307
|
+
open.closest('.cse-card-head').querySelector('.cse-card-title'),
|
|
308
|
+
).not.toBeNull();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('marks the card as selected', () => {
|
|
312
|
+
renderCard({ selected: true });
|
|
313
|
+
|
|
314
|
+
expect(screen.getByRole('article')).toHaveAttribute(
|
|
315
|
+
'data-selected',
|
|
316
|
+
'true',
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('asks the map to show the case study', () => {
|
|
321
|
+
const onShowOnMap = jest.fn();
|
|
322
|
+
renderCard({ onShowOnMap });
|
|
323
|
+
|
|
324
|
+
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
|
|
325
|
+
|
|
326
|
+
expect(onShowOnMap).toHaveBeenCalledWith(item);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('offers to reset the map once the card is the selected one', () => {
|
|
330
|
+
const onShowOnMap = jest.fn();
|
|
331
|
+
const onResetMap = jest.fn();
|
|
332
|
+
renderCard({ selected: true, onShowOnMap, onResetMap });
|
|
333
|
+
|
|
334
|
+
expect(screen.queryByRole('button', { name: 'Show on map' })).toBeNull();
|
|
335
|
+
|
|
336
|
+
fireEvent.click(screen.getByRole('button', { name: 'Reset map' }));
|
|
337
|
+
|
|
338
|
+
expect(onResetMap).toHaveBeenCalledWith(item);
|
|
339
|
+
expect(onShowOnMap).not.toHaveBeenCalled();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('survives a case study with no measures, ecosystems or legislation', () => {
|
|
343
|
+
const bare = {
|
|
344
|
+
geometry: { type: 'Point', coordinates: [0, 0] },
|
|
345
|
+
properties: { title: 'Bare case', path: '/bare' },
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
render(
|
|
349
|
+
<IntlProvider locale="en">
|
|
350
|
+
<CaseStudyCard item={bare} />
|
|
351
|
+
</IntlProvider>,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
expect(screen.getByText('Bare case')).toBeInTheDocument();
|
|
355
|
+
expect(
|
|
356
|
+
within(document.querySelector('.cse-card-meta')).queryByText(
|
|
357
|
+
/NRR measures?$/,
|
|
358
|
+
),
|
|
359
|
+
).toBeNull();
|
|
360
|
+
expect(
|
|
361
|
+
within(screen.getByRole('article')).queryByRole('button', {
|
|
362
|
+
name: /Related NRR measure\(s\)/,
|
|
363
|
+
}),
|
|
364
|
+
).toBeNull();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -1,113 +1,214 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { useIntl } from 'react-intl';
|
|
3
3
|
import { addAppURL } from '@plone/volto/helpers/Url/Url';
|
|
4
|
-
// import config from '@plone/volto/registry';
|
|
5
|
-
import CaseStudyMap from './CaseStudyMap';
|
|
6
|
-
import { ActiveFilters, CaseStudyFilters, SearchBox } from './CaseStudyFilters';
|
|
7
|
-
// import CaseStudyList from './CaseStudyListing';
|
|
8
4
|
|
|
9
|
-
import
|
|
5
|
+
import CaseStudyMap from './CaseStudyMapLazy';
|
|
6
|
+
import CaseStudyResults from './CaseStudyResults';
|
|
7
|
+
import FilterPanel from './FilterPanel';
|
|
8
|
+
import {
|
|
9
|
+
EMPTY_FILTERS,
|
|
10
|
+
buildFacets,
|
|
11
|
+
clearFilters,
|
|
12
|
+
filterCases,
|
|
13
|
+
sortCases,
|
|
14
|
+
} from './filters';
|
|
10
15
|
import { useCases } from './hooks';
|
|
16
|
+
// import { prefersReducedMotion } from './utils';
|
|
17
|
+
import messages from './messages';
|
|
11
18
|
|
|
12
19
|
import './styles.less';
|
|
13
20
|
|
|
21
|
+
const measureIdFromLocation = () => {
|
|
22
|
+
if (typeof window === 'undefined') return null;
|
|
23
|
+
|
|
24
|
+
return new URLSearchParams(window.location.search).get('measure');
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const withoutSelection = (filters, facet, value) => {
|
|
28
|
+
const selected = filters[facet] || [];
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...filters,
|
|
32
|
+
[facet]: selected.includes(value)
|
|
33
|
+
? selected.filter((item) => item !== value)
|
|
34
|
+
: [...selected, value],
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
14
38
|
export default function CaseStudyExplorerView(props) {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const {
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
const [activeItems, setActiveItems] = React.useState(cases);
|
|
34
|
-
const [filters, setFilters] = React.useState([]);
|
|
39
|
+
const intl = useIntl();
|
|
40
|
+
// const cases_url = 'http://localhost:3000/@@case-studies-map.arcgis.json';
|
|
41
|
+
const cases_url = addAppURL('/@@case-studies-map.arcgis.json');
|
|
42
|
+
const { cases, loading, error, reload } = useCases(cases_url);
|
|
43
|
+
|
|
44
|
+
const { filter_path } = props.data || {};
|
|
45
|
+
const filterPath = filter_path;
|
|
46
|
+
const [measureId] = React.useState(measureIdFromLocation);
|
|
47
|
+
|
|
48
|
+
const [filters, setFilters] = React.useState(EMPTY_FILTERS);
|
|
49
|
+
const [selectedPath, setSelectedPath] = React.useState(null);
|
|
50
|
+
const [sortBy, setSortBy] = React.useState('title');
|
|
51
|
+
const [perPage, setPerPage] = React.useState(4);
|
|
52
|
+
const [page, setPage] = React.useState(1);
|
|
53
|
+
const [focusRequest, setFocusRequest] = React.useState(null);
|
|
54
|
+
const [resetRequest, setResetRequest] = React.useState(null);
|
|
35
55
|
const [map, setMap] = React.useState();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
56
|
+
// The explorer only exists on the client (it fetches its own data and the map
|
|
57
|
+
// needs OpenLayers), but the first client render has to match the empty
|
|
58
|
+
// server output. `mounted` keeps both sides in sync: SSR and hydration
|
|
59
|
+
// render nothing, the next render shows the explorer.
|
|
60
|
+
const [mounted, setMounted] = React.useState(false);
|
|
40
61
|
|
|
41
62
|
React.useEffect(() => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}, [pathFilteredCases, activeFilters, activeItems.length]);
|
|
63
|
+
setMounted(true);
|
|
64
|
+
}, []);
|
|
45
65
|
|
|
46
|
-
React.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
66
|
+
const narrowing = React.useMemo(
|
|
67
|
+
() => ({ filterPath, measureId }),
|
|
68
|
+
[filterPath, measureId],
|
|
69
|
+
);
|
|
70
|
+
const facets = React.useMemo(
|
|
71
|
+
() => buildFacets(cases, filters, narrowing),
|
|
72
|
+
[cases, filters, narrowing],
|
|
73
|
+
);
|
|
74
|
+
const narrowedResults = React.useMemo(
|
|
75
|
+
() => sortCases(filterCases(cases, filters, narrowing), sortBy),
|
|
76
|
+
[cases, filters, narrowing, sortBy],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Every filter change is applied immediately: there is no Apply step.
|
|
80
|
+
const toggleFacet = React.useCallback((facet, value) => {
|
|
81
|
+
setFilters((current) => withoutSelection(current, facet, value));
|
|
82
|
+
setPage(1);
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
const changeQuery = React.useCallback((query) => {
|
|
86
|
+
setFilters((current) => ({ ...current, query }));
|
|
87
|
+
setPage(1);
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
const resetFilters = React.useCallback(() => {
|
|
91
|
+
setFilters(clearFilters());
|
|
92
|
+
setPage(1);
|
|
93
|
+
setSelectedPath(null);
|
|
94
|
+
}, []);
|
|
54
95
|
|
|
55
|
-
|
|
56
|
-
|
|
96
|
+
// Selecting a case study on the map also brings its card into view: if it
|
|
97
|
+
// lives on another page of the results, jump to that page first.
|
|
98
|
+
const selectCase = React.useCallback(
|
|
99
|
+
(path) => {
|
|
100
|
+
setSelectedPath(path);
|
|
101
|
+
if (!path) return;
|
|
57
102
|
|
|
58
|
-
|
|
103
|
+
const index = narrowedResults.findIndex(
|
|
104
|
+
(item) => item.properties?.path === path,
|
|
105
|
+
);
|
|
106
|
+
if (index === -1) return;
|
|
107
|
+
|
|
108
|
+
setPage(Math.floor(index / perPage) + 1);
|
|
109
|
+
|
|
110
|
+
// const card = document.getElementById(
|
|
111
|
+
// `cse-card-${encodeURIComponent(path)}`,
|
|
112
|
+
// );
|
|
113
|
+
// card?.scrollIntoView({
|
|
114
|
+
// behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
|
115
|
+
// block: 'center',
|
|
116
|
+
// });
|
|
117
|
+
},
|
|
118
|
+
[narrowedResults, perPage],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const showOnMap = React.useCallback((item) => {
|
|
122
|
+
const path = item?.properties?.path;
|
|
123
|
+
if (!path) return;
|
|
124
|
+
|
|
125
|
+
setSelectedPath(path);
|
|
126
|
+
setFocusRequest({ path, nonce: Date.now() });
|
|
127
|
+
// scrollToElement('cse-map-container');
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
// "Reset map" on an already selected card: drop the selection and send the
|
|
131
|
+
// map back to its default extent, exactly like the map's own reset button.
|
|
132
|
+
const resetMap = React.useCallback(() => {
|
|
133
|
+
setSelectedPath(null);
|
|
134
|
+
setResetRequest({ nonce: Date.now() });
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
if (!mounted) return null;
|
|
138
|
+
|
|
139
|
+
// The explorer is empty until the backend answers, so say what is going on
|
|
140
|
+
// instead of flashing an empty map and a "no results" message.
|
|
141
|
+
if (loading) {
|
|
142
|
+
return (
|
|
143
|
+
<div className="cse-explorer">
|
|
144
|
+
<div className="cse-loading" role="status" aria-live="polite">
|
|
145
|
+
<span className="cse-loading-spinner" aria-hidden="true" />
|
|
146
|
+
{intl.formatMessage(messages.loadingCaseStudies)}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (error) {
|
|
153
|
+
return (
|
|
154
|
+
<div className="cse-explorer">
|
|
155
|
+
<div className="cse-loading" role="alert">
|
|
156
|
+
<p className="cse-loading-message">
|
|
157
|
+
{intl.formatMessage(messages.errorCaseStudies)}
|
|
158
|
+
</p>
|
|
159
|
+
<button type="button" className="cse-loading-retry" onClick={reload}>
|
|
160
|
+
{intl.formatMessage(messages.retry)}
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
59
166
|
|
|
60
167
|
return (
|
|
61
|
-
<div className="
|
|
62
|
-
<
|
|
63
|
-
{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
168
|
+
<div className="cse-explorer">
|
|
169
|
+
<FilterPanel
|
|
170
|
+
facets={facets}
|
|
171
|
+
filters={filters}
|
|
172
|
+
onToggle={toggleFacet}
|
|
173
|
+
onQueryChange={changeQuery}
|
|
174
|
+
onReset={resetFilters}
|
|
175
|
+
/>
|
|
176
|
+
|
|
177
|
+
<div className="cse-layout">
|
|
178
|
+
<div className="cse-layout-map">
|
|
179
|
+
<CaseStudyMap
|
|
180
|
+
items={narrowedResults}
|
|
181
|
+
selectedPath={selectedPath}
|
|
182
|
+
onSelect={selectCase}
|
|
70
183
|
map={map}
|
|
184
|
+
setMap={setMap}
|
|
185
|
+
focusRequest={focusRequest}
|
|
186
|
+
resetRequest={resetRequest}
|
|
71
187
|
/>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<div className="cse-layout-results" id="cse-results">
|
|
191
|
+
<CaseStudyResults
|
|
192
|
+
items={narrowedResults}
|
|
193
|
+
selectedPath={selectedPath}
|
|
194
|
+
onShowOnMap={showOnMap}
|
|
195
|
+
onResetMap={resetMap}
|
|
196
|
+
sortBy={sortBy}
|
|
197
|
+
onSortChange={(value) => {
|
|
198
|
+
setSortBy(value);
|
|
199
|
+
setPage(1);
|
|
200
|
+
}}
|
|
201
|
+
perPage={perPage}
|
|
202
|
+
onPerPageChange={(value) => {
|
|
203
|
+
setPerPage(value);
|
|
204
|
+
setPage(1);
|
|
205
|
+
}}
|
|
206
|
+
page={page}
|
|
207
|
+
onPageChange={setPage}
|
|
208
|
+
onResetFilters={resetFilters}
|
|
90
209
|
/>
|
|
91
|
-
|
|
92
|
-
</
|
|
93
|
-
<Grid.Row>
|
|
94
|
-
{pathFilteredCases.length ? (
|
|
95
|
-
<Grid columns={12}>
|
|
96
|
-
<Grid.Column mobile={12} tablet={12} computer={12}>
|
|
97
|
-
<CaseStudyMap
|
|
98
|
-
items={pathFilteredCases}
|
|
99
|
-
activeItems={activeItems}
|
|
100
|
-
hideFilters={hideFilters}
|
|
101
|
-
selectedCase={selectedCase}
|
|
102
|
-
onSelectedCase={onSelectedCase}
|
|
103
|
-
searchInput={searchInput}
|
|
104
|
-
map={map}
|
|
105
|
-
setMap={setMap}
|
|
106
|
-
/>
|
|
107
|
-
</Grid.Column>
|
|
108
|
-
</Grid>
|
|
109
|
-
) : null}
|
|
110
|
-
</Grid.Row>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
111
212
|
</div>
|
|
112
213
|
);
|
|
113
214
|
}
|