Skip to content

Commit beef45c

Browse files
hextrazaSebastian Benjamin
andauthored
Typed filters (#326)
* Filter refactor * Fix FilterForm bugs, rename filters, add defaultOperator * Fix URL encoding behavior and tests * Fix ranges on > and < operators --------- Co-authored-by: Sebastian Benjamin <sebastiancbenjamin@gmail.com>
1 parent c819cb3 commit beef45c

File tree

5 files changed

+443
-307
lines changed

5 files changed

+443
-307
lines changed

jbrowse/src/client/JBrowse/VariantSearch/components/FilterForm.tsx

Lines changed: 116 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import FormControl from '@mui/material/FormControl';
99
import InputLabel from '@mui/material/InputLabel';
1010
import CardActions from '@mui/material/CardActions';
1111
import Card from '@mui/material/Card';
12-
import { FieldModel, Filter, getOperatorsForField, searchStringToInitialFilters } from '../../utils';
12+
import { FieldModel, Filter, searchStringToInitialFilters } from '../../utils';
13+
import { OperatorKey, OperatorRegistry } from '../operators';
1314
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
1415
import { Box, Menu } from '@mui/material';
1516
import { styled } from '@mui/material/styles';
@@ -77,19 +78,22 @@ const SubmitAndExternal = styled('div')(({ theme }) => ({
7778

7879
const FilterForm = (props: FilterFormProps ) => {
7980
const { handleQuery, setFilters, handleClose, fieldTypeInfo, allowedGroupNames, promotedFilters } = props
80-
const [filters, localSetFilters] = useState<Filter[]>(searchStringToInitialFilters(fieldTypeInfo.map((x) => x.name)));
81+
const initial = searchStringToInitialFilters(fieldTypeInfo.map(x => x.name))
82+
const [filters, localSetFilters] = useState<Filter[]>(
83+
initial.length ? initial : [ new Filter('', OperatorKey.None, '') ]
84+
)
8185
const [highlightedInputs, setHighlightedInputs] = useState<{ [index: number]: { field: boolean, operator: boolean, value: boolean } }>({});
8286
const [commonFilterMenuOpen, setCommonFilterMenuOpen] = useState<boolean>(false)
8387
const buttonRef = React.useRef(null);
8488

8589
const handleAddFilter = () => {
86-
localSetFilters([...filters, new Filter()]);
90+
localSetFilters([...filters, new Filter('', OperatorKey.None, '')]);
8791
};
8892

8993
const handleRemoveFilter = (index) => {
9094
// If it's the last filter, just reset its values to default empty values
9195
if (filters.length === 1) {
92-
localSetFilters([new Filter()]);
96+
localSetFilters([new Filter('', OperatorKey.None, '')]);
9397
} else {
9498
// Otherwise, remove the filter normally
9599
localSetFilters(
@@ -100,64 +104,81 @@ const FilterForm = (props: FilterFormProps ) => {
100104
}
101105
};
102106

103-
const handleFilterChange = (index, key, value) => {
104-
const newFilters = filters.map((filter, i) => {
105-
if (i === index) {
106-
const updatedFilter = Object.assign(new Filter(), { ...filter, [key]: value });
107-
108-
if (key === "operator") {
109-
if (value === "is empty" || value === "is not empty") {
110-
updatedFilter.value = '';
111-
}
112-
113-
if (value === "equals one of" || filter.operator === "equals one of") {
114-
updatedFilter.value = '';
115-
}
116-
}
107+
const handleFilterChange = (
108+
index: number,
109+
key: 'field' | 'operator' | 'value',
110+
value: any
111+
) => {
112+
const newFilters = filters.map((filter, i) => {
113+
if (i !== index) return filter;
114+
115+
const updatedFilter = Object.assign(
116+
new Filter('', OperatorKey.None, ''),
117+
{ ...filter, [key]: value }
118+
);
119+
120+
if (key === 'field') {
121+
const fieldInfo = fieldTypeInfo.find(f => f.name === value);
122+
const defaultOp = fieldInfo?.getDefaultOperator() ?? OperatorRegistry[OperatorKey.None];
123+
updatedFilter.operator = defaultOp;
124+
updatedFilter.value = '';
125+
}
126+
else if (key === 'operator') {
127+
updatedFilter.operator = OperatorRegistry[value as OperatorKey];
128+
updatedFilter.value = '';
129+
}
117130

118-
return updatedFilter;
119-
}
120-
return filter;
121-
});
131+
return updatedFilter;
132+
});
122133

123-
localSetFilters(newFilters);
134+
localSetFilters(newFilters);
124135
};
125136

137+
126138
const handleSubmit = (event) => {
127-
event.preventDefault();
128-
const highlightedInputs = {};
129-
130-
filters.forEach((filter, index) => {
131-
highlightedInputs[index] = { field: false, operator: false, value: false };
132-
133-
filter.field = filter.field ?? '';
134-
filter.operator = filter.operator ?? '';
135-
filter.value = filter.value ?? '';
136-
137-
if (filter.field === '') {
138-
highlightedInputs[index].field = true;
139-
}
140-
141-
if (filter.operator === '') {
142-
highlightedInputs[index].operator = true;
143-
}
144-
145-
if (filter.operator === 'is empty' || filter.operator === 'is not empty') {
146-
filter.value = '';
147-
} else if (filter.value === '') {
148-
highlightedInputs[index].value = true;
149-
}
150-
});
151-
152-
const isSingleEmptyFilter = filters.length === 1 && !filters[0].field && !filters[0].operator && !filters[0].value;
153-
154-
setHighlightedInputs(highlightedInputs);
155-
if (isSingleEmptyFilter || !Object.values(highlightedInputs).some(v => (v as any).field || (v as any).operator || (v as any).value)) {
156-
handleQuery(filters);
157-
setFilters(filters);
158-
handleClose();
139+
event.preventDefault()
140+
const highlighted: Record<number, { field: boolean; operator: boolean; value: boolean }> = {}
141+
142+
filters.forEach((filter, i) => {
143+
highlighted[i] = { field: false, operator: false, value: false }
144+
145+
filter.field = filter.field ?? ''
146+
filter.value = filter.value ?? ''
147+
148+
if (!filter.field) {
149+
highlighted[i].field = true
159150
}
160-
};
151+
152+
if (!filter.operator.key) {
153+
highlighted[i].operator = true
154+
}
155+
156+
if (
157+
filter.operator.key === OperatorKey.IsEmpty ||
158+
filter.operator.key === OperatorKey.IsNotEmpty
159+
) {
160+
filter.value = ''
161+
} else if (!filter.value) {
162+
highlighted[i].value = true
163+
}
164+
})
165+
166+
const isSingleEmpty =
167+
filters.length === 1 &&
168+
!filters[0].field &&
169+
!filters[0].operator.key &&
170+
!filters[0].value
171+
172+
setHighlightedInputs(highlighted)
173+
if (
174+
isSingleEmpty ||
175+
!Object.values(highlighted).some(v => v.field || v.operator || v.value)
176+
) {
177+
handleQuery(filters)
178+
setFilters(filters)
179+
handleClose?.()
180+
}
181+
}
161182

162183
const handleMenuClose = () => {
163184
setCommonFilterMenuOpen(false)
@@ -241,34 +262,33 @@ const FilterForm = (props: FilterFormProps ) => {
241262
/>
242263
</FormControlMinWidth>
243264

244-
<FormControlMinWidth sx={ highlightedInputs[index]?.operator ? highlightedSx : null } >
245-
<InputLabel id="operator-label">Operator</InputLabel>
246-
<Select
247-
labelId="operator-label"
248-
label="Operator"
249-
value={filter.operator}
250-
onChange={(event) =>
251-
handleFilterChange(index, "operator", event.target.value)
252-
}
253-
>
254-
<MenuItem value="None" style={{ display: 'none' }}>
255-
<em>None</em>
256-
</MenuItem>
257-
258-
{getOperatorsForField(fieldTypeInfo.find(obj => obj.name === filter.field)) ? (
259-
getOperatorsForField(fieldTypeInfo.find(obj => obj.name === filter.field)).map((operator) => (
260-
<MenuItem key={operator} value={operator}>
261-
{operator}
262-
</MenuItem>
263-
))
264-
) : (
265-
<MenuItem></MenuItem>
266-
)}
267-
268-
</Select>
265+
<FormControlMinWidth sx={highlightedInputs[index]?.operator ? highlightedSx : null}>
266+
<InputLabel id="operator-label">Operator</InputLabel>
267+
<Select
268+
labelId="operator-label"
269+
label="Operator"
270+
value={filter.operator.key}
271+
onChange={event =>
272+
handleFilterChange(index, "operator", event.target.value as OperatorKey)
273+
}
274+
>
275+
<MenuItem value={OperatorKey.None}>
276+
<em>None</em>
277+
</MenuItem>
278+
{(() => {
279+
const ops = fieldTypeInfo.find(f => f.name === filter.field)?.getOperators() ?? [];
280+
return ops.length > 0
281+
? ops.map(op => (
282+
<MenuItem key={op.key} value={op.key}>
283+
{op.label}
284+
</MenuItem>
285+
))
286+
: <MenuItem />;
287+
})()}
288+
</Select>
269289
</FormControlMinWidth>
270290

271-
{filter.operator === "equals one of" ? (
291+
{filter.operator.key === OperatorKey.EqualsOneOf ? (
272292
<FormControlMinWidth sx={ highlightedInputs[index]?.value ? highlightedSx : null } >
273293
<InputLabel id="value-select-label">Value</InputLabel>
274294
<Select
@@ -288,13 +308,14 @@ const FilterForm = (props: FilterFormProps ) => {
288308
) : fieldTypeInfo.find(obj => obj.name === filter.field)?.allowableValues?.length > 1 ? (
289309
<FormControlMinWidth sx={ highlightedInputs[index]?.value ? highlightedSx : null } >
290310
<AsyncSelect
311+
key={`async-${index}-${filter.operator.key}`}
291312
id={`value-select-${index}`}
292313
inputId={`value-select-${index}`}
293314
aria-labelledby={`value-select-${index}`}
294315
noOptionsMessage={() => 'Type to search...'}
295316
menuPortalTarget={document.body}
296317
menuPosition={'fixed'}
297-
isDisabled={filter.operator === "is empty" || filter.operator === "is not empty"}
318+
isDisabled={filter.operator.key === OperatorKey.IsEmpty || filter.operator.key === OperatorKey.IsNotEmpty}
298319
menuShouldBlockScroll={true}
299320
// See here: https://stackoverflow.com/questions/77625507/my-react-project-with-react-18-2-0-version-and-react-select-5-4-0-v
300321
styles={{ menuPortal: (base: any) => ({ ...base, zIndex: 9999 }) }}
@@ -309,8 +330,16 @@ const FilterForm = (props: FilterFormProps ) => {
309330
.map(value => ({label: value, value}))
310331
);
311332
}}
312-
onChange={(selected) => handleFilterChange(index, "value", selected?.length > 0 ? selected.map(s => s.value).join(',') : undefined)}
313-
value={filter.value ? filter.value.split(',').map(value => ({label: value, value})) : undefined}
333+
onChange={(selected) => {
334+
const arr = Array.isArray(selected) ? selected : [selected].filter(Boolean)
335+
const val = arr.map(s => s.value).join(',')
336+
handleFilterChange(index, 'value', val)
337+
}}
338+
value={
339+
filter.value
340+
? (filter.value as string).split(',').map(v => ({ label: v, value: v }))
341+
: null
342+
}
314343
/>
315344
</FormControlMinWidth>
316345
) : fieldTypeInfo.find(obj => obj.name === filter.field)?.allowableValues?.length > 0 ? (
@@ -321,7 +350,7 @@ const FilterForm = (props: FilterFormProps ) => {
321350
label="Value"
322351
id={`value-select-${index}`}
323352
value={filter.value}
324-
disabled={filter.operator === "is empty" || filter.operator === "is not empty"}
353+
disabled={filter.operator.key === OperatorKey.IsEmpty || filter.operator.key === OperatorKey.IsNotEmpty}
325354
onChange={(event) =>
326355
handleFilterChange(index, "value", event.target.value)
327356
}
@@ -340,7 +369,7 @@ const FilterForm = (props: FilterFormProps ) => {
340369
sx={ highlightedInputs[index]?.value ? highlightedSx : null }
341370
variant="outlined"
342371
value={filter.value}
343-
disabled={filter.operator === "is empty" || filter.operator === "is not empty"}
372+
disabled={filter.operator.key === OperatorKey.IsEmpty || filter.operator.key === OperatorKey.IsNotEmpty}
344373
onChange={(event) =>
345374
handleFilterChange(index, 'value', event.target.value)
346375
}

jbrowse/src/client/JBrowse/VariantSearch/components/VariantTableWidget.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
GridToolbarExport
1414
} from '@mui/x-data-grid';
1515
import SearchIcon from '@mui/icons-material/Search';
16+
import { OperatorKey } from '../operators'
1617
import LinkIcon from '@mui/icons-material/Link';
1718
import DownloadIcon from '@mui/icons-material/Download'
1819
import { ActionURL } from '@labkey/api';
@@ -25,6 +26,7 @@ import { NoAssemblyRegion } from '@jbrowse/core/util/types';
2526
import { toArray } from 'rxjs/operators';
2627
import {
2728
createEncodedFilterString,
29+
buildLuceneQuery,
2830
fetchFieldTypeInfo,
2931
fetchLuceneQuery,
3032
FieldModel,
@@ -78,7 +80,7 @@ const VariantTableWidget = observer(props => {
7880
const { page = pageSizeModel.page, pageSize = pageSizeModel.pageSize } = pageQueryModel;
7981
const { field = "genomicPosition", sort = false } = sortQueryModel[0] ?? {};
8082

81-
const encodedSearchString = createEncodedFilterString(passedFilters, false);
83+
const encodedSearchString = createEncodedFilterString(passedFilters);
8284
const currentUrl = new URL(window.location.href);
8385
currentUrl.searchParams.set("searchString", encodedSearchString);
8486
currentUrl.searchParams.set("page", page.toString());
@@ -98,7 +100,7 @@ const VariantTableWidget = observer(props => {
98100
const handleExport = () => {
99101
const currentUrl = new URL(window.location.href);
100102

101-
const searchString = createEncodedFilterString(filters, true);
103+
const searchString = encodeURIComponent(buildLuceneQuery(filters));
102104
const sortField = sortModel[0]?.field ?? 'genomicPosition';
103105
const sortDirection = sortModel[0]?.sort ?? false;
104106

@@ -552,14 +554,14 @@ const VariantTableWidget = observer(props => {
552554
<div style={{ marginBottom: "10px", display: "flex", alignItems: "center" }}>
553555
<div style={{ flex: 1 }}>
554556
{filters.map((filter, index) => {
555-
if ((filter as any).field && ((filter as any).operator === "is empty" || (filter as any).operator === "is not empty") && !(filter as any).value) {
557+
if ((filter as any).field && ((filter as any).operator.key === OperatorKey.IsEmpty || (filter as any).operator.key === OperatorKey.IsNotEmpty) && !(filter as any).value) {
556558
return (
557559
<Button
558560
key={index}
559561
onClick={() => setFilterModalOpen(true)}
560562
style={{ border: "1px solid gray", margin: "5px" }}
561563
>
562-
{`${(filter as any).field} ${(filter as any).operator}`}
564+
{`${(filter as any).field} ${(filter as any).operator.key}`}
563565
</Button>
564566
);
565567
}
@@ -577,7 +579,7 @@ const VariantTableWidget = observer(props => {
577579
key={index}
578580
onClick={() => setFilterModalOpen(true)}
579581
style={{ border: "1px solid gray", margin: "5px" }} >
580-
{`${(filter as any).field} ${(filter as any).operator} ${(filter as any).value}`}
582+
{`${(filter as any).field} ${(filter as any).operator.key} ${(filter as any).value}`}
581583
</Button>
582584
);
583585
})}

0 commit comments

Comments
 (0)