@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,399 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EMPTY_FILTERS,
|
|
3
|
+
asValues,
|
|
4
|
+
valueCode,
|
|
5
|
+
valueLabel,
|
|
6
|
+
getFacetValues,
|
|
7
|
+
filterCases,
|
|
8
|
+
filterCasesByPath,
|
|
9
|
+
countFacetValues,
|
|
10
|
+
buildFacets,
|
|
11
|
+
getSearchText,
|
|
12
|
+
caseMatchesQuery,
|
|
13
|
+
hasActiveFilters,
|
|
14
|
+
clearFilters,
|
|
15
|
+
sortCases,
|
|
16
|
+
getArticleInfo,
|
|
17
|
+
toTypologyEntry,
|
|
18
|
+
} from './filters';
|
|
19
|
+
|
|
20
|
+
const typology = (code, family, title) => ({ code, family, title });
|
|
21
|
+
|
|
22
|
+
const caseStudy = (id, properties = {}) => ({
|
|
23
|
+
geometry: { type: 'Point', coordinates: [0, 0] },
|
|
24
|
+
properties: {
|
|
25
|
+
title: `Case ${id}`,
|
|
26
|
+
url: `https://example.com/case-${id}`,
|
|
27
|
+
path: `/case-${id}`,
|
|
28
|
+
...properties,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const cases = [
|
|
33
|
+
caseStudy(1, {
|
|
34
|
+
title: 'Peatland rewetting in Bohemia',
|
|
35
|
+
current_status: 'ongoing',
|
|
36
|
+
measures: [
|
|
37
|
+
{ id: 'measure-a', title: 'Restore degraded peatlands', path: '/m/a' },
|
|
38
|
+
],
|
|
39
|
+
typology: [
|
|
40
|
+
typology('NRPM_001', 'MA', 'Prevent conversion of habitats'),
|
|
41
|
+
typology('NRPM_020', 'MB', 'Restore forest habitats'),
|
|
42
|
+
],
|
|
43
|
+
nrr_legislation: ['Article 4 - Terrestrial', 'Birds Directive (BD)'],
|
|
44
|
+
nrr_habitat_ecosystem_type: ['Inland wetlands'],
|
|
45
|
+
nrr_biogeographical_region: ['Continental'],
|
|
46
|
+
nrr_scale_of_planning: ['National Scale'],
|
|
47
|
+
effective: '2026-02-11T09:36:06+00:00',
|
|
48
|
+
}),
|
|
49
|
+
caseStudy(2, {
|
|
50
|
+
title: 'River connectivity in Germany',
|
|
51
|
+
current_status: 'completed',
|
|
52
|
+
measures: [
|
|
53
|
+
{
|
|
54
|
+
id: 'measure-b',
|
|
55
|
+
title: 'Restore rivers and floodplains',
|
|
56
|
+
path: '/m/b',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
typology: [typology('NRPM_020', 'MB', 'Restore forest habitats')],
|
|
60
|
+
nrr_legislation: ['Article 9 - Rivers'],
|
|
61
|
+
nrr_habitat_ecosystem_type: ['Rivers and canals', 'Lakes and reservoirs'],
|
|
62
|
+
nrr_biogeographical_region: ['Atlantic'],
|
|
63
|
+
nrr_scale_of_planning: ['Regional Scale (NUTS 2 / NUTS 3)'],
|
|
64
|
+
effective: '2026-01-02T00:00:00+00:00',
|
|
65
|
+
}),
|
|
66
|
+
caseStudy(3, {
|
|
67
|
+
title: 'Pollinator grasslands in France',
|
|
68
|
+
current_status: 'planned',
|
|
69
|
+
measures: [],
|
|
70
|
+
typology: [],
|
|
71
|
+
nrr_legislation: [],
|
|
72
|
+
nrr_habitat_ecosystem_type: [
|
|
73
|
+
'Grassland (pastures, semi-natural and natural grasslands)',
|
|
74
|
+
],
|
|
75
|
+
nrr_biogeographical_region: ['Atlantic', 'Continental'],
|
|
76
|
+
nrr_scale_of_planning: [],
|
|
77
|
+
}),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
describe('facet values', () => {
|
|
81
|
+
test('asValues normalizes scalars, null and lists', () => {
|
|
82
|
+
expect(asValues(undefined)).toEqual([]);
|
|
83
|
+
expect(asValues(null)).toEqual([]);
|
|
84
|
+
expect(asValues('')).toEqual([]);
|
|
85
|
+
expect(asValues('planned')).toEqual(['planned']);
|
|
86
|
+
expect(asValues(['a', 'b'])).toEqual(['a', 'b']);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('valueCode/valueLabel read strings and objects', () => {
|
|
90
|
+
expect(valueCode('planned')).toBe('planned');
|
|
91
|
+
expect(valueCode({ id: 'measure-a', title: 'Measure A' })).toBe(
|
|
92
|
+
'measure-a',
|
|
93
|
+
);
|
|
94
|
+
expect(valueCode(typology('NRPM_001', 'MA', 'Title'))).toBe('NRPM_001');
|
|
95
|
+
expect(valueLabel({ id: 'measure-a', title: 'Measure A' })).toBe(
|
|
96
|
+
'Measure A',
|
|
97
|
+
);
|
|
98
|
+
expect(valueLabel(typology('NRPM_001', 'MA', 'Title'))).toBe('Title');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('toTypologyEntry parses legacy label strings', () => {
|
|
102
|
+
expect(toTypologyEntry('NRPM_001 - MA01 - Prevent conversion')).toEqual({
|
|
103
|
+
code: 'NRPM_001',
|
|
104
|
+
family: 'MA',
|
|
105
|
+
title: 'Prevent conversion',
|
|
106
|
+
});
|
|
107
|
+
expect(toTypologyEntry('something else')).toEqual({
|
|
108
|
+
code: 'something else',
|
|
109
|
+
family: '',
|
|
110
|
+
title: 'something else',
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('getFacetValues maps every facet to its payload field', () => {
|
|
115
|
+
const properties = cases[0].properties;
|
|
116
|
+
expect(getFacetValues(properties, 'measures')).toHaveLength(1);
|
|
117
|
+
expect(getFacetValues(properties, 'typology')).toHaveLength(2);
|
|
118
|
+
expect(getFacetValues(properties, 'legislation')).toHaveLength(2);
|
|
119
|
+
expect(getFacetValues(properties, 'ecosystem')).toEqual([
|
|
120
|
+
'Inland wetlands',
|
|
121
|
+
]);
|
|
122
|
+
expect(getFacetValues(properties, 'bioregion')).toEqual(['Continental']);
|
|
123
|
+
expect(getFacetValues(properties, 'scale')).toEqual(['National Scale']);
|
|
124
|
+
expect(getFacetValues(properties, 'status')).toEqual(['ongoing']);
|
|
125
|
+
expect(getFacetValues(properties, 'unknown-facet')).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('getFacetValues falls back to legacy typology_of_measures strings', () => {
|
|
129
|
+
expect(
|
|
130
|
+
getFacetValues(
|
|
131
|
+
{ typology_of_measures: ['NRPM_001 - MA01 - Title'] },
|
|
132
|
+
'typology',
|
|
133
|
+
),
|
|
134
|
+
).toEqual([typology('NRPM_001', 'MA', 'Title')]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('getFacetValues reads the habitat_ecosystem_type the map API emits', () => {
|
|
138
|
+
expect(
|
|
139
|
+
getFacetValues(
|
|
140
|
+
{ habitat_ecosystem_type: ['Rivers and canals'] },
|
|
141
|
+
'ecosystem',
|
|
142
|
+
),
|
|
143
|
+
).toEqual(['Rivers and canals']);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('getFacetValues reads the legacy ecosystem_typology alias', () => {
|
|
147
|
+
expect(
|
|
148
|
+
getFacetValues({ ecosystem_typology: ['Forest'] }, 'ecosystem'),
|
|
149
|
+
).toEqual(['Forest']);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('getFacetValues reads the scale_of_planning the map API emits', () => {
|
|
153
|
+
expect(
|
|
154
|
+
getFacetValues({ scale_of_planning: ['Regional Scale'] }, 'scale'),
|
|
155
|
+
).toEqual(['Regional Scale']);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('filterCases', () => {
|
|
160
|
+
test('no filters keeps every case', () => {
|
|
161
|
+
expect(filterCases(cases, EMPTY_FILTERS)).toHaveLength(3);
|
|
162
|
+
expect(filterCases(cases, undefined)).toHaveLength(3);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('a facet with no selection never filters', () => {
|
|
166
|
+
expect(
|
|
167
|
+
filterCases(cases, { ...EMPTY_FILTERS, ecosystem: [] }),
|
|
168
|
+
).toHaveLength(3);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('filters OR within one facet and AND across facets', () => {
|
|
172
|
+
expect(
|
|
173
|
+
filterCases(cases, { ...EMPTY_FILTERS, status: ['ongoing', 'planned'] }),
|
|
174
|
+
).toHaveLength(2);
|
|
175
|
+
|
|
176
|
+
expect(
|
|
177
|
+
filterCases(cases, {
|
|
178
|
+
...EMPTY_FILTERS,
|
|
179
|
+
status: ['ongoing', 'planned'],
|
|
180
|
+
bioregion: ['Continental'],
|
|
181
|
+
}),
|
|
182
|
+
).toHaveLength(2);
|
|
183
|
+
|
|
184
|
+
expect(
|
|
185
|
+
filterCases(cases, {
|
|
186
|
+
...EMPTY_FILTERS,
|
|
187
|
+
status: ['ongoing'],
|
|
188
|
+
bioregion: ['Atlantic'],
|
|
189
|
+
}),
|
|
190
|
+
).toHaveLength(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('filters multi-valued facets', () => {
|
|
194
|
+
expect(
|
|
195
|
+
filterCases(cases, {
|
|
196
|
+
...EMPTY_FILTERS,
|
|
197
|
+
ecosystem: ['Lakes and reservoirs'],
|
|
198
|
+
}),
|
|
199
|
+
).toEqual([cases[1]]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('narrows by filter path', () => {
|
|
203
|
+
expect(filterCasesByPath(cases, '/case-2')).toEqual([cases[1]]);
|
|
204
|
+
expect(filterCasesByPath(cases, ' ')).toEqual(cases);
|
|
205
|
+
expect(
|
|
206
|
+
filterCases(cases, EMPTY_FILTERS, { filterPath: '/case-2' }),
|
|
207
|
+
).toEqual([cases[1]]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('narrows to the case studies of one measure', () => {
|
|
211
|
+
expect(
|
|
212
|
+
filterCases(cases, EMPTY_FILTERS, { measureId: 'measure-a' }),
|
|
213
|
+
).toEqual([cases[0]]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('free text narrows by implemented measures and by typology', () => {
|
|
217
|
+
expect(filterCases(cases, { ...EMPTY_FILTERS, query: 'rivers' })).toEqual([
|
|
218
|
+
cases[1],
|
|
219
|
+
]);
|
|
220
|
+
expect(filterCases(cases, { ...EMPTY_FILTERS, query: 'nrpm_020' })).toEqual(
|
|
221
|
+
[cases[0], cases[1]],
|
|
222
|
+
);
|
|
223
|
+
// `MA` matches the typology family of the first case only.
|
|
224
|
+
expect(filterCases(cases, { ...EMPTY_FILTERS, query: 'MA' })).toEqual([
|
|
225
|
+
cases[0],
|
|
226
|
+
]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('free text requires every term and combines with facets', () => {
|
|
230
|
+
expect(
|
|
231
|
+
filterCases(cases, { ...EMPTY_FILTERS, query: 'restore wetlands' }),
|
|
232
|
+
).toHaveLength(0);
|
|
233
|
+
|
|
234
|
+
expect(
|
|
235
|
+
filterCases(cases, {
|
|
236
|
+
...EMPTY_FILTERS,
|
|
237
|
+
query: 'restore',
|
|
238
|
+
status: ['completed'],
|
|
239
|
+
}),
|
|
240
|
+
).toEqual([cases[1]]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('an empty or blank query never filters', () => {
|
|
244
|
+
expect(filterCases(cases, { ...EMPTY_FILTERS, query: '' })).toHaveLength(3);
|
|
245
|
+
expect(filterCases(cases, { ...EMPTY_FILTERS, query: ' ' })).toHaveLength(
|
|
246
|
+
3,
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('ignores unknown facet names', () => {
|
|
251
|
+
expect(
|
|
252
|
+
filterCases(cases, { ...EMPTY_FILTERS, unknown: ['x'] }),
|
|
253
|
+
).toHaveLength(3);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('search text', () => {
|
|
258
|
+
test('getSearchText concatenates measure and typology values', () => {
|
|
259
|
+
const text = getSearchText(cases[0].properties);
|
|
260
|
+
|
|
261
|
+
expect(text).toContain('restore degraded peatlands');
|
|
262
|
+
expect(text).toContain('/m/a');
|
|
263
|
+
expect(text).toContain('prevent conversion of habitats');
|
|
264
|
+
expect(text).toContain('nrpm_001');
|
|
265
|
+
expect(text).toContain('mb');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('caseMatchesQuery is case-insensitive and term-based', () => {
|
|
269
|
+
expect(caseMatchesQuery(cases[0].properties, 'PEATLANDS')).toBe(true);
|
|
270
|
+
expect(caseMatchesQuery(cases[0].properties, 'peatlands forest')).toBe(
|
|
271
|
+
true,
|
|
272
|
+
);
|
|
273
|
+
expect(caseMatchesQuery(cases[0].properties, 'peatlands rivers')).toBe(
|
|
274
|
+
false,
|
|
275
|
+
);
|
|
276
|
+
expect(caseMatchesQuery(cases[0].properties, '')).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('facet counts', () => {
|
|
281
|
+
test('counts values present in the data', () => {
|
|
282
|
+
expect(countFacetValues(cases, EMPTY_FILTERS, 'status')).toEqual({
|
|
283
|
+
ongoing: 1,
|
|
284
|
+
completed: 1,
|
|
285
|
+
planned: 1,
|
|
286
|
+
});
|
|
287
|
+
expect(countFacetValues(cases, EMPTY_FILTERS, 'ecosystem')).toEqual({
|
|
288
|
+
'Inland wetlands': 1,
|
|
289
|
+
'Rivers and canals': 1,
|
|
290
|
+
'Lakes and reservoirs': 1,
|
|
291
|
+
'Grassland (pastures, semi-natural and natural grasslands)': 1,
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('a facet does not narrow itself', () => {
|
|
296
|
+
const filters = { ...EMPTY_FILTERS, status: ['ongoing'] };
|
|
297
|
+
|
|
298
|
+
expect(countFacetValues(cases, filters, 'status')).toEqual({
|
|
299
|
+
ongoing: 1,
|
|
300
|
+
completed: 1,
|
|
301
|
+
planned: 1,
|
|
302
|
+
});
|
|
303
|
+
// ...but the other facets do narrow it
|
|
304
|
+
expect(countFacetValues(cases, filters, 'bioregion')).toEqual({
|
|
305
|
+
Continental: 1,
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('buildFacets returns sorted options with labels and counts', () => {
|
|
310
|
+
const facets = buildFacets(cases, EMPTY_FILTERS);
|
|
311
|
+
|
|
312
|
+
expect(facets.status).toEqual([
|
|
313
|
+
{ value: 'completed', label: 'completed', count: 1 },
|
|
314
|
+
{ value: 'ongoing', label: 'ongoing', count: 1 },
|
|
315
|
+
{ value: 'planned', label: 'planned', count: 1 },
|
|
316
|
+
]);
|
|
317
|
+
// Measures and typology are searched as free text, not offered as facets.
|
|
318
|
+
expect(facets.measures).toBeUndefined();
|
|
319
|
+
expect(facets.typology).toBeUndefined();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('buildFacets filters options by counts when other facets change', () => {
|
|
323
|
+
const facets = buildFacets(cases, {
|
|
324
|
+
...EMPTY_FILTERS,
|
|
325
|
+
status: ['planned'],
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
expect(facets.ecosystem.map((option) => option.value)).toEqual([
|
|
329
|
+
'Grassland (pastures, semi-natural and natural grasslands)',
|
|
330
|
+
]);
|
|
331
|
+
expect(facets.status.map((option) => option.value)).toEqual([
|
|
332
|
+
'completed',
|
|
333
|
+
'ongoing',
|
|
334
|
+
'planned',
|
|
335
|
+
]);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe('hasActiveFilters', () => {
|
|
340
|
+
test('ignores empty selections', () => {
|
|
341
|
+
expect(hasActiveFilters(EMPTY_FILTERS)).toBe(false);
|
|
342
|
+
expect(hasActiveFilters({ ...EMPTY_FILTERS, status: ['planned'] })).toBe(
|
|
343
|
+
true,
|
|
344
|
+
);
|
|
345
|
+
expect(hasActiveFilters({ ...EMPTY_FILTERS, query: 'peat' })).toBe(true);
|
|
346
|
+
expect(hasActiveFilters({ ...EMPTY_FILTERS, query: ' ' })).toBe(false);
|
|
347
|
+
expect(hasActiveFilters(undefined)).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('clearFilters empties the facets and the query', () => {
|
|
351
|
+
expect(clearFilters()).toEqual(EMPTY_FILTERS);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe('sortCases', () => {
|
|
356
|
+
test('sorts by title A-Z', () => {
|
|
357
|
+
expect(
|
|
358
|
+
sortCases(cases, 'title').map((item) => item.properties.title),
|
|
359
|
+
).toEqual([
|
|
360
|
+
'Peatland rewetting in Bohemia',
|
|
361
|
+
'Pollinator grasslands in France',
|
|
362
|
+
'River connectivity in Germany',
|
|
363
|
+
]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('sorts by newest effective date, undated last', () => {
|
|
367
|
+
expect(
|
|
368
|
+
sortCases(cases, 'newest').map((item) => item.properties.title),
|
|
369
|
+
).toEqual([
|
|
370
|
+
'Peatland rewetting in Bohemia',
|
|
371
|
+
'River connectivity in Germany',
|
|
372
|
+
'Pollinator grasslands in France',
|
|
373
|
+
]);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('does not mutate the input', () => {
|
|
377
|
+
const input = [...cases];
|
|
378
|
+
sortCases(input, 'title');
|
|
379
|
+
expect(input).toEqual(cases);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe('getArticleInfo', () => {
|
|
384
|
+
test('separates NRR articles from directives', () => {
|
|
385
|
+
expect(getArticleInfo(cases[0].properties)).toEqual({
|
|
386
|
+
count: 1,
|
|
387
|
+
articles: ['Article 4 - Terrestrial'],
|
|
388
|
+
directives: ['Birds Directive (BD)'],
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('handles missing legislation', () => {
|
|
393
|
+
expect(getArticleInfo({})).toEqual({
|
|
394
|
+
count: 0,
|
|
395
|
+
articles: [],
|
|
396
|
+
directives: [],
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
});
|
|
@@ -3,18 +3,42 @@ import superagent from 'superagent';
|
|
|
3
3
|
|
|
4
4
|
// const cases_url = '@@case-studies-map.arcgis.json';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Fetch the case studies from the backend. The fetch is slow enough on a
|
|
8
|
+
* cold load that the explorer has to say so: `loading` drives the initial
|
|
9
|
+
* spinner and `error` (with `reload`) drives the retry state.
|
|
10
|
+
*/
|
|
6
11
|
export function useCases(url) {
|
|
7
|
-
const [
|
|
12
|
+
const [state, setState] = React.useState({
|
|
13
|
+
cases: [],
|
|
14
|
+
loading: true,
|
|
15
|
+
error: false,
|
|
16
|
+
});
|
|
17
|
+
const [attempt, setAttempt] = React.useState(0);
|
|
8
18
|
|
|
9
19
|
React.useEffect(() => {
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
setState({ cases: [], loading: true, error: false });
|
|
22
|
+
|
|
10
23
|
superagent
|
|
11
24
|
.get(url)
|
|
12
25
|
.set('accept', 'json')
|
|
13
26
|
.then((resp) => {
|
|
27
|
+
if (cancelled) return;
|
|
14
28
|
const res = JSON.parse(resp.text);
|
|
15
|
-
|
|
29
|
+
setState({ cases: res.features || [], loading: false, error: false });
|
|
30
|
+
})
|
|
31
|
+
.catch(() => {
|
|
32
|
+
if (cancelled) return;
|
|
33
|
+
setState({ cases: [], loading: false, error: true });
|
|
16
34
|
});
|
|
17
|
-
}, [url]);
|
|
18
35
|
|
|
19
|
-
|
|
36
|
+
return () => {
|
|
37
|
+
cancelled = true;
|
|
38
|
+
};
|
|
39
|
+
}, [url, attempt]);
|
|
40
|
+
|
|
41
|
+
const reload = React.useCallback(() => setAttempt((count) => count + 1), []);
|
|
42
|
+
|
|
43
|
+
return { ...state, reload };
|
|
20
44
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import './mockJsdom';
|
|
2
|
+
import { act, waitFor } from '@testing-library/react';
|
|
3
|
+
import { renderHook } from '@testing-library/react-hooks';
|
|
4
|
+
import superagent from 'superagent';
|
|
5
|
+
|
|
6
|
+
import { useCases } from './hooks';
|
|
7
|
+
|
|
8
|
+
jest.mock('superagent', () => ({ get: jest.fn() }));
|
|
9
|
+
|
|
10
|
+
const mockFetch = ({ text, error } = {}) => {
|
|
11
|
+
// Reject after the hook's effect has attached its catch handler; an already
|
|
12
|
+
// rejected promise can trigger Jest's unhandled-rejection handler first.
|
|
13
|
+
const promise = error
|
|
14
|
+
? new Promise((resolve, reject) => setTimeout(() => reject(error), 0))
|
|
15
|
+
: Promise.resolve({ text });
|
|
16
|
+
const request = {
|
|
17
|
+
set: jest.fn(() => request),
|
|
18
|
+
then: (onFulfilled, onRejected) => promise.then(onFulfilled, onRejected),
|
|
19
|
+
catch: (onRejected) => promise.catch(onRejected),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
superagent.get.mockReturnValue(request);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('useCases', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
jest.clearAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('starts out loading', async () => {
|
|
31
|
+
mockFetch({ text: '{"features": []}' });
|
|
32
|
+
|
|
33
|
+
const { result } = renderHook(() => useCases('/cases.json'));
|
|
34
|
+
|
|
35
|
+
expect(result.current.loading).toBe(true);
|
|
36
|
+
expect(result.current.cases).toEqual([]);
|
|
37
|
+
|
|
38
|
+
await act(async () => {});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('exposes the fetched features and stops loading', async () => {
|
|
42
|
+
mockFetch({
|
|
43
|
+
text: JSON.stringify({ features: [{ properties: { path: '/a' } }] }),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const { result } = renderHook(() => useCases('/cases.json'));
|
|
47
|
+
|
|
48
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
49
|
+
expect(result.current.cases).toHaveLength(1);
|
|
50
|
+
expect(result.current.error).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('reports an error instead of staying in the loading state', async () => {
|
|
54
|
+
mockFetch({ error: new Error('boom') });
|
|
55
|
+
|
|
56
|
+
const { result } = renderHook(() => useCases('/cases.json'));
|
|
57
|
+
|
|
58
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
59
|
+
expect(result.current.error).toBe(true);
|
|
60
|
+
expect(result.current.cases).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('refetches when reload is called', async () => {
|
|
64
|
+
mockFetch({ text: '{"features": []}' });
|
|
65
|
+
|
|
66
|
+
const { result } = renderHook(() => useCases('/cases.json'));
|
|
67
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
68
|
+
|
|
69
|
+
act(() => result.current.reload());
|
|
70
|
+
|
|
71
|
+
await waitFor(() => expect(superagent.get).toHaveBeenCalledTimes(2));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { defineMessages } from 'react-intl';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every user-visible string of the case study explorer. Ids are the English
|
|
5
|
+
* source text, so the existing i18n extraction keeps working.
|
|
6
|
+
*/
|
|
7
|
+
export default defineMessages({
|
|
8
|
+
filterPanelTitle: {
|
|
9
|
+
id: 'Filter and explore',
|
|
10
|
+
defaultMessage: 'Filter and explore',
|
|
11
|
+
},
|
|
12
|
+
filterPanelHint: {
|
|
13
|
+
id: 'Find case studies and measures',
|
|
14
|
+
defaultMessage: 'Find case studies and measures',
|
|
15
|
+
},
|
|
16
|
+
resetFilters: {
|
|
17
|
+
id: 'Reset filters',
|
|
18
|
+
defaultMessage: 'Reset filters',
|
|
19
|
+
},
|
|
20
|
+
searchValues: {
|
|
21
|
+
id: 'Search values…',
|
|
22
|
+
defaultMessage: 'Search values…',
|
|
23
|
+
},
|
|
24
|
+
noOptionsAvailable: {
|
|
25
|
+
id: 'No options available',
|
|
26
|
+
defaultMessage: 'No options available',
|
|
27
|
+
},
|
|
28
|
+
noMatchingOptions: {
|
|
29
|
+
id: 'No matching values',
|
|
30
|
+
defaultMessage: 'No matching values',
|
|
31
|
+
},
|
|
32
|
+
searchMeasuresLabel: {
|
|
33
|
+
id: 'Search measures and typology',
|
|
34
|
+
defaultMessage: 'Search measures and typology',
|
|
35
|
+
},
|
|
36
|
+
searchMeasuresPlaceholder: {
|
|
37
|
+
id: 'Type a measure or typology of measures…',
|
|
38
|
+
defaultMessage: 'Type a measure or typology of measures…',
|
|
39
|
+
},
|
|
40
|
+
clearSearch: {
|
|
41
|
+
id: 'Clear the search',
|
|
42
|
+
defaultMessage: 'Clear the search',
|
|
43
|
+
},
|
|
44
|
+
facetLegislation: {
|
|
45
|
+
id: 'Legislation',
|
|
46
|
+
defaultMessage: 'Legislation',
|
|
47
|
+
},
|
|
48
|
+
facetEcosystem: {
|
|
49
|
+
id: 'Habitat/ecosystem',
|
|
50
|
+
defaultMessage: 'Habitat/ecosystem',
|
|
51
|
+
},
|
|
52
|
+
facetBioregion: {
|
|
53
|
+
id: 'Biogeographical region',
|
|
54
|
+
defaultMessage: 'Biogeographical region',
|
|
55
|
+
},
|
|
56
|
+
facetScale: {
|
|
57
|
+
id: 'Scale of planning',
|
|
58
|
+
defaultMessage: 'Scale of planning',
|
|
59
|
+
},
|
|
60
|
+
facetStatus: {
|
|
61
|
+
id: 'Current status',
|
|
62
|
+
defaultMessage: 'Current status',
|
|
63
|
+
},
|
|
64
|
+
statusPlanned: { id: 'Planned', defaultMessage: 'Planned' },
|
|
65
|
+
statusOngoing: { id: 'Ongoing', defaultMessage: 'Ongoing' },
|
|
66
|
+
statusCompleted: { id: 'Completed', defaultMessage: 'Completed' },
|
|
67
|
+
resultsShowing: {
|
|
68
|
+
id: 'Found {count} case studies',
|
|
69
|
+
defaultMessage: 'Found {count} case studies',
|
|
70
|
+
},
|
|
71
|
+
resultsShowingOne: {
|
|
72
|
+
id: 'Found 1 case study',
|
|
73
|
+
defaultMessage: 'Found 1 case study',
|
|
74
|
+
},
|
|
75
|
+
sortBy: { id: 'Sort by', defaultMessage: 'Sort by' },
|
|
76
|
+
sortTitle: { id: 'A to Z', defaultMessage: 'A to Z' },
|
|
77
|
+
sortNewest: { id: 'Newest', defaultMessage: 'Newest' },
|
|
78
|
+
perPage: { id: 'per page', defaultMessage: 'per page' },
|
|
79
|
+
noResultsTitle: {
|
|
80
|
+
id: 'No case studies match your filters',
|
|
81
|
+
defaultMessage: 'No case studies match your filters',
|
|
82
|
+
},
|
|
83
|
+
noResultsHint: {
|
|
84
|
+
id: 'Try removing a filter or resetting the filters.',
|
|
85
|
+
defaultMessage: 'Try removing a filter or resetting the filters.',
|
|
86
|
+
},
|
|
87
|
+
relatedMeasures: {
|
|
88
|
+
id: 'Related NRR measure(s)',
|
|
89
|
+
defaultMessage: 'Related NRR measure(s)',
|
|
90
|
+
},
|
|
91
|
+
moreEcosystems: {
|
|
92
|
+
id: '+{count}',
|
|
93
|
+
defaultMessage: '+{count}',
|
|
94
|
+
},
|
|
95
|
+
hiddenEcosystems: {
|
|
96
|
+
id: 'More habitat/ecosystem values',
|
|
97
|
+
defaultMessage: 'More habitat/ecosystem values',
|
|
98
|
+
},
|
|
99
|
+
resetMap: { id: 'Reset map', defaultMessage: 'Reset map' },
|
|
100
|
+
loadingCaseStudies: {
|
|
101
|
+
id: 'Loading case studies…',
|
|
102
|
+
defaultMessage: 'Loading case studies…',
|
|
103
|
+
},
|
|
104
|
+
loadingMap: {
|
|
105
|
+
id: 'Loading map…',
|
|
106
|
+
defaultMessage: 'Loading map…',
|
|
107
|
+
},
|
|
108
|
+
errorCaseStudies: {
|
|
109
|
+
id: 'The case studies could not be loaded.',
|
|
110
|
+
defaultMessage: 'The case studies could not be loaded.',
|
|
111
|
+
},
|
|
112
|
+
retry: { id: 'Try again', defaultMessage: 'Try again' },
|
|
113
|
+
mapLegend: {
|
|
114
|
+
id: 'Map legend',
|
|
115
|
+
defaultMessage: 'Map legend',
|
|
116
|
+
},
|
|
117
|
+
showOnMap: { id: 'Show on map', defaultMessage: 'Show on map' },
|
|
118
|
+
opensInNewTab: {
|
|
119
|
+
id: '(opens in a new tab)',
|
|
120
|
+
defaultMessage: '(opens in a new tab)',
|
|
121
|
+
},
|
|
122
|
+
openCaseStudy: {
|
|
123
|
+
id: 'Open the case study',
|
|
124
|
+
defaultMessage: 'Open the case study',
|
|
125
|
+
},
|
|
126
|
+
pagination: { id: 'Pagination', defaultMessage: 'Pagination' },
|
|
127
|
+
resultsHeading: {
|
|
128
|
+
id: 'Case studies',
|
|
129
|
+
defaultMessage: 'Case studies',
|
|
130
|
+
},
|
|
131
|
+
previousPage: { id: 'Previous page', defaultMessage: 'Previous page' },
|
|
132
|
+
nextPage: { id: 'Next page', defaultMessage: 'Next page' },
|
|
133
|
+
pageNumber: { id: 'Page {page}', defaultMessage: 'Page {page}' },
|
|
134
|
+
selectedCount: {
|
|
135
|
+
id: '{label} ({count} selected)',
|
|
136
|
+
defaultMessage: '{label} ({count} selected)',
|
|
137
|
+
},
|
|
138
|
+
});
|