@eeacms/volto-bise-policy 1.3.11 → 1.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/package.json +2 -1
  3. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyCard.jsx +404 -0
  4. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyCard.test.jsx +366 -0
  5. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.jsx +192 -91
  6. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.test.jsx +205 -58
  7. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.jsx +329 -178
  8. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.test.jsx +243 -123
  9. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMapLazy.jsx +61 -0
  10. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMapLazy.test.jsx +89 -0
  11. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyResults.jsx +128 -0
  12. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyResults.test.jsx +160 -0
  13. package/src/components/manage/Blocks/CaseStudyExplorer/FacetPopover.jsx +180 -0
  14. package/src/components/manage/Blocks/CaseStudyExplorer/FacetPopover.test.jsx +125 -0
  15. package/src/components/manage/Blocks/CaseStudyExplorer/FilterPanel.jsx +171 -0
  16. package/src/components/manage/Blocks/CaseStudyExplorer/FilterPanel.test.jsx +181 -0
  17. package/src/components/manage/Blocks/CaseStudyExplorer/Pagination.jsx +81 -0
  18. package/src/components/manage/Blocks/CaseStudyExplorer/filters.js +350 -0
  19. package/src/components/manage/Blocks/CaseStudyExplorer/filters.test.js +399 -0
  20. package/src/components/manage/Blocks/CaseStudyExplorer/hooks.js +28 -4
  21. package/src/components/manage/Blocks/CaseStudyExplorer/hooks.test.js +73 -0
  22. package/src/components/manage/Blocks/CaseStudyExplorer/messages.js +138 -0
  23. package/src/components/manage/Blocks/CaseStudyExplorer/styles.less +1157 -317
  24. package/src/components/manage/Blocks/CaseStudyExplorer/testUtils.js +44 -46
  25. package/src/components/manage/Blocks/CaseStudyExplorer/utils.js +68 -184
  26. package/src/components/manage/Blocks/CaseStudyExplorer/utils.test.js +91 -158
  27. package/src/index.js +39 -0
  28. package/src/openlayers.js +51 -0
  29. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyFilters.jsx +0 -383
  30. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyFilters.test.jsx +0 -90
  31. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.jsx +0 -342
  32. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.test.jsx +0 -196
  33. package/src/components/manage/Blocks/CaseStudyExplorer/FeatureDisplay.jsx +0 -24
  34. package/src/components/manage/Blocks/CaseStudyExplorer/FeatureDisplay.test.jsx +0 -49
  35. package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.jsx +0 -100
  36. package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.test.jsx +0 -189
  37. package/src/components/manage/Blocks/CaseStudyExplorer/InfoOverlay.jsx +0 -111
  38. package/src/components/manage/Blocks/CaseStudyExplorer/InfoOverlay.test.jsx +0 -159
  39. package/src/components/manage/Blocks/CaseStudyExplorer/styles.less_old +0 -201
