Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add boolean dropdown filters for heatmap #213

Merged
merged 16 commits into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/common/api/collectionsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@ export type ColumnMeta = {
max_value: undefined;
enum_values: string[];
}
| {
type: 'bool';
filter_strategy: undefined;
min_value: undefined;
max_value: undefined;
enum_values: undefined;
}
);

interface CollectionsResults {
Expand Down
4 changes: 3 additions & 1 deletion src/common/components/FilterChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const FilterChip = ({
...chipProps
}: FilterChipProps) => {
let filterString = '';
if (filter.value) {
if (filter.value !== undefined && filter.value !== null) {
if (
filter.type === 'date' ||
filter.type === 'int' ||
Expand All @@ -30,6 +30,8 @@ export const FilterChip = ({
})
.map((val) => val.toLocaleString());
filterString = `${minString} to ${maxString}`;
} else if (filter.type === 'bool') {
filterString = Boolean(filter.value).toString();
} else {
const val = filter.value;
if (typeof val === 'string') {
Expand Down
110 changes: 102 additions & 8 deletions src/features/collections/CollectionDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,15 @@ import {
Accordion,
AccordionSummary,
AccordionDetails,
FormControl,
Select,
MenuItem,
SelectChangeEvent,
} from '@mui/material';
import { useForm } from 'react-hook-form';
import { CollectionOverview } from './CollectionOverview';
import { FilterChip } from '../../common/components/FilterChip';
import { FilterContextTabs } from './Filters';
import { filterContextScope, FilterContextTabs } from './Filters';

export const detailPath = ':id';
export const detailDataProductPath = ':id/:data_product';
Expand All @@ -63,8 +67,8 @@ const pageConfig: Record<
}
> = {
samples: { features: ['filter'] },
biolog: { features: [] },
microtrait: { features: [] },
biolog: { features: ['filter'] },
microtrait: { features: ['filter'] },
genome_attribs: {
features: ['filter', 'match', 'search'],
},
Expand Down Expand Up @@ -178,7 +182,8 @@ export const CollectionDetail = () => {
!filter ||
filter.type === 'int' ||
filter.type === 'float' ||
filter.type === 'date'
filter.type === 'date' ||
filter.type === 'bool'
)
return;
if (e.target.value === filter.value) return;
Expand Down Expand Up @@ -401,6 +406,7 @@ const useFilterEntries = (collectionId: string) => {
};

const FilterChips = ({ collectionId }: { collectionId: string }) => {
const { columnMeta } = useFilters(collectionId);
const { filterEntries, clearFilterState, context } =
useFilterEntries(collectionId);
if (filterEntries.length === 0) return <></>;
Expand All @@ -411,7 +417,7 @@ const FilterChips = ({ collectionId }: { collectionId: string }) => {
<FilterChip
filter={filter}
key={`${column}-${context}-${filter.type}`}
name={column}
name={columnMeta?.[column]?.display_name || column}
onDelete={() => clearFilterState(column)}
/>
);
Expand Down Expand Up @@ -451,11 +457,19 @@ const FilterMenu = ({
setExpandedCategory(expanded ? category : '');
};

const menuLabel = {
DEFAULT: 'Filters',
genomes: 'Genome Filters',
samples: 'Sample Filters',
biolog: 'Biolog Filters',
microtrait: 'Microtrait Filters',
}[filterContextScope(context) || 'DEFAULT'];

if (open) {
return (
<div className={styles['filters_panel']}>
<Stack direction="row" className={styles['filters-panel-header']}>
<h3>Genome Filters</h3>
<h3>{menuLabel}</h3>
<Stack direction="row" spacing={1}>
<Button
size="small"
Expand Down Expand Up @@ -503,7 +517,8 @@ const FilterMenu = ({
<AccordionDetails>
<Stack spacing={3}>
{category.filters.flatMap(([column, filter]) => {
const hasVal = Boolean(filter.value);
const hasVal =
filter.value !== undefined && filter.value !== null;
return (
<Stack
className={styles['filter-container']}
Expand Down Expand Up @@ -596,6 +611,15 @@ const FilterControls = ({
collectionId={collectionId}
/>
);
} else if (filter.type === 'bool') {
return (
<BooleanFilterControls
column={column}
filter={filter}
context={context}
collectionId={collectionId}
/>
);
}
return null;
};
Expand Down Expand Up @@ -859,7 +883,7 @@ const RangeFilterControls = ({
const [filterMin, filterMax] = [filter.min_value, filter.max_value];
useEffect(() => {
//Clear when the filter is cleared
if (!filter.value) {
if (filter.value === undefined || filter.value === null) {
setValue('min', filterMin);
setValue('max', filterMax);
setSliderPosition([filterMin, filterMax]);
Expand Down Expand Up @@ -977,3 +1001,73 @@ const TextFilterControls = ({
/>
);
};

const BooleanFilterControls = ({
column,
filter,
collectionId,
context,
}: FilterControlProps & {
filter: { type: 'bool' };
}) => {
const dispatch = useAppDispatch();
// Convert boolean values to proper dropdown values
const getBooleanDropdownValue = (value?: number | boolean) => {
if (value === true || value === 1) {
return 'true';
} else if (value === false || value === 0) {
return 'false';
} else {
return 'any';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A helpful change!

}
};
const [selectValue, setSelectValue] = useState<string>(() => {
return getBooleanDropdownValue(filter.value);
});

const handleChange = (event: SelectChangeEvent) => {
setSelectValue(event.target.value as string);
};

// In order not to create a dependency loop when filter changes within the below effect,
// use a filter ref
const filterRef = useRef(filter);
filterRef.current = filter;

useEffect(() => {
let value;
if (selectValue === 'true') {
value = 1;
} else if (selectValue === 'false') {
value = 0;
}
dispatch(
setFilter([
collectionId,
context,
column,
{ ...filterRef.current, value },
])
);
}, [selectValue, collectionId, context, column, dispatch]);

useEffect(() => {
//Clear when the filter is cleared
// Use 'any' for empty value so the select stays controlled and shows "Any" in the dropdown
if (filter.value === undefined || filter.value === null) {
codytodonnell marked this conversation as resolved.
Show resolved Hide resolved
setSelectValue('any');
} else if (filter.value !== filterRef.current.value) {
setSelectValue(getBooleanDropdownValue(filter.value));
}
}, [filter.value, setSelectValue]);

return (
<FormControl fullWidth>
<Select value={selectValue} onChange={handleChange}>
<MenuItem value="true">True</MenuItem>
<MenuItem value="false">False</MenuItem>
<MenuItem value="any">Any</MenuItem>
</Select>
</FormControl>
);
};
116 changes: 75 additions & 41 deletions src/features/collections/Filters.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Chip, Stack, Tab, Tabs } from '@mui/material';
import { useCallback, useEffect, useRef } from 'react';
import { toast } from 'react-hot-toast';
Expand Down Expand Up @@ -147,25 +148,22 @@ export const useContextFilterQueryManagement = (
<T extends CommonResult>(
result: T,
collectionId: string | undefined
): { filterData?: T['data']; context?: FilterContext } => {
): { filterData?: T['data'] } => {
if (result.isError) {
const err = parseError(result.error);
if (err.name !== 'AbortError')
toast('Filter Loading failed: ' + parseError(result.error).message);
}
const context = result.requestId
? requestContext.current[result.requestId]
: undefined;
if (!collectionId || !result?.data || !context || result.isError)
return {};
return { filterData: result.data, context };

if (!collectionId || !result?.data || result.isError) return {};
return { filterData: result.data };
},
[]
);

const handleTableFilters = useCallback(
<T extends typeof genomeResult | typeof sampleResult>(result: T) => {
const { filterData, context } = handleResult(result, collectionId);
const { filterData } = handleResult(result, collectionId);
if (!filterData || !collectionId || !context) return;
dispatch(clearFiltersAndColumnMeta([collectionId, context]));
filterData.columns.forEach((column) => {
Expand Down Expand Up @@ -214,50 +212,88 @@ export const useContextFilterQueryManagement = (
}
});
},
[collectionId, dispatch, handleResult]
[collectionId, dispatch, handleResult, context]
);

const handleHeatmapFilters = useCallback(
<T extends typeof biologResult | typeof microtraitResult>(result: T) => {
const { filterData, context } = handleResult(result, collectionId);
const { filterData } = handleResult(result, collectionId);
if (!filterData || !collectionId || !context) return;
dispatch(clearFiltersAndColumnMeta([collectionId, context]));
filterData.categories.forEach((category) => {
category.columns.forEach((column) => {
const current =
filtersRef.current && filtersRef.current[column.col_id];
const filterMeta: ColumnMeta = {
type: 'int',
key: column.col_id,
max_value: filterData.max_value,
min_value: filterData.min_value,
category: category.category,
description: column.description,
display_name: column.name,
filter_strategy: undefined,
enum_values: undefined,
};
dispatch(
setColumnMeta([collectionId, context, column.col_id, filterMeta])
);
dispatch(
setFilter([
collectionId,
context,
column.col_id,
{
type: filterMeta.type,
min_value: filterMeta.min_value,
max_value: filterMeta.max_value,
value:
current?.type === column.type ? current.value : undefined,
},
])
);
if (column.type === 'bool') {
dispatch(
setColumnMeta([
collectionId,
context,
column.col_id,
{
type: 'bool',
key: column.col_id,
max_value: undefined,
min_value: undefined,
category: category.category,
description: column.description,
display_name: column.name,
filter_strategy: undefined,
enum_values: undefined,
},
])
);
dispatch(
setFilter([
collectionId,
context,
column.col_id,
{
type: 'bool',
value:
current?.type === column.type
? typeof current.value !== 'undefined'
? Boolean(current.value)
: undefined
: undefined,
},
])
);
} else {
// column.type === 'count'
const filterMeta: ColumnMeta = {
type: 'int',
key: column.col_id,
max_value: filterData.max_value,
min_value: filterData.min_value,
category: category.category,
description: column.description,
display_name: column.name,
filter_strategy: undefined,
enum_values: undefined,
};
dispatch(
setColumnMeta([collectionId, context, column.col_id, filterMeta])
);
dispatch(
setFilter([
collectionId,
context,
column.col_id,
{
type: 'int',
min_value: filterMeta.min_value,
max_value: filterMeta.max_value,
value:
current?.type === column.type ? current.value : undefined,
},
])
);
}
});
});
},
[collectionId, dispatch, handleResult]
[collectionId, dispatch, handleResult, context]
);

// When the context (or collection) changes, set the filter context and trigger appropriate query
Expand All @@ -277,8 +313,6 @@ export const useContextFilterQueryManagement = (
} else {
throw new Error(`No filter query matches filter context "${context}"`);
}
requestContext.current[request.requestId] = context;

return () => {
// Abort request if context changes while running (prevents race conditions)
if (request) {
Expand Down
Loading
Loading