-
Notifications
You must be signed in to change notification settings - Fork 37
/
FilterGroups.js
351 lines (306 loc) · 11.2 KB
/
FilterGroups.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
import kebabCase from 'lodash/kebabCase';
import React, { isValidElement } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import FilterControlGroup from '../FilterControlGroup';
import css from './FilterGroups.css';
import {
AccordionSet,
Accordion,
FilterAccordionHeader,
} from '../Accordion';
import Checkbox from '../Checkbox';
// a constant used to split multiple filters in
// order to convert them to CQL
export const FILTER_SEPARATOR = ',';
// a constant used to split filter groups
export const FILTER_GROUP_SEPARATOR = '.';
const getStringFromMessage = (label, formatMessage) => {
if (isValidElement(label) && label?.props?.id) {
return formatMessage({ id: label.props.id })
}
return label;
}
// private
const FilterCheckbox = (props) => {
const { groupName, name, displayName, checked, onChangeFilter: ocf, disabled } = props;
const fullName = `${groupName}.${name}`;
return (<Checkbox
data-test-filter-checkbox
id={`clickable-filter-${kebabCase(fullName)}`}
label={displayName}
name={fullName}
checked={checked}
onChange={ocf}
innerClass={css.filterGroupLabel}
fullWidth
disabled={disabled}
/>);
};
FilterCheckbox.propTypes = {
checked: PropTypes.bool.isRequired,
disabled: PropTypes.bool,
displayName: PropTypes.node.isRequired,
groupName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
onChangeFilter: PropTypes.func.isRequired,
};
// private
const FilterGroup = (props) => {
const { label, groupName, names, filters, onChangeFilter: ocf, disabled } = props;
const { formatMessage } = useIntl();
const stringLabel = getStringFromMessage(label, formatMessage);
return (
<div role="group" aria-label={stringLabel}>
<FilterControlGroup
data-test-filter-group
label={stringLabel}
disabled={disabled}
>
{names.map((name, index) => (
<FilterCheckbox
key={`${name}-${index}`}
groupName={groupName}
name={name.value}
displayName={name.displayName}
onChangeFilter={ocf}
checked={!!filters[`${groupName}.${name.value}`]}
disabled={disabled}
/>))}
</FilterControlGroup>
</div>
);
};
FilterGroup.propTypes = {
disabled: PropTypes.bool,
filters: PropTypes.object.isRequired,
groupName: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
names: PropTypes.arrayOf(PropTypes.object).isRequired,
onChangeFilter: PropTypes.func.isRequired,
};
// DEPRECATED in favour of filterState
export function initialFilterState(config, filters) {
const state = {};
if (filters) {
const fullNames = filters.split(FILTER_SEPARATOR);
const register = {};
for (const fullName of fullNames) {
register[fullName] = true;
}
for (const group of config) {
for (const value of group.values) {
const valueName = (typeof value === 'object') ? value.name : value;
const fullName = `${group.name}.${valueName}`;
if (register[fullName]) state[fullName] = true;
}
}
}
return state;
}
export function filterState(filters) {
const state = {};
if (filters) {
const fullNames = filters.split(FILTER_SEPARATOR);
for (const fullName of fullNames) {
state[fullName] = true;
}
}
return state;
}
/**
* Filters come in groups. Within each group, we want to OR together
* the filters that have been selected; and we want to AND together
* the output of each group. Groups where no filter has been selected
* have no effect at all. And Groups where all filters have been
* selected have no effect unless the `restrictWhenAllSelected`
* setting is on.
*
* config values with the attribute "hidden: true" are not exposed in the UI
* (i.e. no checkbox is presented in the UI) but the filter value is always
* applied. For example, suppose a filter group "species" existed and the
* query should *always* include Humans and allow the user to choose whether
* Ewok, Jawa, or Wookie members should also be included in the search. The
* values entry with the attribute `name: "human"` would also contain the
* attribute `hidden: true`.
*
* @param config array list of objects with keys label, name, cql, values[]
* @param filters string comma-delimited list of active filters, e.g. foo.someValue,bar.otherValue
*
*/
export function filters2cql(config, filters = '') {
const groups = {};
const fullNames = filters.split(FILTER_SEPARATOR);
// restructure the "filters" argument from a string,
// e.g. "foo.valueOne,foo.valueTwo,bar.valueThree", to an object,
// e.g. { foo: [ valueOne, valueTwo ], bar: [ valueThree ]}
for (const fullName of fullNames) {
// Note: splitting an empty string returns an empty string,
// so we need to check if fullName is actually a legit filtername
if (fullName) {
const [groupName, fieldName] = fullName.split(FILTER_GROUP_SEPARATOR);
if (groups[groupName] === undefined) groups[groupName] = [];
groups[groupName].push(fieldName);
}
}
// pull in hidden config values which must always be applied.
for (const filter of config) {
for (const value of filter.values) {
if (typeof value === 'object' && value.hidden) {
if (groups[filter.name] === undefined) {
groups[filter.name] = [];
}
groups[filter.name].push(value.name);
}
}
}
// nothing to do if there are no filters.
// note that we can't simply bail when "filters" is empty up at the top
// because hidden values in config may also be present.
if (!filters && !Object.keys(groups).length) {
return undefined;
}
const conds = [];
for (const groupName of Object.keys(groups)) {
// config is an array and therefore may contain multiple filters
// with the same name. that *shouldn't* happen, but just in case it
// does, only use the first one.
const group = config.filter(g => g.name === groupName)[0];
if (group && group.cql) {
const cqlIndex = group.cql;
// values contains the selected filters
const values = groups[groupName];
if (!(values.length === (group.values.length) && !group.restrictWhenAllSelected)) {
const mappedValues = values.map((v) => {
// If the field is a {name,cql} object, use the CQL.
const obj = group.values.filter(f => typeof f === 'object' && f.name === v);
return (obj.length > 0) ? obj[0].cql : v;
});
// parse to CQL manually via parse
if (typeof group.parse === 'function') {
conds.push(group.parse(mappedValues));
} else if (group.isRange) {
const { isIncludingStart = true, isIncludingEnd = true, rangeSeparator = ':' } = group;
const [start, end] = mappedValues[0].split(rangeSeparator);
const startCondition = `${cqlIndex}>${isIncludingStart ? '=' : ''}"${start}"`;
const endCondition = `${cqlIndex}<${isIncludingEnd ? '=' : ''}"${end}"`;
conds.push(`(${startCondition} and ${endCondition})`);
} else {
let joinedValues = mappedValues.map(v => `"${v}"`).join(' or ');
if (values.length > 1) joinedValues = `(${joinedValues})`;
const { operator = '==' } = group;
conds.push(`${cqlIndex}${operator}${joinedValues}`);
}
}
} else {
console.warn(`Expected to find match for ${groupName} in filter-config`, config);
}
}
return conds.join(' and ');
}
// Requires calling component to have a this.updateFilters method
// DEPRECATED in favour of handleFilterChange
//
export function onChangeFilter(e) {
this.setState(prevState => {
const filters = Object.assign({}, prevState.filters);
filters[e.target.name] = e.target.checked;
this.updateFilters(filters);
return { filters };
});
}
// Relies on caller providing both queryParam and transitionToParams
export function handleFilterChange(e) {
const state = filterState(this.queryParam('filters'));
state[e.target.name] = e.target.checked;
this.transitionToParams({ filters: Object.keys(state).filter(key => state[key]).join(FILTER_SEPARATOR) });
return state;
}
export function handleFilterClear(name) {
const state = filterState(this.queryParam('filters'));
Object.keys(state).forEach((key) => {
if (key.startsWith(`${name}.`)) {
state[key] = false;
}
});
this.transitionToParams({ filters: Object.keys(state).filter(key => state[key]).join(FILTER_SEPARATOR) });
return state;
}
export function handleClearAllFilters() {
// Access the initialFilters that were passed to the calling component. This is probably SearchAndSort.
this.transitionToParams({ filters: this.props.initialFilters || '', query: '' });
}
const FilterGroups = (props) => {
const { config, filters, onChangeFilter: ocf, onClearFilter } = props;
const disableNames = props.disableNames || {};
const { formatMessage } = useIntl();
const filterGroupNames = (group) => {
const names = [];
for (const value of group.values) {
if (typeof value === 'object') {
if (!value.hidden) {
names.push({ value: value.name, displayName: value.displayName || value.name });
}
} else {
names.push({ value, displayName: value });
}
}
return names;
};
return (
<div data-test-filter-groups>
<AccordionSet>
{config.map((group, index) => (
<Accordion
label={group.label}
id={`${getStringFromMessage(group.label, formatMessage)}-${index}`}
headerProps={{ labelId: `${group.name}-${index}` }}
name={group.name}
key={`acc-${group.name}-${index}`}
separator={false}
header={FilterAccordionHeader}
disabled={disableNames[group.name]}
// If any of the filters start with this group's name, then we should display the 'clear all'
// button for this accordion.
displayClearButton={Object.keys(filters).some(filter => filter.startsWith(group.name))}
onClearFilter={onClearFilter}
>
<FilterGroup
key={`${group.label}-${index}`}
label={group.label}
groupName={group.name}
disabled={disableNames[group.name]}
names={filterGroupNames(group)}
filters={filters}
onChangeFilter={ocf}
/>
</Accordion>
))}
</AccordionSet>
</div>
);
};
FilterGroups.propTypes = {
config: PropTypes.arrayOf(
PropTypes.shape({
cql: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
name: PropTypes.string.isRequired,
values: PropTypes.arrayOf(
PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
cql: PropTypes.string.isRequired,
displayName: PropTypes.node,
name: PropTypes.string.isRequired,
}),
]),
).isRequired,
}),
).isRequired,
disableNames: PropTypes.shape({}),
filters: PropTypes.object.isRequired,
onChangeFilter: PropTypes.func.isRequired,
onClearFilter: PropTypes.func.isRequired,
};
export default FilterGroups;