@eeacms/volto-bise-policy 1.3.5 → 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.
- package/CHANGELOG.md +28 -12
- package/package.json +2 -1
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerEdit.jsx +22 -1
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.jsx +17 -12
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.test.jsx +1 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyFilters.jsx +59 -13
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.jsx +28 -16
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.test.jsx +54 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.jsx +68 -35
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.test.jsx +37 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.jsx +41 -39
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.test.jsx +39 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/index.js +2 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/schema.js +19 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/styles.less +33 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/utils.js +171 -93
- package/src/components/manage/Blocks/CaseStudyExplorer/utils.test.js +96 -1
|
@@ -31,6 +31,7 @@ jest.mock('./CaseStudyListing', () => () => (
|
|
|
31
31
|
|
|
32
32
|
jest.mock('./utils', () => ({
|
|
33
33
|
getFeatures: jest.fn(),
|
|
34
|
+
getSelectInteraction: jest.fn(() => null),
|
|
34
35
|
centerAndResetMapZoom: jest.fn(),
|
|
35
36
|
scrollToElement: jest.fn(),
|
|
36
37
|
}));
|
|
@@ -85,6 +86,42 @@ describe('CaseStudyMap', () => {
|
|
|
85
86
|
).not.toBeInTheDocument();
|
|
86
87
|
});
|
|
87
88
|
|
|
89
|
+
it('does not refresh the points source when only the ol wrapper identity changes', () => {
|
|
90
|
+
getFeatures.mockReturnValue([{ id: 1 }]);
|
|
91
|
+
const activeItems = [{ id: 1 }];
|
|
92
|
+
|
|
93
|
+
const { rerender } = render(
|
|
94
|
+
<CaseStudyMap
|
|
95
|
+
items={[{ id: 1 }]}
|
|
96
|
+
activeItems={activeItems}
|
|
97
|
+
ol={mockOl}
|
|
98
|
+
setMap={jest.fn()}
|
|
99
|
+
map={mockMap}
|
|
100
|
+
onSelectedCase={jest.fn()}
|
|
101
|
+
/>,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const pointsSource = mockOl.source.Vector.mock.results[0].value;
|
|
105
|
+
const clearCount = pointsSource.clear.mock.calls.length;
|
|
106
|
+
expect(clearCount).toBeGreaterThan(0);
|
|
107
|
+
|
|
108
|
+
// withOpenLayers builds a fresh `ol` object literal on every render;
|
|
109
|
+
// refreshing the points source would regenerate the cluster features and
|
|
110
|
+
// drop the currently selected feature from the rendered set.
|
|
111
|
+
rerender(
|
|
112
|
+
<CaseStudyMap
|
|
113
|
+
items={[{ id: 1 }]}
|
|
114
|
+
activeItems={activeItems}
|
|
115
|
+
ol={{ ...mockOl }}
|
|
116
|
+
setMap={jest.fn()}
|
|
117
|
+
map={mockMap}
|
|
118
|
+
onSelectedCase={jest.fn()}
|
|
119
|
+
/>,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(pointsSource.clear.mock.calls.length).toBe(clearCount);
|
|
123
|
+
});
|
|
124
|
+
|
|
88
125
|
it('clicking Reset map calls utils and clears features', () => {
|
|
89
126
|
getFeatures.mockReturnValueOnce([{ id: 1 }]);
|
|
90
127
|
const onSelectedCase = jest.fn();
|
|
@@ -18,79 +18,81 @@ export const useStyles = ({ ol }) => {
|
|
|
18
18
|
}),
|
|
19
19
|
}),
|
|
20
20
|
}),
|
|
21
|
-
[ol],
|
|
21
|
+
[ol.style],
|
|
22
22
|
);
|
|
23
23
|
|
|
24
|
-
const selectStyle = React.useCallback(
|
|
25
|
-
(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// console.log(color);
|
|
29
|
-
selected.image_.getFill().setColor(color);
|
|
30
|
-
return selected;
|
|
31
|
-
},
|
|
32
|
-
[selected],
|
|
33
|
-
);
|
|
24
|
+
const selectStyle = React.useCallback(() => {
|
|
25
|
+
selected.image_.getFill().setColor('#0A99FF');
|
|
26
|
+
return selected;
|
|
27
|
+
}, [selected]);
|
|
34
28
|
|
|
35
29
|
return { selected, selectStyle };
|
|
36
30
|
};
|
|
37
31
|
|
|
38
|
-
function FeatureInteraction({
|
|
39
|
-
onFeatureSelect,
|
|
40
|
-
hideFilters,
|
|
41
|
-
selectedCase,
|
|
42
|
-
ol,
|
|
43
|
-
}) {
|
|
44
|
-
// console.log('featureinteraction', selectedCase);
|
|
32
|
+
function FeatureInteraction({ onFeatureSelect, hideFilters, ol }) {
|
|
45
33
|
const { map } = useMapContext();
|
|
46
34
|
const { selectStyle } = useStyles({ ol });
|
|
47
35
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
36
|
+
// Keep callbacks current without recreating the OpenLayers interaction.
|
|
37
|
+
const onFeatureSelectRef = React.useRef(onFeatureSelect);
|
|
38
|
+
onFeatureSelectRef.current = onFeatureSelect;
|
|
39
|
+
const hideFiltersRef = React.useRef(hideFilters);
|
|
40
|
+
hideFiltersRef.current = hideFilters;
|
|
41
|
+
const olRef = React.useRef(ol);
|
|
42
|
+
olRef.current = ol;
|
|
43
|
+
|
|
44
|
+
// Select stores the selected rendered cluster feature and applies its style
|
|
45
|
+
// directly to that feature. Recreating it after selectedCase changes clears
|
|
46
|
+
// that collection and restores the feature's original style.
|
|
47
|
+
const selectRef = React.useRef(null);
|
|
48
|
+
if (!selectRef.current) {
|
|
49
|
+
selectRef.current = new ol.interaction.Select({
|
|
50
|
+
condition: ol.condition.click,
|
|
51
|
+
style: hideFilters ? null : selectStyle,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const select = selectRef.current;
|
|
52
55
|
|
|
53
56
|
React.useEffect(() => {
|
|
54
57
|
if (!map) return;
|
|
55
58
|
|
|
56
|
-
|
|
59
|
+
const onSelect = (e) => {
|
|
57
60
|
const features = e.target.getFeatures().getArray();
|
|
58
61
|
|
|
59
62
|
features.forEach((feature) => {
|
|
60
63
|
const subfeatures = feature.values_.features;
|
|
61
64
|
if (subfeatures.length === 1) {
|
|
62
65
|
const selectedFeature = subfeatures[0].values_;
|
|
63
|
-
if (
|
|
66
|
+
if (hideFiltersRef.current) {
|
|
64
67
|
const url = window.location.origin + selectedFeature.path;
|
|
65
68
|
// window.open(url);
|
|
66
69
|
window.location.href = url;
|
|
67
70
|
}
|
|
68
|
-
|
|
71
|
+
onFeatureSelectRef.current(selectedFeature);
|
|
69
72
|
scrollToElement('ol-map-container');
|
|
70
|
-
// map.getView().animate({
|
|
71
|
-
// duration: 10,
|
|
72
|
-
// center: selectedFeature.geometry.flatCoordinates,
|
|
73
|
-
// });
|
|
74
73
|
} else {
|
|
75
|
-
|
|
76
|
-
zoomMapToFeatures({ map, features: subfeatures, ol });
|
|
74
|
+
onFeatureSelectRef.current(null);
|
|
75
|
+
zoomMapToFeatures({ map, features: subfeatures, ol: olRef.current });
|
|
77
76
|
}
|
|
78
77
|
});
|
|
78
|
+
};
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
});
|
|
82
|
-
|
|
80
|
+
select.on('select', onSelect);
|
|
83
81
|
map.addInteraction(select);
|
|
84
82
|
|
|
85
|
-
|
|
83
|
+
const onPointerMove = (e) => {
|
|
86
84
|
const pixel = map.getEventPixel(e.originalEvent);
|
|
87
85
|
const hit = map.hasFeatureAtPixel(pixel);
|
|
88
86
|
map.getViewport().style.cursor = hit ? 'pointer' : '';
|
|
89
|
-
}
|
|
87
|
+
};
|
|
88
|
+
map.on('pointermove', onPointerMove);
|
|
90
89
|
|
|
91
|
-
return () =>
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
return () => {
|
|
91
|
+
select.un('select', onSelect);
|
|
92
|
+
map.un('pointermove', onPointerMove);
|
|
93
|
+
map.removeInteraction(select);
|
|
94
|
+
};
|
|
95
|
+
}, [map, select]);
|
|
94
96
|
|
|
95
97
|
return null;
|
|
96
98
|
}
|
|
@@ -35,6 +35,7 @@ const mockOl = createMockOl({
|
|
|
35
35
|
mockSelectCb = cb;
|
|
36
36
|
}
|
|
37
37
|
}),
|
|
38
|
+
un: jest.fn(),
|
|
38
39
|
})),
|
|
39
40
|
},
|
|
40
41
|
});
|
|
@@ -43,6 +44,7 @@ const mockMap = {
|
|
|
43
44
|
addInteraction,
|
|
44
45
|
removeInteraction,
|
|
45
46
|
on: onMap,
|
|
47
|
+
un: jest.fn(),
|
|
46
48
|
getEventPixel,
|
|
47
49
|
hasFeatureAtPixel,
|
|
48
50
|
getViewport,
|
|
@@ -66,6 +68,43 @@ describe('FeatureInteraction', () => {
|
|
|
66
68
|
mockSelectCb = null;
|
|
67
69
|
});
|
|
68
70
|
|
|
71
|
+
it('configures the selection style when filters are visible', () => {
|
|
72
|
+
render(
|
|
73
|
+
<FeatureInteraction
|
|
74
|
+
onFeatureSelect={jest.fn()}
|
|
75
|
+
hideFilters={false}
|
|
76
|
+
selectedCase={null}
|
|
77
|
+
ol={mockOl}
|
|
78
|
+
/>,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const options = mockOl.interaction.Select.mock.calls[0][0];
|
|
82
|
+
expect(options.style).toEqual(expect.any(Function));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('keeps one Select interaction across rerenders', () => {
|
|
86
|
+
const { rerender } = render(
|
|
87
|
+
<FeatureInteraction
|
|
88
|
+
onFeatureSelect={jest.fn()}
|
|
89
|
+
hideFilters={false}
|
|
90
|
+
selectedCase={null}
|
|
91
|
+
ol={mockOl}
|
|
92
|
+
/>,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
rerender(
|
|
96
|
+
<FeatureInteraction
|
|
97
|
+
onFeatureSelect={jest.fn()}
|
|
98
|
+
hideFilters={false}
|
|
99
|
+
selectedCase={{ path: '/selected' }}
|
|
100
|
+
ol={{ ...mockOl }}
|
|
101
|
+
/>,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(mockOl.interaction.Select).toHaveBeenCalledTimes(1);
|
|
105
|
+
expect(addInteraction).toHaveBeenCalledTimes(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
69
108
|
it('selects a single feature and calls onFeatureSelect + scrollToElement (hideFilters=false)', () => {
|
|
70
109
|
const onFeatureSelect = jest.fn();
|
|
71
110
|
const singleFeature = {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import worldSVG from '@plone/volto/icons/world.svg';
|
|
2
2
|
import CaseStudyExplorerEdit from './CaseStudyExplorerEdit';
|
|
3
3
|
import CaseStudyExplorerView from './CaseStudyExplorerView';
|
|
4
|
+
import schema from './schema';
|
|
4
5
|
|
|
5
6
|
export default function installCaseStudyExplorerBlock(config) {
|
|
6
7
|
config.blocks.blocksConfig.caseStudyExplorer = {
|
|
@@ -10,6 +11,7 @@ export default function installCaseStudyExplorerBlock(config) {
|
|
|
10
11
|
group: 'custom_blocks',
|
|
11
12
|
edit: CaseStudyExplorerEdit,
|
|
12
13
|
view: CaseStudyExplorerView,
|
|
14
|
+
schema,
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
return config;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const schema = {
|
|
2
|
+
title: 'NRR Case Study Explorer',
|
|
3
|
+
fieldsets: [
|
|
4
|
+
{
|
|
5
|
+
id: 'default',
|
|
6
|
+
title: 'Default',
|
|
7
|
+
fields: ['filter_path'],
|
|
8
|
+
},
|
|
9
|
+
],
|
|
10
|
+
properties: {
|
|
11
|
+
filter_path: {
|
|
12
|
+
title: 'Filter path',
|
|
13
|
+
type: 'string',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
required: [],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default schema;
|
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
padding-top: 2em;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
.nrr-case-study-map-container .case-study-status-legend {
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-wrap: wrap;
|
|
8
|
+
align-items: center;
|
|
9
|
+
justify-content: flex-end;
|
|
10
|
+
margin: 0.75rem 0;
|
|
11
|
+
font-size: 0.9rem;
|
|
12
|
+
gap: 0.75rem;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.nrr-case-study-map-container .legend-title {
|
|
16
|
+
font-weight: 600;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.nrr-case-study-map-container .legend-item {
|
|
20
|
+
display: inline-flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: 0.3rem;
|
|
23
|
+
text-transform: capitalize;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.nrr-case-study-map-container .legend-dot {
|
|
27
|
+
display: inline-block;
|
|
28
|
+
width: 0.75rem;
|
|
29
|
+
height: 0.75rem;
|
|
30
|
+
border-radius: 50%;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.nrr-case-study-map-container .filter-wrapper .facet-btn:disabled {
|
|
34
|
+
cursor: not-allowed;
|
|
35
|
+
opacity: 0.55;
|
|
36
|
+
}
|
|
37
|
+
|
|
5
38
|
#csepopup > span {
|
|
6
39
|
display: 'block';
|
|
7
40
|
margin-bottom: '10px';
|
|
@@ -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
|
|
12
|
-
|
|
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:
|
|
44
|
-
image:
|
|
45
|
-
nwrm_type:
|
|
46
|
-
measures_implemented:
|
|
47
|
-
typology_of_measures:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
221
|
+
return filters;
|
|
144
222
|
}
|