@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
@@ -0,0 +1,181 @@
1
+ import './mockJsdom';
2
+ import React from 'react';
3
+ import '@testing-library/jest-dom';
4
+ import { render, screen, fireEvent } from '@testing-library/react';
5
+ import { IntlProvider } from 'react-intl';
6
+
7
+ import FilterPanel from './FilterPanel';
8
+ import { EMPTY_FILTERS } from './filters';
9
+
10
+ const facets = {
11
+ legislation: [
12
+ {
13
+ value: 'Article 4 - Terrestrial',
14
+ label: 'Article 4 - Terrestrial',
15
+ count: 20,
16
+ },
17
+ ],
18
+ ecosystem: [{ value: 'Grassland', label: 'Grassland', count: 18 }],
19
+ bioregion: [{ value: 'Atlantic', label: 'Atlantic', count: 30 }],
20
+ scale: [{ value: 'National Scale', label: 'National Scale', count: 25 }],
21
+ status: [
22
+ { value: 'planned', label: 'planned', count: 12 },
23
+ { value: 'ongoing', label: 'ongoing', count: 74 },
24
+ { value: 'completed', label: 'completed', count: 42 },
25
+ ],
26
+ };
27
+
28
+ const renderPanel = (props = {}) =>
29
+ render(
30
+ <IntlProvider locale="en">
31
+ <FilterPanel
32
+ facets={facets}
33
+ filters={EMPTY_FILTERS}
34
+ onToggle={jest.fn()}
35
+ onQueryChange={jest.fn()}
36
+ onReset={jest.fn()}
37
+ {...props}
38
+ />
39
+ </IntlProvider>,
40
+ );
41
+
42
+ describe('FilterPanel', () => {
43
+ test('renders the title, the search field and every facet', () => {
44
+ renderPanel();
45
+
46
+ expect(screen.getByText('Filter and explore')).toBeInTheDocument();
47
+ expect(
48
+ screen.getByRole('searchbox', { name: /Search measures and typology/i }),
49
+ ).toBeInTheDocument();
50
+ expect(
51
+ screen.queryByRole('button', { name: /Measure or typology/i }),
52
+ ).not.toBeInTheDocument();
53
+ expect(
54
+ screen.getByRole('button', { name: /Legislation/i }),
55
+ ).toBeInTheDocument();
56
+ expect(
57
+ screen.getByRole('button', { name: /Habitat\/ecosystem/i }),
58
+ ).toBeInTheDocument();
59
+ expect(
60
+ screen.getByRole('button', { name: /Biogeographical region/i }),
61
+ ).toBeInTheDocument();
62
+ expect(
63
+ screen.getByRole('button', { name: /Scale of planning/i }),
64
+ ).toBeInTheDocument();
65
+ });
66
+
67
+ test('renders no Apply button - changes take effect immediately', () => {
68
+ renderPanel();
69
+
70
+ expect(
71
+ screen.queryByRole('button', { name: /Apply filters/i }),
72
+ ).not.toBeInTheDocument();
73
+ expect(screen.queryByText(/Filters changed/)).not.toBeInTheDocument();
74
+ });
75
+
76
+ test('renders the three status toggles with counts, nothing pressed', () => {
77
+ renderPanel();
78
+
79
+ expect(screen.getByRole('button', { name: /Planned/ })).toHaveAttribute(
80
+ 'aria-pressed',
81
+ 'false',
82
+ );
83
+ expect(screen.getByRole('button', { name: /Ongoing/ })).toHaveTextContent(
84
+ '74',
85
+ );
86
+ expect(
87
+ screen.getByRole('button', { name: /Completed/ }),
88
+ ).toBeInTheDocument();
89
+ });
90
+
91
+ test('reports free-text searches through onQueryChange', () => {
92
+ const onQueryChange = jest.fn();
93
+ renderPanel({ onQueryChange });
94
+
95
+ fireEvent.change(
96
+ screen.getByRole('searchbox', { name: /Search measures and typology/i }),
97
+ { target: { value: 'restore rivers' } },
98
+ );
99
+
100
+ expect(onQueryChange).toHaveBeenCalledWith('restore rivers');
101
+ });
102
+
103
+ test('offers a clear button only while the search has a value', () => {
104
+ const onQueryChange = jest.fn();
105
+ const { rerender } = renderPanel();
106
+ expect(
107
+ screen.queryByRole('button', { name: 'Clear the search' }),
108
+ ).not.toBeInTheDocument();
109
+
110
+ rerender(
111
+ <IntlProvider locale="en">
112
+ <FilterPanel
113
+ facets={facets}
114
+ filters={{ ...EMPTY_FILTERS, query: 'peat' }}
115
+ onToggle={jest.fn()}
116
+ onQueryChange={onQueryChange}
117
+ onReset={jest.fn()}
118
+ />
119
+ </IntlProvider>,
120
+ );
121
+
122
+ fireEvent.click(screen.getByRole('button', { name: 'Clear the search' }));
123
+
124
+ expect(onQueryChange).toHaveBeenCalledWith('');
125
+ });
126
+
127
+ test('toggles a status filter through onToggle', () => {
128
+ const onToggle = jest.fn();
129
+ renderPanel({ onToggle });
130
+
131
+ fireEvent.click(screen.getByRole('button', { name: /Planned/ }));
132
+
133
+ expect(onToggle).toHaveBeenCalledWith('status', 'planned');
134
+ });
135
+
136
+ test('marks a selected status as pressed', () => {
137
+ renderPanel({ filters: { ...EMPTY_FILTERS, status: ['ongoing'] } });
138
+
139
+ expect(screen.getByRole('button', { name: /Ongoing/ })).toHaveAttribute(
140
+ 'aria-pressed',
141
+ 'true',
142
+ );
143
+ });
144
+
145
+ test('opens a dropdown and toggles an option', () => {
146
+ const onToggle = jest.fn();
147
+ renderPanel({ onToggle });
148
+
149
+ fireEvent.click(screen.getByRole('button', { name: /Legislation/i }));
150
+ fireEvent.click(screen.getByLabelText(/Article 4 - Terrestrial/));
151
+
152
+ expect(onToggle).toHaveBeenCalledWith(
153
+ 'legislation',
154
+ 'Article 4 - Terrestrial',
155
+ );
156
+ });
157
+
158
+ test('disables Reset when no filter is selected', () => {
159
+ renderPanel();
160
+ expect(
161
+ screen.getByRole('button', { name: /Reset filters/ }),
162
+ ).toBeDisabled();
163
+ });
164
+
165
+ test('enables Reset as soon as one filter is selected', () => {
166
+ renderPanel({ filters: { ...EMPTY_FILTERS, status: ['planned'] } });
167
+ expect(screen.getByRole('button', { name: /Reset filters/ })).toBeEnabled();
168
+ });
169
+
170
+ test('calls onReset', () => {
171
+ const onReset = jest.fn();
172
+ renderPanel({
173
+ filters: { ...EMPTY_FILTERS, status: ['planned'] },
174
+ onReset,
175
+ });
176
+
177
+ fireEvent.click(screen.getByRole('button', { name: /Reset filters/ }));
178
+
179
+ expect(onReset).toHaveBeenCalled();
180
+ });
181
+ });
@@ -0,0 +1,81 @@
1
+ import React from 'react';
2
+ import { useIntl } from 'react-intl';
3
+
4
+ import messages from './messages';
5
+
6
+ const range = (from, to) =>
7
+ Array.from({ length: to - from + 1 }, (_, index) => from + index);
8
+
9
+ /**
10
+ * Page numbers to show: all of them when the list is short, otherwise first,
11
+ * last and the neighbours of the current page, with ellipsis markers.
12
+ */
13
+ export function paginationItems(page, pageCount) {
14
+ if (pageCount <= 7) return range(1, pageCount);
15
+
16
+ const items = [1];
17
+ const start = Math.max(2, page - 1);
18
+ const end = Math.min(pageCount - 1, page + 1);
19
+
20
+ if (start > 2) items.push('start-ellipsis');
21
+ items.push(...range(start, end));
22
+ if (end < pageCount - 1) items.push('end-ellipsis');
23
+ items.push(pageCount);
24
+
25
+ return items;
26
+ }
27
+
28
+ export default function Pagination({ page, pageCount, onPageChange }) {
29
+ const intl = useIntl();
30
+ if (pageCount < 1) return null;
31
+
32
+ return (
33
+ <nav
34
+ className="cse-pagination"
35
+ aria-label={intl.formatMessage(messages.pagination)}
36
+ >
37
+ <button
38
+ type="button"
39
+ className="cse-pagination-step"
40
+ onClick={() => onPageChange(page - 1)}
41
+ disabled={page <= 1}
42
+ aria-label={intl.formatMessage(messages.previousPage)}
43
+ >
44
+ <i className="icon ri-arrow-left-s-line" aria-hidden="true" />
45
+ </button>
46
+
47
+ {paginationItems(page, pageCount).map((item) =>
48
+ typeof item === 'number' ? (
49
+ <button
50
+ type="button"
51
+ key={item}
52
+ className="cse-pagination-page"
53
+ aria-current={item === page ? 'page' : undefined}
54
+ aria-label={intl.formatMessage(messages.pageNumber, { page: item })}
55
+ onClick={() => onPageChange(item)}
56
+ >
57
+ {item}
58
+ </button>
59
+ ) : (
60
+ <span
61
+ className="cse-pagination-ellipsis"
62
+ key={item}
63
+ aria-hidden="true"
64
+ >
65
+ …
66
+ </span>
67
+ ),
68
+ )}
69
+
70
+ <button
71
+ type="button"
72
+ className="cse-pagination-step"
73
+ onClick={() => onPageChange(page + 1)}
74
+ disabled={page >= pageCount}
75
+ aria-label={intl.formatMessage(messages.nextPage)}
76
+ >
77
+ <i className="icon ri-arrow-right-s-line" aria-hidden="true" />
78
+ </button>
79
+ </nav>
80
+ );
81
+ }
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Case study facet model.
3
+ *
4
+ * Pure functions behind the explorer's filter panel: normalising payload
5
+ * values, free-text search over measures and typology, building facet options
6
+ * with counts, applying a set of filters and ordering the results. Kept free of React and of the map so the behaviour
7
+ * can be tested without a browser.
8
+ *
9
+ * See docs/adr/0002-case-study-explorer-filter-model.md.
10
+ */
11
+
12
+ /**
13
+ * Facets offered as dropdowns. The measures and the typology of measures are
14
+ * not facets any more: they are searched as free text (`query`), see
15
+ * `caseMatchesQuery`.
16
+ */
17
+ export const FACET_NAMES = [
18
+ 'legislation',
19
+ 'ecosystem',
20
+ 'bioregion',
21
+ 'scale',
22
+ 'status',
23
+ ];
24
+
25
+ export const EMPTY_FILTERS = Object.freeze({
26
+ ...FACET_NAMES.reduce((accumulator, name) => {
27
+ accumulator[name] = [];
28
+ return accumulator;
29
+ }, {}),
30
+ query: '',
31
+ });
32
+
33
+ // The backend renamed several fields over time: the ecosystem facet was
34
+ // serialised as `ecosystem_typology`, later as `nrr_habitat_ecosystem_type`,
35
+ // and the map endpoint currently emits `habitat_ecosystem_type`. Accept all of
36
+ // them so the card and the facet do not silently render empty.
37
+ const FIELD_ALIASES = {
38
+ ecosystem: [
39
+ 'habitat_ecosystem_type',
40
+ 'nrr_habitat_ecosystem_type',
41
+ 'ecosystem_typology',
42
+ ],
43
+ scale: ['scale_of_planning', 'nrr_scale_of_planning'],
44
+ };
45
+
46
+ export function getCaseProperty(properties, fieldName) {
47
+ if (properties?.[fieldName] !== undefined) return properties[fieldName];
48
+
49
+ return (FIELD_ALIASES[fieldName] || [])
50
+ .map((field) => properties?.[field])
51
+ .find((value) => value !== undefined);
52
+ }
53
+
54
+ export function asValues(value) {
55
+ if (value === undefined || value === null || value === '') return [];
56
+ return Array.isArray(value) ? value : [value];
57
+ }
58
+
59
+ export function valueCode(value) {
60
+ if (value && typeof value === 'object') {
61
+ return String(
62
+ value.id ??
63
+ value.code ??
64
+ value.value ??
65
+ value.token ??
66
+ value.title ??
67
+ value,
68
+ );
69
+ }
70
+
71
+ return String(value);
72
+ }
73
+
74
+ export function valueLabel(value) {
75
+ if (value && typeof value === 'object') {
76
+ return String(
77
+ value.title ??
78
+ value.name ??
79
+ value.label ??
80
+ value.code ??
81
+ value.value ??
82
+ value,
83
+ );
84
+ }
85
+
86
+ return String(value);
87
+ }
88
+
89
+ const MEASURE_CODE_PREFIX = /^([A-Za-z]+)/;
90
+
91
+ /**
92
+ * Turn a typology choice label ("NRPM_001 - MA01 - Prevent conversion ...")
93
+ * into its structured parts. The backend sends `typology` already split; this
94
+ * is the fallback for payloads that only carry the legacy string list.
95
+ */
96
+ export function toTypologyEntry(label) {
97
+ const text = String(label ?? '').trim();
98
+ const parts = text.split(' - ', 3).map((part) => part.trim());
99
+
100
+ if (parts.length < 2 || !(parts[0] && parts[1])) {
101
+ return { code: text, family: '', title: text };
102
+ }
103
+
104
+ const [code, measureCode] = parts;
105
+ const title = parts.length === 3 && parts[2] ? parts[2] : text;
106
+ const family = MEASURE_CODE_PREFIX.exec(measureCode);
107
+
108
+ return { code, family: family ? family[1] : '', title };
109
+ }
110
+
111
+ /**
112
+ * Raw values of one facet for one case study's properties. Returns the
113
+ * payload values (strings or objects), never codes or labels.
114
+ */
115
+ export function getFacetValues(properties, facetName) {
116
+ const data = properties || {};
117
+
118
+ switch (facetName) {
119
+ case 'measures':
120
+ return asValues(data.measures);
121
+ case 'typology':
122
+ return asValues(data.typology)?.length
123
+ ? asValues(data.typology)
124
+ : asValues(data.typology_of_measures).map(toTypologyEntry);
125
+ case 'legislation':
126
+ return asValues(data.nrr_legislation);
127
+ case 'ecosystem':
128
+ return asValues(getCaseProperty(data, 'ecosystem'));
129
+ case 'bioregion':
130
+ return asValues(data.nrr_biogeographical_region);
131
+ case 'scale':
132
+ return asValues(getCaseProperty(data, 'scale'));
133
+ case 'status':
134
+ return asValues(data.current_status);
135
+ default:
136
+ return [];
137
+ }
138
+ }
139
+
140
+ const normalizeSearch = (value) =>
141
+ String(value ?? '')
142
+ .trim()
143
+ .toLowerCase();
144
+
145
+ /**
146
+ * Everything a free-text search may match on: the case study's implemented
147
+ * measures (title, id, path) and its typology of measures (title, code,
148
+ * family). Rendered as one lower-cased haystack so a query can mix both.
149
+ */
150
+ export function getSearchText(properties) {
151
+ const measures = getFacetValues(properties, 'measures').flatMap((measure) =>
152
+ measure && typeof measure === 'object'
153
+ ? [measure.title, measure.id, measure.path]
154
+ : [measure],
155
+ );
156
+ const typology = getFacetValues(properties, 'typology').flatMap((entry) => [
157
+ valueLabel(entry),
158
+ valueCode(entry),
159
+ entry?.family,
160
+ entry?.title,
161
+ ]);
162
+
163
+ return [measures, typology].flat().filter(Boolean).join(' ').toLowerCase();
164
+ }
165
+
166
+ /**
167
+ * Free-text filter over the measures implemented and the typology of
168
+ * measures. Whitespace separates terms and every term has to appear, so
169
+ * "restore rivers" finds a case whose measure title contains both words.
170
+ */
171
+ export function caseMatchesQuery(properties, query) {
172
+ const terms = normalizeSearch(query).split(/\s+/).filter(Boolean);
173
+ if (!terms.length) return true;
174
+
175
+ const haystack = getSearchText(properties);
176
+
177
+ return terms.every((term) => haystack.includes(term));
178
+ }
179
+
180
+ export function caseMatchesFacet(properties, facetName, selected) {
181
+ if (!selected?.length) return true;
182
+
183
+ const values = getFacetValues(properties, facetName).map(valueCode);
184
+
185
+ return selected.some((choice) => values.includes(String(choice)));
186
+ }
187
+
188
+ export function filterCasesByPath(cases, filterPath) {
189
+ const path = filterPath?.trim();
190
+ if (!path) return cases;
191
+
192
+ return cases.filter((item) =>
193
+ String(item.properties?.path || '').includes(path),
194
+ );
195
+ }
196
+
197
+ /**
198
+ * Apply the free-text query and every facet (OR inside a facet, AND between
199
+ * facets), optionally narrowed to a path or to the case studies implementing
200
+ * one measure.
201
+ */
202
+ export function filterCases(cases, filters, options = {}) {
203
+ const { filterPath, measureId } = options || {};
204
+ const active = filters || EMPTY_FILTERS;
205
+
206
+ return filterCasesByPath(cases, filterPath).filter((item) => {
207
+ const properties = item.properties || {};
208
+
209
+ if (measureId) {
210
+ const measures = getFacetValues(properties, 'measures').map(valueCode);
211
+ if (!measures.includes(String(measureId))) return false;
212
+ }
213
+
214
+ if (!caseMatchesQuery(properties, active.query)) return false;
215
+
216
+ return FACET_NAMES.every((facetName) =>
217
+ caseMatchesFacet(properties, facetName, active[facetName]),
218
+ );
219
+ });
220
+ }
221
+
222
+ /**
223
+ * How many cases each value of a facet would match, given the *other* facets
224
+ * currently selected. A facet does not narrow itself, otherwise a value would
225
+ * always read as its own selection count.
226
+ */
227
+ export function countFacetValues(cases, filters, facetName, options = {}) {
228
+ const otherFilters = FACET_NAMES.reduce((accumulator, name) => {
229
+ accumulator[name] = name === facetName ? [] : filters?.[name] || [];
230
+ return accumulator;
231
+ }, {});
232
+ otherFilters.query = filters?.query || '';
233
+
234
+ const visible = filterCases(cases, otherFilters, options);
235
+
236
+ return visible.reduce((counts, item) => {
237
+ getFacetValues(item.properties, facetName).forEach((value) => {
238
+ const code = valueCode(value);
239
+ counts[code] = (counts[code] || 0) + 1;
240
+ });
241
+
242
+ return counts;
243
+ }, {});
244
+ }
245
+
246
+ function collectFacetIndex(cases, facetName) {
247
+ const index = new Map();
248
+
249
+ (cases || []).forEach((item) => {
250
+ getFacetValues(item.properties, facetName).forEach((value) => {
251
+ const code = valueCode(value);
252
+ if (index.has(code)) return;
253
+
254
+ index.set(code, {
255
+ value: code,
256
+ label: valueLabel(value),
257
+ ...(facetName === 'typology' && value?.family
258
+ ? { family: value.family }
259
+ : {}),
260
+ });
261
+ });
262
+ });
263
+
264
+ return index;
265
+ }
266
+
267
+ /**
268
+ * Facet options (label, count, typology family) for every facet, ordered by
269
+ * label. Only values present in the data are offered: the typology facet
270
+ * alone has 162 possible codes.
271
+ */
272
+ export function buildFacets(cases, filters = EMPTY_FILTERS, options = {}) {
273
+ return FACET_NAMES.reduce((facets, facetName) => {
274
+ const index = collectFacetIndex(cases, facetName);
275
+ const counts = countFacetValues(cases, filters, facetName, options);
276
+
277
+ facets[facetName] = Object.entries(counts)
278
+ .map(([value, count]) => ({
279
+ value,
280
+ label: index.get(value)?.label ?? value,
281
+ ...(index.get(value)?.family
282
+ ? { family: index.get(value).family }
283
+ : {}),
284
+ count,
285
+ }))
286
+ .sort((first, second) => first.label.localeCompare(second.label));
287
+
288
+ return facets;
289
+ }, {});
290
+ }
291
+
292
+ export function hasActiveFilters(filters) {
293
+ return (
294
+ Boolean(filters?.query?.trim()) ||
295
+ FACET_NAMES.some((name) => (filters?.[name] || []).length > 0)
296
+ );
297
+ }
298
+
299
+ export function clearFilters() {
300
+ return {
301
+ ...FACET_NAMES.reduce((cleared, name) => {
302
+ cleared[name] = [];
303
+ return cleared;
304
+ }, {}),
305
+ query: '',
306
+ };
307
+ }
308
+
309
+ function effectiveTime(item) {
310
+ const time = Date.parse(item.properties?.effective ?? '');
311
+ return Number.isNaN(time) ? null : time;
312
+ }
313
+
314
+ export function sortCases(cases, sortBy = 'title') {
315
+ const sorted = [...(cases || [])];
316
+
317
+ if (sortBy === 'newest') {
318
+ return sorted.sort((first, second) => {
319
+ const firstTime = effectiveTime(first);
320
+ const secondTime = effectiveTime(second);
321
+ if (firstTime === null && secondTime === null) return 0;
322
+ if (firstTime === null) return 1;
323
+ if (secondTime === null) return -1;
324
+ return secondTime - firstTime;
325
+ });
326
+ }
327
+
328
+ return sorted.sort((first, second) =>
329
+ String(first.properties?.title || '').localeCompare(
330
+ String(second.properties?.title || ''),
331
+ ),
332
+ );
333
+ }
334
+
335
+ const NRR_ARTICLE_PREFIX = /^article\b/i;
336
+
337
+ /**
338
+ * The card shows how many NRR articles a case study cites. The payload stores
339
+ * them in `nrr_legislation`, mixed with directives (Habitats, Birds), which
340
+ * are not NRR articles and are shown separately.
341
+ */
342
+ export function getArticleInfo(properties) {
343
+ const legislation = asValues(properties?.nrr_legislation).map(String);
344
+
345
+ return {
346
+ articles: legislation.filter((value) => NRR_ARTICLE_PREFIX.test(value)),
347
+ directives: legislation.filter((value) => !NRR_ARTICLE_PREFIX.test(value)),
348
+ count: legislation.filter((value) => NRR_ARTICLE_PREFIX.test(value)).length,
349
+ };
350
+ }