@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,160 @@
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 CaseStudyResults from './CaseStudyResults';
8
+ import Pagination, { paginationItems } from './Pagination';
9
+
10
+ const makeItem = (index) => ({
11
+ geometry: { type: 'Point', coordinates: [0, 0] },
12
+ properties: {
13
+ title: `Case ${index}`,
14
+ path: `/case-${index}`,
15
+ current_status: 'ongoing',
16
+ measures: [],
17
+ },
18
+ });
19
+
20
+ const items = Array.from({ length: 12 }, (_, index) => makeItem(index + 1));
21
+
22
+ const renderResults = (props = {}) =>
23
+ render(
24
+ <IntlProvider locale="en">
25
+ <CaseStudyResults
26
+ items={items}
27
+ sortBy="title"
28
+ onSortChange={jest.fn()}
29
+ perPage={8}
30
+ onPerPageChange={jest.fn()}
31
+ page={1}
32
+ onPageChange={jest.fn()}
33
+ {...props}
34
+ />
35
+ </IntlProvider>,
36
+ );
37
+
38
+ describe('CaseStudyResults', () => {
39
+ test('reports the total result count, not the page size', () => {
40
+ renderResults();
41
+ expect(screen.getByText('Found 12 case studies')).toBeInTheDocument();
42
+ });
43
+
44
+ test('uses the singular form for one result', () => {
45
+ renderResults({ items: items.slice(0, 1) });
46
+ expect(screen.getByText('Found 1 case study')).toBeInTheDocument();
47
+ });
48
+
49
+ test('renders only the current page of cards', () => {
50
+ renderResults();
51
+ expect(screen.getAllByRole('article')).toHaveLength(8);
52
+ });
53
+
54
+ test('renders the second page', () => {
55
+ renderResults({ page: 2 });
56
+ expect(screen.getAllByRole('article')).toHaveLength(4);
57
+ });
58
+
59
+ test('changes sort and page size', () => {
60
+ const onSortChange = jest.fn();
61
+ const onPerPageChange = jest.fn();
62
+ renderResults({ onSortChange, onPerPageChange });
63
+
64
+ fireEvent.change(screen.getByLabelText('Sort by'), {
65
+ target: { value: 'newest' },
66
+ });
67
+ fireEvent.change(screen.getByLabelText('per page'), {
68
+ target: { value: '16' },
69
+ });
70
+
71
+ expect(onSortChange).toHaveBeenCalledWith('newest');
72
+ expect(onPerPageChange).toHaveBeenCalledWith(16);
73
+ });
74
+
75
+ test('shows an empty state with a reset action', () => {
76
+ const onResetFilters = jest.fn();
77
+ renderResults({ items: [], onResetFilters });
78
+
79
+ expect(
80
+ screen.getByText('No case studies match your filters'),
81
+ ).toBeInTheDocument();
82
+ fireEvent.click(screen.getByRole('button', { name: 'Reset filters' }));
83
+
84
+ expect(onResetFilters).toHaveBeenCalled();
85
+ });
86
+
87
+ test('marks the selected card', () => {
88
+ renderResults({ selectedPath: '/case-3' });
89
+
90
+ expect(
91
+ screen
92
+ .getAllByRole('article')
93
+ .filter((card) => card.dataset.selected === 'true'),
94
+ ).toHaveLength(1);
95
+ });
96
+ });
97
+
98
+ describe('Pagination', () => {
99
+ test('shows every page when the list is short', () => {
100
+ expect(paginationItems(1, 5)).toEqual([1, 2, 3, 4, 5]);
101
+ });
102
+
103
+ test('windows long lists with ellipsis markers', () => {
104
+ expect(paginationItems(1, 20)).toEqual([1, 2, 'end-ellipsis', 20]);
105
+ expect(paginationItems(10, 20)).toEqual([
106
+ 1,
107
+ 'start-ellipsis',
108
+ 9,
109
+ 10,
110
+ 11,
111
+ 'end-ellipsis',
112
+ 20,
113
+ ]);
114
+ });
115
+
116
+ test('renders the current page and pages through', () => {
117
+ const onPageChange = jest.fn();
118
+ render(
119
+ <IntlProvider locale="en">
120
+ <Pagination page={2} pageCount={3} onPageChange={onPageChange} />
121
+ </IntlProvider>,
122
+ );
123
+
124
+ expect(screen.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
125
+ 'aria-current',
126
+ 'page',
127
+ );
128
+
129
+ fireEvent.click(screen.getByRole('button', { name: 'Next page' }));
130
+ expect(onPageChange).toHaveBeenCalledWith(3);
131
+ });
132
+
133
+ test('renders a single, current page with both steps disabled', () => {
134
+ render(
135
+ <IntlProvider locale="en">
136
+ <Pagination page={1} pageCount={1} onPageChange={jest.fn()} />
137
+ </IntlProvider>,
138
+ );
139
+
140
+ expect(screen.getByRole('button', { name: 'Page 1' })).toHaveAttribute(
141
+ 'aria-current',
142
+ 'page',
143
+ );
144
+ expect(screen.queryByRole('button', { name: 'Page 2' })).toBeNull();
145
+ expect(
146
+ screen.getByRole('button', { name: 'Previous page' }),
147
+ ).toBeDisabled();
148
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
149
+ });
150
+
151
+ test('renders nothing when there are no pages', () => {
152
+ const { container } = render(
153
+ <IntlProvider locale="en">
154
+ <Pagination page={1} pageCount={0} onPageChange={jest.fn()} />
155
+ </IntlProvider>,
156
+ );
157
+
158
+ expect(container).toBeEmptyDOMElement();
159
+ });
160
+ });
@@ -0,0 +1,180 @@
1
+ import React from 'react';
2
+ import { useIntl } from 'react-intl';
3
+ import cx from 'classnames';
4
+
5
+ import messages from './messages';
6
+
7
+ const normalize = (value) => String(value).trim().toLowerCase();
8
+
9
+ export function filterOptions(options, search) {
10
+ const needle = normalize(search);
11
+ if (!needle) return options;
12
+
13
+ return options.filter((option) =>
14
+ normalize(
15
+ `${option.label} ${option.value} ${option.family || ''}`,
16
+ ).includes(needle),
17
+ );
18
+ }
19
+
20
+ function selectedLabel(groups, filters) {
21
+ const selected = groups.flatMap((group) =>
22
+ group.options.filter((option) =>
23
+ (filters[group.facet] || []).includes(option.value),
24
+ ),
25
+ );
26
+
27
+ if (!selected.length) return null;
28
+ if (selected.length === 1) return selected[0].label;
29
+
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Trigger + panel holding checkbox groups, one group per facet. Used both for
35
+ * the combined measure/typology control and for the single-facet dropdowns.
36
+ * Ticking a box calls `onToggle`, which applies the change immediately.
37
+ */
38
+ export default function FacetPopover({
39
+ id,
40
+ label,
41
+ groups,
42
+ filters = {},
43
+ onToggle,
44
+ className,
45
+ }) {
46
+ const intl = useIntl();
47
+ const [open, setOpen] = React.useState(false);
48
+ const [search, setSearch] = React.useState('');
49
+ const wrapperRef = React.useRef(null);
50
+ const triggerRef = React.useRef(null);
51
+ const searchRef = React.useRef(null);
52
+
53
+ const close = React.useCallback(() => {
54
+ setOpen(false);
55
+ setSearch('');
56
+ }, []);
57
+
58
+ React.useEffect(() => {
59
+ if (!open) return undefined;
60
+
61
+ const onDocumentClick = (event) => {
62
+ if (!wrapperRef.current?.contains(event.target)) close();
63
+ };
64
+ const onKeyDown = (event) => {
65
+ if (event.key !== 'Escape') return;
66
+ close();
67
+ triggerRef.current?.focus();
68
+ };
69
+
70
+ document.addEventListener('click', onDocumentClick);
71
+ document.addEventListener('keydown', onKeyDown);
72
+ searchRef.current?.focus();
73
+
74
+ return () => {
75
+ document.removeEventListener('click', onDocumentClick);
76
+ document.removeEventListener('keydown', onKeyDown);
77
+ };
78
+ }, [close, open]);
79
+
80
+ const totalOptions = groups.reduce(
81
+ (total, group) => total + group.options.length,
82
+ 0,
83
+ );
84
+ const selectedCount = groups.reduce(
85
+ (total, group) => total + (filters[group.facet] || []).length,
86
+ 0,
87
+ );
88
+ const singleLabel = selectedLabel(groups, filters);
89
+ const summary =
90
+ selectedCount === 1 && singleLabel
91
+ ? singleLabel
92
+ : selectedCount > 1
93
+ ? intl.formatMessage(messages.selectedCount, {
94
+ label,
95
+ count: selectedCount,
96
+ })
97
+ : label;
98
+
99
+ const visibleGroups = groups.map((group) => ({
100
+ ...group,
101
+ visible: filterOptions(group.options, search),
102
+ }));
103
+ const hasMatches = visibleGroups.some((group) => group.visible.length > 0);
104
+
105
+ return (
106
+ <div className={cx('facet-popover', className)} ref={wrapperRef}>
107
+ <button
108
+ type="button"
109
+ ref={triggerRef}
110
+ className={cx('facet-trigger', { active: selectedCount > 0 })}
111
+ aria-expanded={open}
112
+ aria-controls={`${id}-panel`}
113
+ onClick={() => setOpen((wasOpen) => !wasOpen)}
114
+ >
115
+ <span className="facet-trigger-label" title={summary}>
116
+ {summary}
117
+ </span>
118
+ <i aria-hidden="true" className="icon angle down" />
119
+ </button>
120
+
121
+ {open ? (
122
+ <div
123
+ className="facet-panel"
124
+ id={`${id}-panel`}
125
+ role="group"
126
+ aria-label={label}
127
+ >
128
+ {totalOptions > 7 ? (
129
+ <input
130
+ ref={searchRef}
131
+ type="search"
132
+ className="facet-search"
133
+ value={search}
134
+ placeholder={intl.formatMessage(messages.searchValues)}
135
+ aria-label={intl.formatMessage(messages.searchValues)}
136
+ onChange={(event) => setSearch(event.target.value)}
137
+ />
138
+ ) : null}
139
+
140
+ {hasMatches ? (
141
+ visibleGroups.map((group) =>
142
+ group.visible.length === 0 ? null : (
143
+ <fieldset className="facet-group" key={group.facet}>
144
+ {group.title ? <legend>{group.title}</legend> : null}
145
+ <div className="facet-options">
146
+ {group.visible.map((option) => (
147
+ <label className="facet-option" key={option.value}>
148
+ <input
149
+ type="checkbox"
150
+ value={option.value}
151
+ checked={(filters[group.facet] || []).includes(
152
+ option.value,
153
+ )}
154
+ onChange={() => onToggle(group.facet, option.value)}
155
+ />
156
+ <span className="facet-option-label">
157
+ {option.label}
158
+ </span>
159
+ <span className="facet-option-count">
160
+ {' '}
161
+ ({option.count})
162
+ </span>
163
+ </label>
164
+ ))}
165
+ </div>
166
+ </fieldset>
167
+ ),
168
+ )
169
+ ) : (
170
+ <p className="facet-empty">
171
+ {totalOptions === 0
172
+ ? intl.formatMessage(messages.noOptionsAvailable)
173
+ : intl.formatMessage(messages.noMatchingOptions)}
174
+ </p>
175
+ )}
176
+ </div>
177
+ ) : null}
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,125 @@
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 FacetPopover, { filterOptions } from './FacetPopover';
8
+
9
+ const manyOptions = Array.from({ length: 9 }, (_, index) => ({
10
+ value: `value-${index}`,
11
+ label: `Option ${index}`,
12
+ count: index,
13
+ }));
14
+
15
+ const renderPopover = (props = {}) =>
16
+ render(
17
+ <IntlProvider locale="en">
18
+ <FacetPopover
19
+ id="test"
20
+ label="Legislation"
21
+ groups={[{ facet: 'legislation', options: manyOptions }]}
22
+ filters={{ legislation: [] }}
23
+ onToggle={jest.fn()}
24
+ {...props}
25
+ />
26
+ </IntlProvider>,
27
+ );
28
+
29
+ describe('FacetPopover', () => {
30
+ test('shows the facet label when nothing is selected', () => {
31
+ renderPopover();
32
+ expect(screen.getByRole('button')).toHaveTextContent('Legislation');
33
+ });
34
+
35
+ test('shows the single selected label', () => {
36
+ renderPopover({ filters: { legislation: ['value-3'] } });
37
+ expect(screen.getByRole('button')).toHaveTextContent('Option 3');
38
+ });
39
+
40
+ test('shows a count when several values are selected', () => {
41
+ renderPopover({ filters: { legislation: ['value-1', 'value-2'] } });
42
+ expect(screen.getByRole('button')).toHaveTextContent(
43
+ 'Legislation (2 selected)',
44
+ );
45
+ });
46
+
47
+ test('is closed until the trigger is clicked', () => {
48
+ renderPopover();
49
+ expect(screen.queryByRole('group', { name: 'Legislation' })).toBeNull();
50
+
51
+ fireEvent.click(screen.getByRole('button'));
52
+
53
+ expect(
54
+ screen.getByRole('group', { name: 'Legislation' }),
55
+ ).toBeInTheDocument();
56
+ });
57
+
58
+ test('renders options with their counts and toggles them', () => {
59
+ const onToggle = jest.fn();
60
+ renderPopover({ onToggle });
61
+
62
+ fireEvent.click(screen.getByRole('button'));
63
+ expect(screen.getByLabelText(/Option 0\b/)).toBeInTheDocument();
64
+ expect(
65
+ screen.getByText('(0)', { selector: '.facet-option-count' }),
66
+ ).toBeInTheDocument();
67
+
68
+ fireEvent.click(screen.getByLabelText(/Option 4\b/));
69
+
70
+ expect(onToggle).toHaveBeenCalledWith('legislation', 'value-4');
71
+ });
72
+
73
+ test('filters options with the search box when there are many', () => {
74
+ renderPopover();
75
+ fireEvent.click(screen.getByRole('button'));
76
+ fireEvent.change(screen.getByRole('searchbox'), {
77
+ target: { value: 'option 7' },
78
+ });
79
+
80
+ expect(screen.getByLabelText(/Option 7\b/)).toBeInTheDocument();
81
+ expect(screen.queryByLabelText(/Option 1\b/)).toBeNull();
82
+ });
83
+
84
+ test('shows the empty state when nothing matches', () => {
85
+ renderPopover();
86
+ fireEvent.click(screen.getByRole('button'));
87
+ fireEvent.change(screen.getByRole('searchbox'), {
88
+ target: { value: 'zzz' },
89
+ });
90
+
91
+ expect(screen.getByText('No matching values')).toBeInTheDocument();
92
+ });
93
+
94
+ test('hides the search box for short option lists', () => {
95
+ renderPopover({
96
+ groups: [{ facet: 'legislation', options: manyOptions.slice(0, 3) }],
97
+ });
98
+ fireEvent.click(screen.getByRole('button'));
99
+
100
+ expect(screen.queryByRole('searchbox')).toBeNull();
101
+ });
102
+
103
+ test('closes on Escape and on an outside click', () => {
104
+ renderPopover();
105
+ fireEvent.click(screen.getByRole('button'));
106
+ fireEvent.keyDown(document, { key: 'Escape' });
107
+ expect(screen.queryByRole('group', { name: 'Legislation' })).toBeNull();
108
+
109
+ fireEvent.click(screen.getByRole('button'));
110
+ fireEvent.click(document.body);
111
+ expect(screen.queryByRole('group', { name: 'Legislation' })).toBeNull();
112
+ });
113
+
114
+ test('filterOptions matches label, value and family', () => {
115
+ const options = [
116
+ { value: 'NRPM_001', label: 'Prevent conversion', family: 'MA' },
117
+ { value: 'NRPM_020', label: 'Restore forest', family: 'MB' },
118
+ ];
119
+
120
+ expect(filterOptions(options, '')).toHaveLength(2);
121
+ expect(filterOptions(options, 'ma')).toEqual([options[0]]);
122
+ expect(filterOptions(options, 'nrpm_020')).toEqual([options[1]]);
123
+ expect(filterOptions(options, 'forest')).toEqual([options[1]]);
124
+ });
125
+ });
@@ -0,0 +1,171 @@
1
+ import React from 'react';
2
+ import { useIntl } from 'react-intl';
3
+ import cx from 'classnames';
4
+
5
+ import FacetPopover from './FacetPopover';
6
+ import messages from './messages';
7
+ import { STATUS_COLORS } from './utils';
8
+ import { hasActiveFilters } from './filters';
9
+
10
+ const DROPDOWN_FACETS = [
11
+ { facet: 'legislation', label: messages.facetLegislation },
12
+ { facet: 'ecosystem', label: messages.facetEcosystem },
13
+ { facet: 'bioregion', label: messages.facetBioregion },
14
+ { facet: 'scale', label: messages.facetScale },
15
+ ];
16
+
17
+ const STATUSES = [
18
+ { value: 'planned', label: messages.statusPlanned },
19
+ { value: 'ongoing', label: messages.statusOngoing },
20
+ { value: 'completed', label: messages.statusCompleted },
21
+ ];
22
+
23
+ function StatusFacet({ facets = [], filters, onToggle }) {
24
+ const intl = useIntl();
25
+ const selected = filters.status || [];
26
+ const counts = facets.reduce((accumulator, option) => {
27
+ accumulator[option.value] = option.count;
28
+ return accumulator;
29
+ }, {});
30
+
31
+ return (
32
+ <div className="facet-status">
33
+ <span className="facet-status-label" id="cse-status-label">
34
+ {intl.formatMessage(messages.facetStatus)}
35
+ </span>
36
+ <div
37
+ className="facet-status-buttons"
38
+ role="group"
39
+ aria-labelledby="cse-status-label"
40
+ >
41
+ {STATUSES.map((status) => {
42
+ const isSelected = selected.includes(status.value);
43
+
44
+ return (
45
+ <button
46
+ type="button"
47
+ key={status.value}
48
+ className={cx('facet-status-button', { active: isSelected })}
49
+ aria-pressed={isSelected}
50
+ onClick={() => onToggle('status', status.value)}
51
+ >
52
+ <span
53
+ className="facet-status-dot"
54
+ style={{ backgroundColor: STATUS_COLORS[status.value] }}
55
+ aria-hidden="true"
56
+ />
57
+ <span className="facet-status-text">
58
+ {intl.formatMessage(status.label)}
59
+ </span>
60
+ <span className="facet-status-count">
61
+ ({counts[status.value] || 0})
62
+ </span>
63
+ </button>
64
+ );
65
+ })}
66
+ </div>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ function SearchMeasures({ value = '', onChange }) {
72
+ const intl = useIntl();
73
+
74
+ return (
75
+ <div className="cse-search">
76
+ <label className="cse-search-label" htmlFor="cse-search-input">
77
+ {intl.formatMessage(messages.searchMeasuresLabel)}
78
+ </label>
79
+ <div className="cse-search-field">
80
+ <i className="icon ri-search-line" aria-hidden="true" />
81
+ <input
82
+ id="cse-search-input"
83
+ type="search"
84
+ className="cse-search-input"
85
+ value={value}
86
+ placeholder={intl.formatMessage(messages.searchMeasuresPlaceholder)}
87
+ onChange={(event) => onChange(event.target.value)}
88
+ />
89
+ {value ? (
90
+ <button
91
+ type="button"
92
+ className="cse-search-clear"
93
+ aria-label={intl.formatMessage(messages.clearSearch)}
94
+ onClick={() => onChange('')}
95
+ >
96
+ <i className="icon ri-close-line" aria-hidden="true" />
97
+ </button>
98
+ ) : null}
99
+ </div>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Filter panel: a free-text search over the measures implemented and the
106
+ * typology of measures, four single-facet dropdowns and the current-status
107
+ * toggles. Every change is applied immediately - `onQueryChange` and
108
+ * `onToggle` update the filters the map and the list read from - so there is
109
+ * no Apply button and no staged draft.
110
+ */
111
+ export default function FilterPanel({
112
+ facets = {},
113
+ filters = {},
114
+ onToggle,
115
+ onQueryChange,
116
+ onReset,
117
+ }) {
118
+ const intl = useIntl();
119
+ const canReset = hasActiveFilters(filters);
120
+
121
+ return (
122
+ <section className="cse-filter-panel" aria-labelledby="cse-filter-title">
123
+ <header className="cse-filter-header">
124
+ <div className="cse-filter-heading">
125
+ <span className="cse-filter-icon" aria-hidden="true">
126
+ <i className="icon ri-filter-3-line" />
127
+ </span>
128
+ <h2 className="cse-filter-title" id="cse-filter-title">
129
+ {intl.formatMessage(messages.filterPanelTitle)}
130
+ </h2>
131
+ {/* <span className="cse-filter-subtitle">
132
+ {intl.formatMessage(messages.filterPanelHint)}
133
+ </span> */}
134
+ </div>
135
+ <div className="cse-filter-actions">
136
+ <button
137
+ type="button"
138
+ className="cse-reset-filters"
139
+ onClick={onReset}
140
+ disabled={!canReset}
141
+ >
142
+ <i className="icon ri-refresh-line" aria-hidden="true" />
143
+ {intl.formatMessage(messages.resetFilters)}
144
+ </button>
145
+ </div>
146
+ </header>
147
+
148
+ <div className="cse-filter-row cse-filter-row-primary">
149
+ <SearchMeasures value={filters.query} onChange={onQueryChange} />
150
+ </div>
151
+
152
+ <div className="cse-filter-row cse-filter-row-secondary">
153
+ {DROPDOWN_FACETS.map(({ facet, label }) => (
154
+ <FacetPopover
155
+ key={facet}
156
+ id={facet}
157
+ label={intl.formatMessage(label)}
158
+ filters={filters}
159
+ onToggle={onToggle}
160
+ groups={[{ facet, options: facets[facet] || [] }]}
161
+ />
162
+ ))}
163
+ <StatusFacet
164
+ facets={facets.status}
165
+ filters={filters}
166
+ onToggle={onToggle}
167
+ />
168
+ </div>
169
+ </section>
170
+ );
171
+ }