@eeacms/volto-cca-policy 1.0.2 → 1.0.4

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 (78) hide show
  1. package/CHANGELOG.md +259 -3
  2. package/jest-addon.config.js +4 -2
  3. package/locales/bg/LC_MESSAGES/volto.po +576 -0
  4. package/locales/cs/LC_MESSAGES/volto.po +576 -0
  5. package/locales/da/LC_MESSAGES/volto.po +576 -0
  6. package/locales/de/LC_MESSAGES/volto.po +576 -0
  7. package/locales/el/LC_MESSAGES/volto.po +576 -0
  8. package/locales/en/LC_MESSAGES/volto.po +576 -0
  9. package/locales/es/LC_MESSAGES/volto.po +576 -0
  10. package/locales/et/LC_MESSAGES/volto.po +576 -0
  11. package/locales/fi/LC_MESSAGES/volto.po +576 -0
  12. package/locales/fr/LC_MESSAGES/volto.po +576 -0
  13. package/locales/ga/LC_MESSAGES/volto.po +576 -0
  14. package/locales/hr/LC_MESSAGES/volto.po +576 -0
  15. package/locales/hu/LC_MESSAGES/volto.po +576 -0
  16. package/locales/is/LC_MESSAGES/volto.po +576 -0
  17. package/locales/it/LC_MESSAGES/volto.po +576 -0
  18. package/locales/lt/LC_MESSAGES/volto.po +576 -0
  19. package/locales/lv/LC_MESSAGES/volto.po +576 -0
  20. package/locales/mt/LC_MESSAGES/volto.po +576 -0
  21. package/locales/nl/LC_MESSAGES/volto.po +576 -0
  22. package/locales/nn/LC_MESSAGES/volto.po +576 -0
  23. package/locales/pl/LC_MESSAGES/volto.po +576 -0
  24. package/locales/pt/LC_MESSAGES/volto.po +576 -0
  25. package/locales/ro/LC_MESSAGES/volto.po +576 -0
  26. package/locales/sk/LC_MESSAGES/volto.po +576 -0
  27. package/locales/sl/LC_MESSAGES/volto.po +576 -0
  28. package/locales/sv/LC_MESSAGES/volto.po +576 -0
  29. package/locales/tr/LC_MESSAGES/volto.po +576 -0
  30. package/locales/volto.pot +577 -1
  31. package/package.json +1 -1
  32. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem.jsx +224 -0
  33. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem.test.jsx +173 -0
  34. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueContentView.jsx +189 -0
  35. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueMapView.jsx +272 -0
  36. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueMapView.test.jsx +160 -0
  37. package/src/components/Search/NavigatorCatalogue/utils.js +157 -0
  38. package/src/components/Search/NavigatorCatalogue/utils.test.js +132 -0
  39. package/src/components/Search/NavigatorGuide/NavigatorGuideContentView.jsx +409 -0
  40. package/src/components/Search/NavigatorGuide/NavigatorGuideLayout.jsx +8 -0
  41. package/src/components/index.js +2 -0
  42. package/src/components/theme/CompareTools/CompareToolsPanel.jsx +151 -0
  43. package/src/components/theme/CompareTools/CompareToolsPanel.test.jsx +96 -0
  44. package/src/components/theme/CompareTools/CompareToolsView.jsx +475 -0
  45. package/src/components/theme/CompareTools/CompareToolsView.test.jsx +110 -0
  46. package/src/components/theme/CompareTools/utils.js +111 -0
  47. package/src/components/theme/CompareTools/utils.test.jsx +217 -0
  48. package/src/components/theme/Views/ExtendedToolView.jsx +201 -201
  49. package/src/components/theme/Views/ExtendedToolView.test.jsx +420 -58
  50. package/src/constants.js +0 -48
  51. package/src/customizations/volto/reducers/breadcrumbs/breadcrumbs.js +1 -1
  52. package/src/helpers/Utils.jsx +29 -0
  53. package/src/helpers/index.js +3 -0
  54. package/src/index.js +28 -2
  55. package/src/search/index.js +6 -0
  56. package/src/search/navigator_catalogue/config.js +89 -0
  57. package/src/search/navigator_catalogue/facets.js +86 -0
  58. package/src/search/navigator_catalogue/views.js +28 -0
  59. package/src/search/navigator_guide/config.js +70 -0
  60. package/src/search/navigator_guide/facets.js +31 -0
  61. package/src/search/navigator_guide/guideSteps.js +93 -0
  62. package/src/slate-styles.less +4 -0
  63. package/src/state.js +1 -0
  64. package/theme/elements/button.overrides +0 -11
  65. package/theme/elements/label.overrides +15 -0
  66. package/theme/globals/blocks.less +3 -2
  67. package/theme/globals/navigator.less +1090 -0
  68. package/theme/globals/site.overrides +5 -0
  69. package/theme/tokens/colors.less +2 -0
  70. package/artifacts/BLOCKS.md +0 -167
  71. package/artifacts/link-integrity-workflow/README.md +0 -76
  72. package/artifacts/link-integrity-workflow/index.md +0 -33
  73. package/artifacts/link-integrity-workflow/link-integrity-block-fields-report.md +0 -132
  74. package/artifacts/link-integrity-workflow/link-integrity-blocks-report.md +0 -52
  75. package/artifacts/link-integrity-workflow/understanding-link-integrity.md +0 -143
  76. package/artifacts/link-integrity-workflow/volto-block-link-analysis.md +0 -63
  77. package/artifacts/link-integrity-workflow/volto-block-link-discovery.md +0 -60
  78. package/artifacts/test-fixes/test-fixes-specification.md +0 -267