@@ -1,40 +1,48 @@
1
- export const createMockMap = (additionalProps = {}) => ({
2
- on: jest.fn(),
3
- un: jest.fn(),
4
- addInteraction: jest.fn(),
5
- removeInteraction: jest.fn(),
6
- getView: jest.fn(() => ({
1
+ export const createMockMap = (additionalProps = {}) => {
2
+ const view = {
7
3
  getZoom: () => 4,
8
4
  getCenter: () => [111, 222],
9
- })),
10
- getInteractions: jest.fn(() => ({
11
- array_: [
12
- {},
13
- {},
14
- {},
15
- {},
16
- {},
17
- {},
18
- {},
19
- {},
20
- {},
21
- {
22
- getFeatures: jest.fn(() => ({
23
- clear: jest.fn(),
24
- push: jest.fn(),
25
- })),
26
- },
27
- ],
28
- })),
29
- getEventPixel: jest.fn(() => [10, 20]),
30
- hasFeatureAtPixel: jest.fn(() => true),
31
- getViewport: jest.fn(() => ({ style: {} })),
32
- getPixelFromCoordinate: jest.fn(() => [100, 200]),
33
- getFeaturesAtPixel: jest.fn(() => ['mockFeature']),
34
- addOverlay: jest.fn(),
35
- removeOverlay: jest.fn(),
36
- ...additionalProps,
37
- });
5
+ fit: jest.fn(),
6
+ animate: jest.fn(),
7
+ };
8
+
9
+ return {
10
+ on: jest.fn(),
11
+ un: jest.fn(),
12
+ addInteraction: jest.fn(),
13
+ removeInteraction: jest.fn(),
14
+ getView: jest.fn(() => view),
15
+ view,
16
+ getInteractions: jest.fn(() => ({
17
+ array_: [
18
+ {},
19
+ {},
20
+ {},
21
+ {},
22
+ {},
23
+ {},
24
+ {},
25
+ {},
26
+ {},
27
+ {
28
+ getFeatures: jest.fn(() => ({
29
+ clear: jest.fn(),
30
+ push: jest.fn(),
31
+ })),
32
+ },
33
+ ],
34
+ })),
35
+ getEventPixel: jest.fn(() => [10, 20]),
36
+ hasFeatureAtPixel: jest.fn(() => true),
37
+ getViewport: jest.fn(() => ({ style: {} })),
38
+ getPixelFromCoordinate: jest.fn(() => [100, 200]),
39
+ getFeaturesAtPixel: jest.fn(() => ['mockFeature']),
40
+ updateSize: jest.fn(),
41
+ addOverlay: jest.fn(),
42
+ removeOverlay: jest.fn(),
43
+ ...additionalProps,
44
+ };
45
+ };
38
46
 
