@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.
- package/CHANGELOG.md +37 -1
- package/package.json +2 -1
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyCard.jsx +404 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyCard.test.jsx +366 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.jsx +192 -91
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.test.jsx +205 -58
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.jsx +329 -178
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.test.jsx +243 -123
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMapLazy.jsx +61 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMapLazy.test.jsx +89 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyResults.jsx +128 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyResults.test.jsx +160 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FacetPopover.jsx +180 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FacetPopover.test.jsx +125 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FilterPanel.jsx +171 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FilterPanel.test.jsx +181 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/Pagination.jsx +81 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/filters.js +350 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/filters.test.js +399 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/hooks.js +28 -4
- package/src/components/manage/Blocks/CaseStudyExplorer/hooks.test.js +73 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/messages.js +138 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/styles.less +1157 -317
- package/src/components/manage/Blocks/CaseStudyExplorer/testUtils.js +44 -46
- package/src/components/manage/Blocks/CaseStudyExplorer/utils.js +68 -184
- package/src/components/manage/Blocks/CaseStudyExplorer/utils.test.js +91 -158
- package/src/index.js +39 -0
- package/src/openlayers.js +51 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyFilters.jsx +0 -383
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyFilters.test.jsx +0 -90
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.jsx +0 -342
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.test.jsx +0 -196
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureDisplay.jsx +0 -24
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureDisplay.test.jsx +0 -49
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.jsx +0 -100
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.test.jsx +0 -189
- package/src/components/manage/Blocks/CaseStudyExplorer/InfoOverlay.jsx +0 -111
- package/src/components/manage/Blocks/CaseStudyExplorer/InfoOverlay.test.jsx +0 -159
- 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
|
+
}
|