@@ -0,0 +1,272 @@
1
+ import React from 'react';
2
+ import { compose } from 'redux';
3
+ import { Icon, Button } from 'semantic-ui-react';
4
+ import { defineMessages, useIntl } from 'react-intl';
5
+ import { useSearchContext, useViews } from '@eeacms/search/lib/hocs';
6
+ import { withOpenLayers } from '@eeacms/volto-openlayers-map';
7
+ import { useMapContext } from '@eeacms/volto-openlayers-map/api';
8
+ import { Map, Layer, Layers, Controls } from '@eeacms/volto-openlayers-map/api';
9
+ import { withGeoJsonData } from '@eeacms/volto-cca-policy/helpers/country_map/hocs';
10
+ import { euCountryNames } from '@eeacms/volto-cca-policy/helpers/country_map/countryMap';
11
+ import { clientOnly } from '@eeacms/volto-cca-policy/helpers';
12
+ import {
13
+ withResponsiveContainer,
14
+ withVisibilitySensor,
15
+ } from '@eeacms/volto-cca-policy/hocs';
16
+ import {
17
+ getColorForCount,
18
+ normalizeCountryName,
19
+ buildCountryCounts,
20
+ mapLegendItems,
21
+ } from './utils';
22
+
23
+ const messages = defineMessages({
24
+ toolAvailability: {
25
+ id: '{count, plural, =0 {No tools available} one {# tool available} other {# tools available}}',
26
+ defaultMessage:
27
+ '{count, plural, =0 {No tools available} one {# tool available} other {# tools available}}',
28
+ },
29
+ exploreTools: {
30
+ id: 'Explore tools',
31
+ defaultMessage: 'Explore tools',
32
+ },
33
+ toolsPerCountry: {
34
+ id: 'Tools per country',
35
+ defaultMessage: 'Tools per country',
36
+ },
37
+ close: {
38
+ id: 'Close',
39
+ defaultMessage: 'Close',
40
+ },
41
+ });
42
+
43
+ /* istanbul ignore next */
44
+ const CountryClickInteractions = ({ ol, countryCounts, onExploreTools }) => {
45
+ const { map } = useMapContext();
46
+ const intl = useIntl();
47
+ const [tooltipData, setTooltipData] = React.useState(null);
48
+
49
+ React.useEffect(() => {
50
+ if (!map) return;
51
+
52
+ const handleClick = (evt) => {
53
+ if (evt.dragging) return;
54
+ const pixel = map.getEventPixel(evt.originalEvent);
55
+ const feature = map.forEachFeatureAtPixel(pixel, (f) => f);
56
+ if (!feature) {
57
+ setTooltipData(null);
58
+ return;
59
+ }
60
+
61
+ const name = feature.get('na');
62
+ const normalized = normalizeCountryName(name);
63
+ const count = countryCounts[normalized] || 0;
64
+
65
+ setTooltipData({
66
+ name: normalized,
67
+ count,
68
+ pixel: [evt.originalEvent.clientX, evt.originalEvent.clientY],
69
+ mapRect: map.getTargetElement().getBoundingClientRect(),
70
+ });
71
+ };
72
+
73
+ const handlePointerMove = (evt) => {
74
+ if (evt.dragging) return;
75
+ const pixel = map.getEventPixel(evt.originalEvent);
76
+ const feature = map.forEachFeatureAtPixel(pixel, (f) => f);
77
+ map.getTargetElement().style.cursor = feature ? 'pointer' : '';
78
+ };
79
+
80
+ map.on('singleclick', handleClick);
81
+ map.on('pointermove', handlePointerMove);
82
+
83
+ return () => {
84
+ map.un('singleclick', handleClick);
85
+ map.un('pointermove', handlePointerMove);
86
+ };
87
+ }, [map, countryCounts]);
88
+
89
+ // Render tooltip as a portal-like overlay
90
+ return (
91
+ <>
92
+ {tooltipData && (
93
+ <div
94
+ className="navigator-catalogue-map-tooltip"
95
+ style={{
96
+ left: `${tooltipData.pixel[0] - tooltipData.mapRect.left + 15}px`,
97
+ top: `${tooltipData.pixel[1] - tooltipData.mapRect.top - 10}px`,
98
+ }}
99
+ >
100
+ <div className="tooltip-header">
101
+ <strong>{tooltipData.name}</strong>
102
+ </div>
103
+ <div className="tooltip-body">
104
+ <span className="tooltip-count">
105
+ {intl.formatMessage(messages.toolAvailability, {
106
+ count: tooltipData.count,
107
+ })}
108
+ </span>
109
+ </div>
110
+ {tooltipData.count > 0 && (
111
+ <div className="tooltip-actions">
112
+ <Button
113
+ labelPosition="right"
114
+ className="primary small icon"
115
+ onClick={() => onExploreTools(tooltipData.name)}
116
+ >
117
+ {intl.formatMessage(messages.exploreTools)}
118
+ <Icon className="ri-arrow-right-line" />
119
+ </Button>
120
+ </div>
121
+ )}
122
+ <Button
123
+ className="tooltip-close"
124
+ onClick={() => setTooltipData(null)}
125
+ aria-label={intl.formatMessage(messages.close)}
126
+ >
127
+ <Icon className="ri-close-line" aria-hidden="true" />
128
+ </Button>
129
+ </div>
130
+ )}
131
+ </>
132
+ );
133
+ };
134
+
135
+ /* istanbul ignore next */
136
+ const NavigatorCatalogueMapViewInner = (props) => {
137
+ const { geofeatures, projection, ol } = props;
138
+ const intl = useIntl();
139
+ const searchContext = useSearchContext();
140
+ const views = useViews();
141
+
142
+ // Handler: replace country filter + switch to list view
143
+ const handleExploreTools = React.useCallback(
144
+ (countryName) => {
145
+ const field = 'cca_geographic_countries.keyword';
146
+ // Clear existing country filter, then set the new one
147
+ searchContext?.removeFilter(field);
148
+ searchContext?.addFilter(field, countryName, 'any');
149
+ // Switch to listing view
150
+ views.setActiveViewId('listing');
151
+ },
152
+ [searchContext, views],
153
+ );
154
+
155
+ // Build lookup: countryName -> count
156
+ const countryCounts = React.useMemo(
157
+ () => buildCountryCounts(searchContext?.facets),
158
+ [searchContext?.facets],
159
+ );
160
+
161
+ const [tileWMSSources, setTileWMSSources] = React.useState(null);
162
+ const [euCountriesSource, setEuCountriesSource] = React.useState(null);
163
+
164
+ React.useEffect(() => {
165
+ // Tile layer
166
+ setTileWMSSources([
167
+ new ol.source.TileWMS({
168
+ url: 'https://gisco-services.ec.europa.eu/maps/service',
169
+ params: {
170
+ LAYERS: 'OSMBrightBackground',
171
+ TILED: true,
172
+ },
173
+ serverType: 'geoserver',
174
+ transition: 0,
175
+ }),
176
+ ]);
177
+
178
+ // Country vector layer with per-feature styles
179
+ const features = new ol.format.GeoJSON().readFeatures(geofeatures, {
180
+ dataProjection: 'EPSG:4326',
181
+ featureProjection: 'EPSG:3857',
182
+ });
183
+ const filtered = features.filter((f) =>
184
+ euCountryNames.includes(f.get('na')),
185
+ );
186
+
187
+ // Apply style to each feature
188
+ filtered.forEach((feature) => {
189
+ const name = feature.get('na');
190
+ const normalized = normalizeCountryName(name);
191
+ const count = countryCounts[normalized] || 0;
192
+ const fillColor = getColorForCount(count);
193
+
194
+ feature.setStyle(
195
+ new ol.style.Style({
196
+ stroke: new ol.style.Stroke({
197
+ color: '#333333',
198
+ width: 1,
199
+ }),
200
+ fill: new ol.style.Fill({
201
+ color: fillColor,
202
+ }),
203
+ }),
204
+ );
205
+ });
206
+
207
+ setEuCountriesSource(new ol.source.Vector({ features: filtered }));
208
+ }, [geofeatures, ol, countryCounts]);
209
+
210
+ if (!tileWMSSources || !euCountriesSource) {
211
+ return (
212
+ <div className="navigator-catalogue-map-loading">
213
+ <i className="icon loading" />
214
+ Loading map...
215
+ </div>
216
+ );
217
+ }
218
+
219
+ return (
220
+ <div className="navigator-catalogue-map">
221
+ <div className="navigator-catalogue-map-canvas">
222
+ <Map
223
+ view={{
224
+ center: ol.proj.fromLonLat([14.5, 57], projection),
225
+ projection,
226
+ showFullExtent: true,
227
+ zoom: 3.6,
228
+ }}
229
+ pixelRatio={1}
230
+ >
231
+ <Controls attribution={false} />
232
+ <Layers>
233
+ <CountryClickInteractions
234
+ ol={ol}
235
+ countryCounts={countryCounts}
236
+ onExploreTools={handleExploreTools}
237
+ />
238
+ <Layer.Vector source={euCountriesSource} zIndex={2} />
239
+ <Layer.Tile source={tileWMSSources[0]} zIndex={0} />
240
+ </Layers>
241
+ </Map>
242
+ </div>
243
+
244
+ <aside
245
+ className="navigator-catalogue-map-legend"
246
+ aria-label={intl.formatMessage(messages.toolsPerCountry)}
247
+ >
248
+ <h4 className="legend-title">
249
+ {intl.formatMessage(messages.toolsPerCountry)}
250
+ </h4>
251
+ {mapLegendItems.map((item) => (
252
+ <div key={item.label} className="legend-item">
253
+ <span
254
+ className="legend-color"
255
+ style={{ backgroundColor: item.color }}
256
+ />
257
+ <span>{item.label}</span>
258
+ </div>
259
+ ))}
260
+ </aside>
261
+ </div>
262
+ );
263
+ };
264
+
265
+ /* istanbul ignore next */
266
+ export default compose(
267
+ clientOnly,
268
+ withGeoJsonData(true),
269
+ withResponsiveContainer('navigatorCatalogueMap'),
270
+ withVisibilitySensor(),
271
+ withOpenLayers,
272
+ )(NavigatorCatalogueMapViewInner);
@@ -0,0 +1,160 @@
1
+ import '@testing-library/jest-dom';
2
+ import {
3
+ getColorForCount,
4
+ normalizeCountryName,
5
+ buildCountryCounts,
6
+ mapLegendItems,
7
+ } from './utils';
8
+
9
+ describe('getColorForCount', () => {
10
+ it('returns darkest green for 10+ tools', () => {
11
+ expect(getColorForCount(10)).toBe('#0a5c4e');
12
+ expect(getColorForCount(15)).toBe('#0a5c4e');
13
+ expect(getColorForCount(100)).toBe('#0a5c4e');
14
+ });
15
+
16
+ it('returns dark green for 7-9 tools', () => {
17
+ expect(getColorForCount(7)).toBe('#0d7a68');
18
+ expect(getColorForCount(8)).toBe('#0d7a68');
19
+ expect(getColorForCount(9)).toBe('#0d7a68');
20
+ });
21
+
22
+ it('returns base accent green for 4-6 tools', () => {
23
+ expect(getColorForCount(4)).toBe('#289588');
24
+ expect(getColorForCount(5)).toBe('#289588');
25
+ expect(getColorForCount(6)).toBe('#289588');
26
+ });
27
+
28
+ it('returns light green for 1-3 tools', () => {
29
+ expect(getColorForCount(1)).toBe('#6fc4b8');
30
+ expect(getColorForCount(2)).toBe('#6fc4b8');
31
+ expect(getColorForCount(3)).toBe('#6fc4b8');
32
+ });
33
+
34
+ it('returns lightest gray-green for 0 or falsy counts', () => {
35
+ expect(getColorForCount(0)).toBe('#e8eded');
36
+ expect(getColorForCount(null)).toBe('#e8eded');
37
+ expect(getColorForCount(undefined)).toBe('#e8eded');
38
+ });
39
+ });
40
+
41
+ describe('normalizeCountryName', () => {
42
+ it('normalizes Türkiye to Turkey', () => {
43
+ expect(normalizeCountryName('Türkiye')).toBe('Turkey');
44
+ });
45
+
46
+ it('normalizes Czech Rep. to Czechia', () => {
47
+ expect(normalizeCountryName('Czech Rep.')).toBe('Czechia');
48
+ });
49
+
50
+ it('normalizes Bosnia and Herz. to Bosnia and Herzegovina', () => {
51
+ expect(normalizeCountryName('Bosnia and Herz.')).toBe(
52
+ 'Bosnia and Herzegovina',
53
+ );
54
+ });
55
+
56
+ it('returns unchanged name for non-special countries', () => {
57
+ expect(normalizeCountryName('Germany')).toBe('Germany');
58
+ expect(normalizeCountryName('Italy')).toBe('Italy');
59
+ expect(normalizeCountryName('France')).toBe('France');
60
+ expect(normalizeCountryName('Spain')).toBe('Spain');
61
+ });
62
+
63
+ it('handles null and undefined', () => {
64
+ expect(normalizeCountryName(null)).toBeNull();
65
+ expect(normalizeCountryName(undefined)).toBeUndefined();
66
+ });
67
+ });
68
+
69
+ describe('buildCountryCounts', () => {
70
+ it('builds correct lookup from facet data array', () => {
71
+ const facets = {
72
+ 'cca_geographic_countries.keyword': [
73
+ {
74
+ field: 'cca_geographic_countries.keyword',
75
+ type: 'value',
76
+ data: [
77
+ { value: 'Germany', count: 5 },
78
+ { value: 'Italy', count: 3 },
79
+ { value: 'France', count: 1 },
80
+ ],
81
+ },
82
+ ],
83
+ };
84
+
85
+ const counts = buildCountryCounts(facets);
86
+ expect(counts).toEqual({
87
+ Germany: 5,
88
+ Italy: 3,
89
+ France: 1,
90
+ });
91
+ });
92
+
93
+ it('handles empty facet data', () => {
94
+ const facets = {
95
+ 'cca_geographic_countries.keyword': [
96
+ { field: 'cca_geographic_countries.keyword', data: [] },
97
+ ],
98
+ };
99
+ const counts = buildCountryCounts(facets);
100
+ expect(Object.keys(counts)).toHaveLength(0);
101
+ });
102
+
103
+ it('handles missing facet gracefully', () => {
104
+ const facets = {};
105
+ const counts = buildCountryCounts(facets);
106
+ expect(counts).toEqual({});
107
+ });
108
+
109
+ it('handles undefined facets gracefully', () => {
110
+ const counts = buildCountryCounts(undefined);
111
+ expect(counts).toEqual({});
112
+ });
113
+
114
+ it('handles null facets gracefully', () => {
115
+ const counts = buildCountryCounts(null);
116
+ expect(counts).toEqual({});
117
+ });
118
+
119
+ it('handles non-array facet value', () => {
120
+ const facets = {
121
+ 'cca_geographic_countries.keyword': {
122
+ data: [{ value: 'Germany', count: 5 }],
123
+ },
124
+ };
125
+ const counts = buildCountryCounts(facets);
126
+ expect(counts).toEqual({ Germany: 5 });
127
+ });
128
+
129
+ it('handles missing count values', () => {
130
+ const facets = {
131
+ 'cca_geographic_countries.keyword': [{ data: [{ value: 'Germany' }] }],
132
+ };
133
+ const counts = buildCountryCounts(facets);
134
+ expect(counts.Germany).toBeUndefined();
135
+ });
136
+ });
137
+
138
+ describe('mapLegendItems', () => {
139
+ it('has 5 legend items', () => {
140
+ expect(mapLegendItems).toHaveLength(5);
141
+ });
142
+
143
+ it('colors match getColorForCount scale', () => {
144
+ expect(mapLegendItems[0].color).toBe(getColorForCount(10));
145
+ expect(mapLegendItems[1].color).toBe(getColorForCount(7));
146
+ expect(mapLegendItems[2].color).toBe(getColorForCount(4));
147
+ expect(mapLegendItems[3].color).toBe(getColorForCount(1));
148
+ expect(mapLegendItems[4].color).toBe(getColorForCount(0));
149
+ });
150
+
151
+ it('has correct labels', () => {
152
+ expect(mapLegendItems.map((item) => item.label)).toEqual([
153
+ '10+',
154
+ '7–9',
155
+ '4–6',
156
+ '1–3',
157
+ 'None',
158
+ ]);
159
+ });
160
+ });
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Color scale for tool counts (EEA green tones)
3
+ * Used by the map view to color-code countries
4
+ */
5
+ export const getColorForCount = (count) => {
6
+ if (count >= 10) return '#0a5c4e'; // darkest
7
+ if (count >= 7) return '#0d7a68';
8
+ if (count >= 4) return '#289588'; // base accent
9
+ if (count >= 1) return '#6fc4b8'; // light
10
+ return '#e8eded'; // none (very light gray-green)
11
+ };
12
+
13
+ /**
14
+ * Normalize country names between GeoJSON and facet data
15
+ */
16
+ export const normalizeCountryName = (name) => {
17
+ if (!name) return name;
18
+ const normalizer = {
19
+ Türkiye: 'Turkey',
20
+ 'United Kingdom': 'United Kingdom',
21
+ 'Czech Rep.': 'Czechia',
22
+ 'Bosnia and Herz.': 'Bosnia and Herzegovina',
23
+ };
24
+ return normalizer[name] || name;
25
+ };
26
+
27
+ /**
28
+ * Build a country -> count lookup from facet data
29
+ * @param {Object} facets - searchContext.facets
30
+ * @returns {Object} countryName -> count
31
+ */
32
+ export const buildCountryCounts = (facets) => {
33
+ const countriesFacetArray = facets?.['cca_geographic_countries.keyword'];
34
+ const countriesFacet = Array.isArray(countriesFacetArray)
35
+ ? countriesFacetArray[0]
36
+ : countriesFacetArray;
37
+ const countryData = countriesFacet?.data || [];
38
+
39
+ const counts = {};
40
+ countryData.forEach((entry) => {
41
+ counts[entry.value] = entry.count;
42
+ });
43
+ return counts;
44
+ };
45
+
46
+ /**
47
+ * Legend items for the map view
48
+ */
49
+ export const mapLegendItems = [
50
+ { label: '10+', color: '#0a5c4e' },
51
+ { label: '7–9', color: '#0d7a68' },
52
+ { label: '4–6', color: '#289588' },
53
+ { label: '1–3', color: '#6fc4b8' },
54
+ { label: 'None', color: '#e8eded' },
55
+ ];
56
+
57
+ export const asArray = (value) => {
58
+ if (!value) return [];
59
+ const raw = value.raw !== undefined ? value.raw : value;
60
+ if (!raw) return [];
61
+ const values = Array.isArray(raw)
62
+ ? raw.filter(Boolean)
63
+ : [raw].filter(Boolean);
64
+
65
+ return values.map((value) => value?.title || value?.token || value);
66
+ };
67
+
68
+ export const arrayFieldToString = (value) => asArray(value).join(', ');
69
+
70
+ export const formatFunctionalityScore = (value) => {
71
+ const raw = value?.raw !== undefined ? value.raw : value;
72
+ const score = raw === null || raw === undefined ? Number.NaN : Number(raw);
73
+
74
+ return Number.isFinite(score) ? `${Math.min(6, Math.max(0, score))}/6` : '—';
75
+ };
76
+
77
+ export const escapeCsvValue = (value) => {
78
+ const stringValue = value === undefined || value === null ? '' : `${value}`;
79
+ return `"${stringValue.replace(/"/g, '""')}"`;
80
+ };
81
+
82
+ export const downloadCsv = (filename, rows) => {
83
+ const csv = rows.map((row) => row.map(escapeCsvValue).join(',')).join('\n');
84
+
85
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
86
+ const url = URL.createObjectURL(blob);
87
+ const link = document.createElement('a');
88
+
89
+ link.href = url;
90
+ link.setAttribute('download', filename);
91
+ document.body.appendChild(link);
92
+ link.click();
93
+ link.remove();
94
+
95
+ URL.revokeObjectURL(url);
96
+ };
97
+
98
+ export const getLocalizedLandingPageURL = (appConfig, currentLang = 'en') => {
99
+ const lang = currentLang || 'en';
100
+ const landingPageURL =
101
+ appConfig?.landingPageURL || '/en/navigator/tool-catalogue';
102
+
103
+ return landingPageURL.replace(/^\/en(?=\/|$)/, `/${lang}`);
104
+ };
105
+
106
+ export const getComparePageURL = (appConfig, currentLang = 'en') => {
107
+ const lang = currentLang || 'en';
108
+ const comparePageURL = appConfig?.comparePageURL || '/en/navigator/compare';
109
+
110
+ return comparePageURL.replace(/^\/en(?=\/|$)/, `/${lang}`);
111
+ };
112
+
113
+ export const exportComparisonTable = (tools, getToolField) => {
114
+ const rows = [
115
+ ['Criteria', ...tools.map((tool) => tool.title)],
116
+ [
117
+ 'Usability',
118
+ ...tools.map((tool) =>
119
+ arrayFieldToString(getToolField(tool, 'accessibility_and_usability')),
120
+ ),
121
+ ],
122
+ [
123
+ 'Functionality',
124
+ ...tools.map((tool) =>
125
+ formatFunctionalityScore(getToolField(tool, 'functionality')),
126
+ ),
127
+ ],
128
+ [
129
+ 'Spatial scale',
130
+ ...tools.map((tool) =>
131
+ arrayFieldToString(getToolField(tool, 'cca_geographical_scale')),
132
+ ),
133
+ ],
134
+ [
135
+ 'Output type',
136
+ ...tools.map((tool) =>
137
+ arrayFieldToString(getToolField(tool, 'cca_type_of_outputs')),
138
+ ),
139
+ ],
140
+ [
141
+ 'Adaptation support cycle step',
142
+ ...tools.map((tool) =>
143
+ arrayFieldToString(
144
+ getToolField(tool, 'cca_adaptation_support_cycle_step'),
145
+ ),
146
+ ),
147
+ ],
148
+ [
149
+ 'Sector',
150
+ ...tools.map((tool) =>
151
+ arrayFieldToString(getToolField(tool, 'cca_adaptation_sectors')),
152
+ ),
153
+ ],
154
+ ];
155
+
156
+ downloadCsv('navigator-catalogue-comparison.csv', rows);
157
+ };