39
47
  export const createMockOl = (additionalProps = {}) => ({
40
48
  source: {
@@ -42,6 +50,7 @@ export const createMockOl = (additionalProps = {}) => ({
42
50
  Vector: jest.fn(() => ({
43
51
  clear: jest.fn(),
44
52
  addFeatures: jest.fn(),
53
+ changed: jest.fn(),
45
54
  })),
46
55
  Cluster: jest.fn(() => ({})),
47
56
  },
@@ -71,14 +80,3 @@ export const createMockOl = (additionalProps = {}) => ({
71
80
  })),
72
81
  ...additionalProps,
73
82
  });
74
-
75
- export const createMockPointsSource = (features = []) => ({
76
- getFeatures: () => features,
77
- clear: jest.fn(),
78
- addFeatures: jest.fn(),
79
- });
80
-
81
- export const createMockFilters = () => ({
82
- measures_implemented: { m1: 'Measure 1', m2: 'Measure 2' },
83
- typology_of_measures: { sector1: 'Sector 1', sector2: 'Sector 2' },
84
- });
@@ -1,224 +1,108 @@
1
+ // EEA design system tokens: link-default, warning, secondary.
1
2
  export const STATUS_COLORS = {
2
- planned: '#006bb8',
3
- ongoing: '#ff9933',
4
- completed: '#00a390',
3
+ planned: '#006BB8',
4
+ ongoing: '#FF9933',
5
+ completed: '#007B6C',
5
6
  };
6
7
 
7
8
  export const FALLBACK_STATUS_COLOR = '#6B7280';
8
9
  export const CLUSTER_COLOR = '#007B6C';
9
-
10
- const FIELD_ALIASES = {
11
- habitat_ecosystem_type: ['habitat_ecosystem_type', 'ecosystem_typology'],
12
- };
13
-
14
- export function getCaseProperty(properties, fieldName) {
15
- if (properties?.[fieldName] !== undefined) return properties[fieldName];
16
- return (FIELD_ALIASES[fieldName] || [])
17
- .map((field) => properties?.[field])
18
- .find((value) => value !== undefined);
19
- }
20
-
21
- export function asValues(value) {
22
- if (value === undefined || value === null || value === '') return [];
23
- return Array.isArray(value) ? value : [value];
24
- }
25
-
26
- export function valueCode(value) {
27
- if (value && typeof value === 'object') {
28
- return String(
29
- value.id ?? value.value ?? value.token ?? value.title ?? value,
30
- );
31
- }
32
- return String(value);
33
- }
34
-
35
- export function valueLabel(value) {
36
- if (value && typeof value === 'object') {
37
- return String(
38
- value.title ?? value.name ?? value.label ?? value.value ?? value.id,
39
- );
40
- }
41
- return String(value);
42
- }
10
+ // Design system `@cse-green-dark`: the pin of the selected case study.
11
+ export const SELECTED_COLOR = '#005248';
43
12
 
44
13
  export function getStatusColor(status) {
45
14
  return STATUS_COLORS[status] || FALLBACK_STATUS_COLOR;
46
15
  }
47
16
 
17
+ export function prefersReducedMotion() {
18
+ return (
19
+ typeof window !== 'undefined' &&
20
+ typeof window.matchMedia === 'function' &&
21
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
22
+ );
23
+ }
24
+
48
25
  export function centerAndResetMapZoom({ map, ol }) {
49
- map.getView().animate({
50
- zoom: 4,
51
- duration: 1000,
52
- center: ol.proj.transform([10, 49], 'EPSG:4326', 'EPSG:3857'),
26
+ map?.getView().animate({
27
+ zoom: 4.5,
28
+ duration: prefersReducedMotion() ? 0 : 1000,
29
+ center: ol.proj.transform([8, 55], 'EPSG:4326', 'EPSG:3857'),
53
30
  });
54
31
  }
55
32
 
56
- export function scrollToElement(elementId) {
57
- const element = document.getElementById(elementId);
58
- element?.scrollIntoView({ behavior: 'smooth' });
59
- }
33
+ /**
34
+ * How many wrapping chips to keep visible before a trailing "+N" trigger has
35
+ * to stand in for the rest.
36
+ *
37
+ * `chips` are the measured boxes of every chip in render order: `right` is the
38
+ * chip's right edge measured from the left edge of the list and `row` its
39
+ * 0-based row index in the full, untruncated layout. The trigger takes the
40
+ * place right after the last visible chip, so dropping a chip may let the
41
+ * trigger fit on a row it did not fit on before; the scan goes from the full
42
+ * list downwards and stops at the first count whose last chip plus the trigger
43
+ * still fit in `rows` rows.
44
+ *
45
+ * Returns the number of chips to show (0 for an empty list).
46
+ */
47
+ export function visibleChipCount(
48
+ chips,
49
+ { rows = 2, gap = 0, moreWidth = 0, width = 0 } = {},
50
+ ) {
51
+ const total = chips.length;
52
+ if (!total) return 0;
60
53
 
61
- export function getSelectInteraction(map) {
62
- const interactions = map?.getInteractions?.();
63
- const interactionArray =
64
- interactions?.getArray?.() || interactions?.array_ || [];
54
+ const maxRow = rows - 1;
65
55
 
66
- return interactionArray.find(
67
- (interaction) => typeof interaction?.getFeatures === 'function',
68
- );
69
- }
56
+ // Everything already fits: nothing is hidden, so no trigger is needed.
57
+ if (chips[total - 1].row <= maxRow) return total;
58
+
59
+ for (let count = total - 1; count > 0; count -= 1) {
60
+ const last = chips[count - 1];
61
+ const triggerRow =
62
+ last.right + gap + moreWidth <= width ? last.row : last.row + 1;
63
+
64
+ if (triggerRow <= maxRow) return count;
65
+ }
70
66
 
71
- export function getExtentOfFeatures({ features, ol }) {
72
- const points = features.map((f) => f.getGeometry().flatCoordinates);
73
- const point = new ol.geom.MultiPoint(points);
74
- return point.getExtent();
67
+ // Too narrow for any chip next to the trigger: keep one rather than hide
68
+ // everything behind a bare "+N".
69
+ return 1;
75
70
  }
76
71
 
77
- export function zoomMapToFeatures({ map, features, threshold = 500, ol }) {
78
- const extent = getExtentOfFeatures({ features, ol });
79
- let extentBuffer = (extent[3] - extent[1] + extent[2] - extent[0]) / 4;
80
- extentBuffer = extentBuffer < threshold ? threshold : extentBuffer;
81
- const paddedExtent = ol.extent.buffer(extent, extentBuffer);
82
- map.getView().fit(paddedExtent, { ...map.getSize(), duration: 1000 });
72
+ export function scrollToElement(elementId) {
73
+ const element = document.getElementById(elementId);
74
+ element?.scrollIntoView({
75
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
76
+ });
83
77
  }
84
78
 
79
+ /**
80
+ * GeoJSON features -> OpenLayers point features carrying the properties the
81
+ * map style and the map/list coupling need.
82
+ */
85
83
  export function getFeatures({ cases, ol }) {
86
84
  const Feature = ol.ol.Feature;
87
85
 
88
- return cases.map((c, index) => {
86
+ return cases.map((item, index) => {
89
87
  const point = new Feature(
90
- new ol.geom.Point(ol.proj.fromLonLat(c.geometry.coordinates)),
88
+ new ol.geom.Point(ol.proj.fromLonLat(item.geometry.coordinates)),
91
89
  );
92
- const properties = c.properties || {};
90
+ const properties = item.properties || {};
93
91
  const status = properties.current_status;
92
+
94
93
  point.setId(index);
95
94
  point.setProperties(
96
95
  {
97
96
  title: properties.title,
98
97
  image: properties.image,
99
- nwrm_type: properties.nwrm_type,
100
- measures_implemented: properties.measures,
101
- typology_of_measures: properties.typology_of_measures,
102
- current_status: status,
103
- habitat_ecosystem_type: getCaseProperty(
104
- properties,
105
- 'habitat_ecosystem_type',
106
- ),
107
- nrr_legislation: properties.nrr_legislation,
108
- nrr_biogeographical_region: properties.nrr_biogeographical_region,
109
- scale_of_planning: properties.scale_of_planning,
110
- description: properties.description,
111
- index,
112
98
  path: properties.path,
99
+ current_status: status,
113
100
  color: getStatusColor(status),
101
+ index,
114
102
  },
115
103
  false,
116
104
  );
117
- return point;
118
- });
119
- }
120
-
121
- // Escape a string so it can be embedded in a RegExp pattern as a literal.
122
- // Prevents user-provided input from being interpreted as regex syntax
123
- // (avoids ReDoS via crafted patterns and SyntaxError from malformed ones).
124
- export function escapeRegExp(str) {
125
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
126
- }
127
-
128
- const WORD_CHAR = /[A-Za-z0-9_]/;
129
-
130
- // Case-insensitive whole-word substring match implemented without regular
131
- // expressions, so the needle is always treated as literal text. Equivalent
132
- // to /\bneedle\b/i but runs in linear time (indexOf) and is immune to
133
- // catastrophic backtracking.
134
- function hasWholeWord(text, needle) {
135
- let index = text.indexOf(needle);
136
- while (index !== -1) {
137
- const before = index === 0 ? '' : text[index - 1];
138
- const after =
139
- index + needle.length >= text.length ? '' : text[index + needle.length];
140
- if (!WORD_CHAR.test(before) && !WORD_CHAR.test(after)) return true;
141
- index = text.indexOf(needle, index + 1);
142
- }
143
- return false;
144
- }
145
105
 
146
- const FILTER_FIELDS = [
147
- 'nrr_legislation',
148
- 'nrr_biogeographical_region',
149
- 'measures_implemented',
150
- 'typology_of_measures',
151
- 'current_status',
152
- 'habitat_ecosystem_type',
153
- 'scale_of_planning',
154
- ];
155
-
156
- export function filterCasesByPath(cases, filterPath) {
157
- const path = filterPath?.trim();
158
- if (!path) return cases;
159
-
160
- return cases.filter((_case) =>
161
- String(_case.properties?.path || '').includes(path),
162
- );
163
- }
164
-
165
- export function filterCases(
166
- cases,
167
- activeFilters,
168
- caseStudiesIds,
169
- searchInput,
170
- filterPath,
171
- ) {
172
- return filterCasesByPath(cases, filterPath).filter((_case) => {
173
- const properties = _case.properties || {};
174
- const flagCase = caseStudiesIds
175
- ? caseStudiesIds.includes(properties.url?.split('/').pop())
176
- : true;
177
- const searchable = `${properties.title || ''} ${
178
- properties.description || ''
179
- }`;
180
- const flagSearch =
181
- !searchInput ||
182
- hasWholeWord(searchable.toLowerCase(), searchInput.toLowerCase());
183
-
184
- const matchesFilters = FILTER_FIELDS.every((filterName) => {
185
- const selected = activeFilters[filterName] || [];
186
- if (!selected.length) return true;
187
- const values =
188
- filterName === 'measures_implemented'
189
- ? asValues(properties.measures).map((item) => valueCode(item))
190
- : filterName === 'typology_of_measures'
191
- ? asValues(properties.typology_of_measures).map((item) =>
192
- valueCode(item),
193
- )
194
- : asValues(getCaseProperty(properties, filterName)).map((item) =>
195
- valueCode(item),
196
- );
197
- return selected.some((filter) => values.includes(String(filter)));
198
- });
199
-
200
- return flagCase && flagSearch && matchesFilters;
201
- });
202
- }
203
-
204
- export function getFilters(cases) {
205
- const filters = Object.fromEntries(FILTER_FIELDS.map((field) => [field, {}]));
206
-
207
- Object.values(cases || {}).forEach((_case) => {
208
- const properties = _case.properties || {};
209
- FILTER_FIELDS.forEach((field) => {
210
- const source =
211
- field === 'measures_implemented'
212
- ? properties.measures
213
- : field === 'typology_of_measures'
214
- ? properties.typology_of_measures
215
- : getCaseProperty(properties, field);
216
- asValues(source).forEach((item) => {
217
- const code = valueCode(item);
218
- if (!filters[field][code]) filters[field][code] = valueLabel(item);
219
- });
220
- });
106
+ return point;
221
107
  });
222
-
223
- return filters;
224
108
  }
@@ -2,191 +2,124 @@ import './mockJsdom';
2
2
  import '@testing-library/jest-dom';
3
3
  import {
4
4
  FALLBACK_STATUS_COLOR,
5
+ STATUS_COLORS,
6
+ centerAndResetMapZoom,
5
7
  getFeatures,
6
8
  getStatusColor,
7
- filterCases,
8
- getFilters,
9
+ scrollToElement,
10
+ visibleChipCount,
9
11
  } from './utils';
10
12
 
11
13
  describe('utils.js', () => {
12
- test('maps exact statuses and unknown statuses to colors', () => {
13
- expect(getStatusColor('planned')).toBe('#006bb8');
14
- expect(getStatusColor('ongoing')).toBe('#ff9933');
15
- expect(getStatusColor('completed')).toBe('#00a390');
14
+ test('maps exact statuses to design system colours, unknown to fallback', () => {
15
+ expect(getStatusColor('planned')).toBe('#006BB8');
16
+ expect(getStatusColor('ongoing')).toBe('#FF9933');
17
+ expect(getStatusColor('completed')).toBe('#007B6C');
16
18
  expect(getStatusColor('Planned')).toBe(FALLBACK_STATUS_COLOR);
17
19
  expect(getStatusColor('future')).toBe(FALLBACK_STATUS_COLOR);
20
+ expect(STATUS_COLORS.completed).toBe('#007B6C');
18
21
  });
19
22
 
20
- const mockCases = [
21
- {
22
- geometry: { coordinates: [0, 0] },
23
- properties: {
24
- title: 'test case study',
25
- image: '',
26
- nwrm_type: 'light',
27
- measures: [{ id: 'test-measure1', title: 'test measure 1' }],
28
- description: 'test',
29
- typology_of_measures: ['testsector'],
30
- path: '/test-case-study',
31
- url: 'localhost.com/test-case-study',
32
- },
33
- },
34
- {
35
- geometry: { coordinates: [0, 0] },
36
- properties: {
37
- title: 'case study 2',
38
- image: '',
39
- nwrm_type: 'light',
40
- measures: [{ id: 'test-measure1', title: 'test measure 1' }],
41
- description: 'test',
42
- typology_of_measures: ['testsector'],
43
- path: '/test-case-study',
44
- url: 'localhost.com/test-case-study',
45
- },
46
- },
47
- ];
48
-
49
- test('getFeatures', () => {
50
- const mockFeature = {
23
+ test('getFeatures carries the properties needed by map and list', () => {
24
+ const feature = {
51
25
  setId: jest.fn(),
52
26
  setProperties: jest.fn(),
27
+ props: null,
53
28
  };
29
+ feature.setProperties.mockImplementation((properties) => {
30
+ feature.props = properties;
31
+ });
54
32
 
55
33
  const ol = {
56
- ol: {
57
- Feature: jest.fn().mockImplementation(() => mockFeature),
58
- },
59
- geom: {
60
- Point: jest.fn().mockImplementation(() => ({})),
61
- },
62
- proj: {
63
- fromLonLat: jest.fn().mockReturnValue([0, 0]),
64
- },
34
+ ol: { Feature: jest.fn(() => feature) },
35
+ geom: { Point: jest.fn(() => ({})) },
36
+ proj: { fromLonLat: jest.fn(() => [1, 2]) },
65
37
  };
66
38
 
67
- expect(() => {
68
- getFeatures({ cases: mockCases, ol });
69
- }).not.toThrowError();
39
+ const features = getFeatures({
40
+ cases: [
41
+ {
42
+ geometry: { coordinates: [14.4, 50.1] },
43
+ properties: {
44
+ title: 'Peatlands',
45
+ path: '/case-peatlands',
46
+ image: 'image.jpg',
47
+ current_status: 'ongoing',
48
+ },
49
+ },
50
+ ],
51
+ ol,
52
+ });
53
+
54
+ expect(features).toHaveLength(1);
55
+ expect(feature.setId).toHaveBeenCalledWith(0);
56
+ expect(feature.props).toMatchObject({
57
+ title: 'Peatlands',
58
+ path: '/case-peatlands',
59
+ current_status: 'ongoing',
60
+ color: '#FF9933',
61
+ });
70
62
  });
71
63
 
72
- test('filterCases', () => {
73
- const mockActiveFilters = {
74
- measures_implemented: ['test measure 1'],
75
- typology_of_measures: ['testsector'],
76
- };
77
- const mockCaseStudiesIds = ['test-case-study'];
78
- const mockCasesFiltered = filterCases(
79
- mockCases,
80
- mockActiveFilters,
81
- mockCaseStudiesIds,
82
- 'test',
64
+ test('centerAndResetMapZoom returns to the European extent', () => {
65
+ const map = { getView: () => ({ animate: jest.fn() }) };
66
+ const ol = { proj: { transform: jest.fn(() => [111, 222]) } };
67
+
68
+ centerAndResetMapZoom({ map, ol });
69
+
70
+ expect(map.getView().animate).toBeDefined();
71
+ expect(ol.proj.transform).toHaveBeenCalledWith(
72
+ [8, 55],
73
+ 'EPSG:4326',
74
+ 'EPSG:3857',
83
75
  );
84
- expect(mockCasesFiltered).toStrictEqual([]);
85
76
  });
86
77
 
87
- test('filterCases treats search input as literal text (ReDoS-safe)', () => {
88
- const cases = [
89
- {
90
- geometry: { coordinates: [0, 0] },
91
- properties: {
92
- title: 'the river project (R1)',
93
- description: 'a aaa aaaa aaaaa b',
94
- url: '/the-river-project-r1',
95
- },
96
- },
97
- ];
98
- const noFilters = {
99
- nrr_legislation: [],
100
- nrr_biogeographical_region: [],
101
- measures_implemented: [],
102
- typology_of_measures: [],
103
- current_status: [],
104
- habitat_ecosystem_type: [],
105
- scale_of_planning: [],
106
- };
107
-
108
- // Malformed regex input must not throw (previously: SyntaxError)
109
- expect(() => filterCases(cases, noFilters, null, '(')).not.toThrow();
110
- // Regex metacharacters are matched literally, not interpreted
111
- expect(() => filterCases(cases, noFilters, null, '(a+)+$')).not.toThrow();
112
- expect(filterCases(cases, noFilters, null, '(a+)+$')).toHaveLength(0);
113
- expect(filterCases(cases, noFilters, null, '(r1')).toHaveLength(1);
114
- // Whole-word semantics are preserved
115
- expect(filterCases(cases, noFilters, null, 'rive')).toHaveLength(0);
116
- expect(filterCases(cases, noFilters, null, 'river')).toHaveLength(1);
117
- // Case-insensitive
118
- expect(filterCases(cases, noFilters, null, 'R1')).toHaveLength(1);
78
+ test('scrollToElement tolerates missing elements', () => {
79
+ expect(() => scrollToElement('does-not-exist')).not.toThrow();
119
80
  });
120
81
 
121
- test('filters new scalar and multi-value fields', () => {
122
- const cases = [
123
- {
124
- geometry: { coordinates: [0, 0] },
125
- properties: {
126
- title: 'planned river case',
127
- description: '',
128
- url: '/planned-river-case',
129
- current_status: 'planned',
130
- ecosystem_typology: ['River', 'Wetland'],
131
- nrr_legislation: [{ id: 'article-1', title: 'Article 1' }],
132
- nrr_biogeographical_region: [{ id: 'region-1', title: 'Alpine' }],
133
- scale_of_planning: 'Regional',
134
- },
135
- },
136
- {
137
- geometry: { coordinates: [0, 0] },
138
- properties: {
139
- title: 'future case',
140
- description: '',
141
- url: '/future-case',
142
- current_status: 'Future',
143
- ecosystem_typology: ['Forest'],
144
- nrr_legislation: ['article-2'],
145
- nrr_biogeographical_region: ['region-2'],
146
- scale_of_planning: 'National',
147
- },
148
- },
149
- ];
82
+ describe('visibleChipCount', () => {
83
+ const CHIP = 90;
84
+ const GAP = 8;
85
+ const WIDTH = 300;
86
+ // Three chips per row: x = 0, 98, 196 and the fourth wraps.
87
+ const layout = (rows) =>
88
+ rows.flatMap((aRow) =>
89
+ [0, 1, 2].map((column) => ({
90
+ row: aRow,
91
+ right: column * (CHIP + GAP) + CHIP,
92
+ })),
93
+ );
94
+ const options = { rows: 2, gap: GAP, moreWidth: 40, width: WIDTH };
150
95
 
151
- expect(
152
- filterCases(cases, {
153
- nrr_legislation: ['article-1'],
154
- nrr_biogeographical_region: ['region-1'],
155
- measures_implemented: [],
156
- typology_of_measures: [],
157
- current_status: ['planned'],
158
- habitat_ecosystem_type: ['River'],
159
- scale_of_planning: ['Regional'],
160
- }),
161
- ).toHaveLength(1);
162
- expect(getFilters(cases).nrr_legislation).toEqual({
163
- 'article-1': 'Article 1',
164
- 'article-2': 'article-2',
96
+ test('returns 0 for an empty list', () => {
97
+ expect(visibleChipCount([], options)).toBe(0);
165
98
  });
166
- expect(getFilters(cases).nrr_biogeographical_region).toEqual({
167
- 'region-1': 'Alpine',
168
- 'region-2': 'region-2',
99
+
100
+ test('keeps every chip when they all fit in two rows', () => {
101
+ expect(visibleChipCount(layout([0, 1]), options)).toBe(6);
102
+ expect(visibleChipCount(layout([0]), options)).toBe(3);
103
+ });
104
+
105
+ test('drops a chip so the trigger fits on the second row', () => {
106
+ // 6 chips fill two rows, but the trigger no longer fits next to the 6th.
107
+ expect(visibleChipCount(layout([0, 1, 2]), options)).toBe(5);
169
108
  });
170
- });
171
109
 
172
- test('getFilters', () => {
173
- const mockCasesObject = mockCases.reduce((acc, item, index) => {
174
- acc[index] = item;
175
- return acc;
176
- }, {});
177
- const mockFilters = getFilters(mockCasesObject);
178
- expect(mockFilters).toStrictEqual({
179
- measures_implemented: {
180
- 'test-measure1': 'test measure 1',
181
- },
182
- typology_of_measures: {
183
- testsector: 'testsector',
184
- },
185
- current_status: {},
186
- habitat_ecosystem_type: {},
187
- nrr_legislation: {},
188
- nrr_biogeographical_region: {},
189
- scale_of_planning: {},
110
+ test('keeps one chip rather than hiding everything behind a trigger', () => {
111
+ const chips = [
112
+ { row: 0, right: 300 },
113
+ { row: 1, right: 50 },
114
+ ];
115
+ expect(
116
+ visibleChipCount(chips, {
117
+ rows: 1,
118
+ gap: GAP,
119
+ moreWidth: 200,
120
+ width: 60,
121
+ }),
122
+ ).toBe(1);
190
123
  });
191
124
  });
192
125
  });