@eeacms/volto-bise-policy 1.3.4 → 1.3.6

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.
@@ -1,3 +1,50 @@
1
+ export const STATUS_COLORS = {
2
+ planned: '#006bb8',
3
+ ongoing: '#ff9933',
4
+ completed: '#00a390',
5
+ };
6
+
7
+ export const FALLBACK_STATUS_COLOR = '#6B7280';
8
+ 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
+ }
43
+
44
+ export function getStatusColor(status) {
45
+ return STATUS_COLORS[status] || FALLBACK_STATUS_COLOR;
46
+ }
47
+
1
48
  export function centerAndResetMapZoom({ map, ol }) {
2
49
  map.getView().animate({
3
50
  zoom: 4,
@@ -8,9 +55,17 @@ export function centerAndResetMapZoom({ map, ol }) {
8
55
 
9
56
  export function scrollToElement(elementId) {
10
57
  const element = document.getElementById(elementId);
11
- element.scrollIntoView({
12
- behavior: 'smooth',
13
- });
58
+ element?.scrollIntoView({ behavior: 'smooth' });
59
+ }
60
+
61
+ export function getSelectInteraction(map) {
62
+ const interactions = map?.getInteractions?.();
63
+ const interactionArray =
64
+ interactions?.getArray?.() || interactions?.array_ || [];
65
+
66
+ return interactionArray.find(
67
+ (interaction) => typeof interaction?.getFeatures === 'function',
68
+ );
14
69
  }
15
70
 
16
71
  export function getExtentOfFeatures({ features, ol }) {
@@ -31,24 +86,30 @@ export function getFeatures({ cases, ol }) {
31
86
  const Feature = ol.ol.Feature;
32
87
 
33
88
  return cases.map((c, index) => {
34
- const {
35
- geometry: { coordinates },
36
- } = c;
37
89
  const point = new Feature(
38
- new ol.geom.Point(ol.proj.fromLonLat(coordinates)),
90
+ new ol.geom.Point(ol.proj.fromLonLat(c.geometry.coordinates)),
39
91
  );
92
+ const properties = c.properties || {};
93
+ const status = properties.current_status;
40
94
  point.setId(index);
41
95
  point.setProperties(
42
96
  {
43
- title: c.properties.title,
44
- image: c.properties.image,
45
- nwrm_type: c.properties.nwrm_type,
46
- measures_implemented: c.properties.measures,
47
- typology_of_measures: c.properties.typology_of_measures,
48
- description: c.properties.description,
49
- index: index,
50
- path: c.properties.path,
51
- color: c.properties.nwrm_type === 'Light' ? '#50B0A4' : '#0083E0',
97
+ title: properties.title,
98
+ 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_article: properties.nrr_article,
108
+ scale_of_planning: properties.scale_of_planning,
109
+ description: properties.description,
110
+ index,
111
+ path: properties.path,
112
+ color: getStatusColor(status),
52
113
  },
53
114
  false,
54
115
  );
@@ -56,89 +117,106 @@ export function getFeatures({ cases, ol }) {
56
117
  });
57
118
  }
58
119
 
59
- export function filterCases(cases, activeFilters, caseStudiesIds, searchInput) {
60
- const data = cases.filter((_case) => {
61
- let flag_searchInput = false;
62
- let flag_implemented = false;
63
- let flag_typology_of_measures = false;
64
- let flag_case = caseStudiesIds
65
- ? caseStudiesIds.includes(_case.properties.url.split('/').pop())
66
- : true;
67
-
68
- if (!searchInput) {
69
- flag_searchInput = true;
70
- } else {
71
- if (_case.properties.title.toLowerCase().match(searchInput)) {
72
- flag_searchInput = true;
73
- } else if (
74
- _case.properties.description.toLowerCase().match(searchInput)
75
- ) {
76
- flag_searchInput = true;
77
- }
78
- }
79
-
80
- if (!activeFilters.measures_implemented.length) {
81
- flag_implemented = true;
82
- } else {
83
- let measures_implemented = _case.properties.measures?.map((item) => {
84
- return item['id'].toString();
85
- });
120
+ // Escape a string so it can be embedded in a RegExp pattern as a literal.
121
+ // Prevents user-provided input from being interpreted as regex syntax
122
+ // (avoids ReDoS via crafted patterns and SyntaxError from malformed ones).
123
+ export function escapeRegExp(str) {
124
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
125
+ }
86
126
 
87
- activeFilters.measures_implemented.forEach((filter) => {
88
- if (measures_implemented?.includes(filter)) flag_implemented = true;
89
- });
90
- }
91
-
92
- if (!activeFilters.typology_of_measures.length) {
93
- flag_typology_of_measures = true;
94
- } else {
95
- let typology_of_measures = _case.properties.typology_of_measures?.map(
96
- (item) => {
97
- return item.toString();
98
- },
99
- );
100
-
101
- activeFilters.typology_of_measures.forEach((filter) => {
102
- if (typology_of_measures?.includes(filter))
103
- flag_typology_of_measures = true;
104
- });
105
- }
106
-
107
- return flag_case &&
108
- flag_implemented &&
109
- flag_typology_of_measures &&
110
- flag_searchInput
111
- ? _case
112
- : false;
113
- });
127
+ const WORD_CHAR = /[A-Za-z0-9_]/;
128
+
129
+ // Case-insensitive whole-word substring match implemented without regular
130
+ // expressions, so the needle is always treated as literal text. Equivalent
131
+ // to /\bneedle\b/i but runs in linear time (indexOf) and is immune to
132
+ // catastrophic backtracking.
133
+ function hasWholeWord(text, needle) {
134
+ let index = text.indexOf(needle);
135
+ while (index !== -1) {
136
+ const before = index === 0 ? '' : text[index - 1];
137
+ const after =
138
+ index + needle.length >= text.length ? '' : text[index + needle.length];
139
+ if (!WORD_CHAR.test(before) && !WORD_CHAR.test(after)) return true;
140
+ index = text.indexOf(needle, index + 1);
141
+ }
142
+ return false;
143
+ }
114
144
 
115
- return data;
145
+ const FILTER_FIELDS = [
146
+ 'measures_implemented',
147
+ 'typology_of_measures',
148
+ 'current_status',
149
+ 'habitat_ecosystem_type',
150
+ 'nrr_article',
151
+ 'scale_of_planning',
152
+ ];
153
+
154
+ export function filterCasesByPath(cases, filterPath) {
155
+ const path = filterPath?.trim();
156
+ if (!path) return cases;
157
+
158
+ return cases.filter((_case) =>
159
+ String(_case.properties?.path || '').includes(path),
160
+ );
116
161
  }
117
162
 
118
- export function getFilters(cases) {
119
- let _filters = {
120
- measures_implemented: {},
121
- typology_of_measures: {},
122
- };
123
-
124
- for (let key of Object.keys(cases)) {
125
- const _case = cases[key];
126
- let measures_implemented = _case.properties.measures;
127
- measures_implemented.map((item) => {
128
- if (!_filters.measures_implemented.hasOwnProperty(item['id'])) {
129
- _filters.measures_implemented[item['id']] = item['title'];
130
- }
131
- return [];
163
+ export function filterCases(
164
+ cases,
165
+ activeFilters,
166
+ caseStudiesIds,
167
+ searchInput,
168
+ filterPath,
169
+ ) {
170
+ return filterCasesByPath(cases, filterPath).filter((_case) => {
171
+ const properties = _case.properties || {};
172
+ const flagCase = caseStudiesIds
173
+ ? caseStudiesIds.includes(properties.url?.split('/').pop())
174
+ : true;
175
+ const searchable = `${properties.title || ''} ${
176
+ properties.description || ''
177
+ }`;
178
+ const flagSearch =
179
+ !searchInput ||
180
+ hasWholeWord(searchable.toLowerCase(), searchInput.toLowerCase());
181
+
182
+ const matchesFilters = FILTER_FIELDS.every((filterName) => {
183
+ const selected = activeFilters[filterName] || [];
184
+ if (!selected.length) return true;
185
+ const values =
186
+ filterName === 'measures_implemented'
187
+ ? asValues(properties.measures).map((item) => valueCode(item))
188
+ : filterName === 'typology_of_measures'
189
+ ? asValues(properties.typology_of_measures).map((item) =>
190
+ valueCode(item),
191
+ )
192
+ : asValues(getCaseProperty(properties, filterName)).map((item) =>
193
+ valueCode(item),
194
+ );
195
+ return selected.some((filter) => values.includes(String(filter)));
132
196
  });
133
197
 
134
- let typology_of_measures = _case.properties.typology_of_measures;
135
- typology_of_measures.map((item) => {
136
- if (!_filters.typology_of_measures.hasOwnProperty(item)) {
137
- _filters.typology_of_measures[item] = item;
138
- }
139
- return [];
198
+ return flagCase && flagSearch && matchesFilters;
199
+ });
200
+ }
201
+
202
+ export function getFilters(cases) {
203
+ const filters = Object.fromEntries(FILTER_FIELDS.map((field) => [field, {}]));
204
+
205
+ Object.values(cases || {}).forEach((_case) => {
206
+ const properties = _case.properties || {};
207
+ FILTER_FIELDS.forEach((field) => {
208
+ const source =
209
+ field === 'measures_implemented'
210
+ ? properties.measures
211
+ : field === 'typology_of_measures'
212
+ ? properties.typology_of_measures
213
+ : getCaseProperty(properties, field);
214
+ asValues(source).forEach((item) => {
215
+ const code = valueCode(item);
216
+ if (!filters[field][code]) filters[field][code] = valueLabel(item);
217
+ });
140
218
  });
141
- }
219
+ });
142
220
 
143
- return _filters;
221
+ return filters;
144
222
  }
@@ -1,8 +1,22 @@
1
1
  import './mockJsdom';
2
2
  import '@testing-library/jest-dom';
3
- import { getFeatures, filterCases, getFilters } from './utils';
3
+ import {
4
+ FALLBACK_STATUS_COLOR,
5
+ getFeatures,
6
+ getStatusColor,
7
+ filterCases,
8
+ getFilters,
9
+ } from './utils';
4
10
 
5
11
  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');
16
+ expect(getStatusColor('Planned')).toBe(FALLBACK_STATUS_COLOR);
17
+ expect(getStatusColor('future')).toBe(FALLBACK_STATUS_COLOR);
18
+ });
19
+
6
20
  const mockCases = [
7
21
  {
8
22
  geometry: { coordinates: [0, 0] },
@@ -70,6 +84,83 @@ describe('utils.js', () => {
70
84
  expect(mockCasesFiltered).toStrictEqual([]);
71
85
  });
72
86
 
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
+ measures_implemented: [],
100
+ typology_of_measures: [],
101
+ current_status: [],
102
+ habitat_ecosystem_type: [],
103
+ nrr_article: [],
104
+ scale_of_planning: [],
105
+ };
106
+
107
+ // Malformed regex input must not throw (previously: SyntaxError)
108
+ expect(() => filterCases(cases, noFilters, null, '(')).not.toThrow();
109
+ // Regex metacharacters are matched literally, not interpreted
110
+ expect(() => filterCases(cases, noFilters, null, '(a+)+$')).not.toThrow();
111
+ expect(filterCases(cases, noFilters, null, '(a+)+$')).toHaveLength(0);
112
+ expect(filterCases(cases, noFilters, null, '(r1')).toHaveLength(1);
113
+ // Whole-word semantics are preserved
114
+ expect(filterCases(cases, noFilters, null, 'rive')).toHaveLength(0);
115
+ expect(filterCases(cases, noFilters, null, 'river')).toHaveLength(1);
116
+ // Case-insensitive
117
+ expect(filterCases(cases, noFilters, null, 'R1')).toHaveLength(1);
118
+ });
119
+
120
+ test('filters new scalar and multi-value fields', () => {
121
+ const cases = [
122
+ {
123
+ geometry: { coordinates: [0, 0] },
124
+ properties: {
125
+ title: 'planned river case',
126
+ description: '',
127
+ url: '/planned-river-case',
128
+ current_status: 'planned',
129
+ ecosystem_typology: ['River', 'Wetland'],
130
+ nrr_article: [{ id: 'article-1', title: 'Article 1' }],
131
+ scale_of_planning: 'Regional',
132
+ },
133
+ },
134
+ {
135
+ geometry: { coordinates: [0, 0] },
136
+ properties: {
137
+ title: 'future case',
138
+ description: '',
139
+ url: '/future-case',
140
+ current_status: 'Future',
141
+ ecosystem_typology: ['Forest'],
142
+ nrr_article: ['article-2'],
143
+ scale_of_planning: 'National',
144
+ },
145
+ },
146
+ ];
147
+
148
+ expect(
149
+ filterCases(cases, {
150
+ measures_implemented: [],
151
+ typology_of_measures: [],
152
+ current_status: ['planned'],
153
+ habitat_ecosystem_type: ['River'],
154
+ nrr_article: ['article-1'],
155
+ scale_of_planning: ['Regional'],
156
+ }),
157
+ ).toHaveLength(1);
158
+ expect(getFilters(cases).nrr_article).toEqual({
159
+ 'article-1': 'Article 1',
160
+ 'article-2': 'article-2',
161
+ });
162
+ });
163
+
73
164
  test('getFilters', () => {
74
165
  const mockCasesObject = mockCases.reduce((acc, item, index) => {
75
166
  acc[index] = item;
@@ -83,6 +174,10 @@ describe('utils.js', () => {
83
174
  typology_of_measures: {
84
175
  testsector: 'testsector',
85
176
  },
177
+ current_status: {},
178
+ habitat_ecosystem_type: {},
179
+ nrr_article: {},
180
+ scale_of_planning: {},
86
181
  });
87
182
  });
88
183
  });
@@ -22,23 +22,40 @@
22
22
  box-shadow: none;
23
23
  }
24
24
 
25
- .contenttype-marine_eunis_habitat .help.slate_wysiwyg p.help {
26
- white-space: break-spaces;
25
+ .is-adding-contenttype-marine_eunis_habitat {
26
+ .slate_wysiwyg_box {
27
+ background-color: white;
28
+ padding: 5px;
29
+ border: 1px solid #c7d5d8;
30
+ }
27
31
  }
28
32
 
29
- .contenttype-marine_eunis_habitat .bise-objectlist-widget p.help {
30
- white-space: break-spaces;
33
+ .contenttype-marine_eunis_habitat,
34
+ .is-adding-contenttype-marine_eunis_habitat {
35
+ .help.slate_wysiwyg p.help {
36
+ white-space: break-spaces;
37
+ }
31
38
  }
32
39
 
33
- .contenttype-marine_eunis_habitat .eea.banner .gradient {
34
- background: none;
40
+ .is-adding-contenttype-marine_eunis_habitat,
41
+ .contenttype-marine_eunis_habitat {
42
+ .bise-objectlist-widget p.help {
43
+ white-space: break-spaces;
44
+ }
45
+ }
46
+
47
+ .is-adding-contenttype-marine_eunis_habitat,
48
+ .contenttype-marine_eunis_habitat {
49
+ .eea.banner .gradient {
50
+ background: none;
51
+ }
35
52
  }
36
53
 
37
54
  .contenttype-nrr_measure .eea.banner .gradient {
38
55
  background: none;
39
56
  }
40
57
 
41
- .bise-objectlist-widget .inline.field > .ui.grid {
58
+ .bise-objectlist-widget .inline.field>.ui.grid {
42
59
  margin: 0;
43
60
  }
44
61
 
@@ -46,7 +63,6 @@
46
63
  color: var(--text-color-secondary, #289588);
47
64
  }
48
65
 
49
- .contenttype-nrr_measure
50
- .top.aligned.four.wide.computer.twelve.wide.mobile.four.wide.column {
66
+ .contenttype-nrr_measure .top.aligned.four.wide.computer.twelve.wide.mobile.four.wide.column {
51
67
  background-color: #f9f9f9;
52
- }
68
+ }