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

Feature/standardize property filters #71

Merged
merged 10 commits into from
Mar 26, 2024
120 changes: 99 additions & 21 deletions packages/core/components/AnnotationFilterForm/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Spinner, SpinnerSize } from "@fluentui/react";
import { find, isNil } from "lodash";
import * as React from "react";
import { useDispatch, useSelector } from "react-redux";

import { AnnotationType } from "../../entity/AnnotationFormatter";
import FileFilter from "../../entity/FileFilter";
import ListPicker, { ListItem } from "../../components/ListPicker";
import ListPicker, { ListItem } from "../ListPicker";
import NumberRangePicker from "../NumberRangePicker";
import SearchBoxForm from "../SearchBoxForm";
import DateRangePicker from "../DateRangePicker";
import { interaction, metadata, selection } from "../../state";
import useAnnotationValues from "./useAnnotationValues";

Expand All @@ -20,11 +24,11 @@ interface AnnotationFilterFormProps {
*/
export default function AnnotationFilterForm(props: AnnotationFilterFormProps) {
const { annotationName } = props;

const dispatch = useDispatch();
const annotations = useSelector(metadata.selectors.getAnnotations);
const fileFilters = useSelector(selection.selectors.getAnnotationFilters);
const annotations = useSelector(metadata.selectors.getSupportedAnnotations);
const fileFilters = useSelector(selection.selectors.getFileFilters);
const annotationService = useSelector(interaction.selectors.getAnnotationService);
// TODO: annotationService throws an error for annotations that aren't in the API
const [annotationValues, isLoading, errorMessage] = useAnnotationValues(
annotationName,
annotationService
Expand All @@ -35,6 +39,11 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) {
[annotations, annotationName]
);

const currentValues = React.useMemo(
() => find(fileFilters, (annotation) => annotation.name === annotationName),
[annotationName, fileFilters]
);

const items = React.useMemo<ListItem[]>(() => {
const appliedFilters = fileFilters
.filter((filter) => filter.name === annotation?.name)
Expand Down Expand Up @@ -89,22 +98,91 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) {
dispatch(selection.actions.addFileFilter(filters));
};

// TODO, return different pickers based on annotation type
// e.g., a date picker, a range (numeric) picker, etc.
switch (annotation?.type) {
case AnnotationType.STRING:
// prettier-ignore
default: // FALL-THROUGH
return (
<ListPicker
items={items}
loading={isLoading}
errorMessage={errorMessage}
onDeselect={onDeselect}
onDeselectAll={onDeselectAll}
onSelect={onSelect}
onSelectAll={onSelectAll}
/>
);
function onSearch(filterValue: string) {
if (filterValue && filterValue.trim()) {
const fileFilter = new FileFilter(annotationName, filterValue);
if (currentValues) {
dispatch(selection.actions.removeFileFilter(currentValues));
}
dispatch(selection.actions.addFileFilter(fileFilter));
}
}

function onReset() {
if (currentValues) {
dispatch(selection.actions.removeFileFilter(currentValues));
}
}

const listPicker = () => {
return (
<ListPicker
items={items}
loading={isLoading}
errorMessage={errorMessage}
onDeselect={onDeselect}
onDeselectAll={onDeselectAll}
onSelect={onSelect}
onSelectAll={onSelectAll}
/>
);
};

if (isLoading) {
return (
<div>
<Spinner size={SpinnerSize.small} />
</div>
);
}

const customInput = () => {
switch (annotation?.type) {
case AnnotationType.DATE:
case AnnotationType.DATETIME:
return (
<DateRangePicker
onSearch={onSearch}
onReset={onReset}
currentRange={currentValues}
/>
);
case AnnotationType.NUMBER:
return (
<NumberRangePicker
items={items}
loading={isLoading}
errorMessage={errorMessage}
onSearch={onSearch}
onReset={onReset}
currentRange={currentValues}
units={annotation?.units}
/>
);
case AnnotationType.DURATION:
case AnnotationType.STRING:
// prettier-ignore
default: // FALL-THROUGH
return (
<> {listPicker()} </>
);
}
};
// Use the checkboxes if values exist and are few enough to reasonably scroll through
if (items.length > 0 && items.length <= 100) {
return <> {listPicker()} </>;
}
// Use a search box if the API does not return values to select
// (e.g., it's not an AICS annotation)
else if (items.length === 0 && annotation?.type === AnnotationType.STRING) {
return (
<SearchBoxForm
onSearch={onSearch}
onReset={onReset}
fieldName={annotation.name}
currentValue={currentValues}
/>
);
}
return <> {customInput()} </>;
}
4 changes: 2 additions & 2 deletions packages/core/components/AnnotationList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const SEARCH_ICON_PATH_DATA =
*/
export default function AnnotationList(props: AnnotationListProps) {
const dispatch = useDispatch();
const filters = useSelector(selection.selectors.getAnnotationFilters);
const filters = useSelector(selection.selectors.getFileFilters);
const annotationsLoading = useSelector(
selection.selectors.getAvailableAnnotationsForHierarchyLoading
);
Expand Down Expand Up @@ -94,7 +94,7 @@ export default function AnnotationList(props: AnnotationListProps) {
return (
<div className={classNames(styles.root, props.className)}>
<h3 className={styles.title}>Available Annotations</h3>
<h6 className={styles.description}>Drag any annotation to the box above</h6>
<h6 className={styles.description}>Drag annotations to the box above</h6>
<div className={styles.listContainer} id={Tutorial.ANNOTATION_LIST_ID}>
<div className={styles.searchBox}>
<SvgIcon
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { annotationsJson } from "../../../entity/Annotation/mocks";
import FileFilter from "../../../entity/FileFilter";
import { initialState, reducer, reduxLogics, selection } from "../../../state";
import { DND_LIST_CONTAINER_ID } from "../../DnDList/DnDList";
import { SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS } from "../../../constants";

import styles from "../AnnotationList.module.css";

Expand Down Expand Up @@ -55,7 +56,9 @@ describe("<AnnotationList />", () => {
const allAnnotationDisplayNames = annotationsJson.map(
(annotation) => annotation.annotationDisplayName
);
expect(queryNumberListItems()).to.equal(allAnnotationDisplayNames.length);
expect(queryNumberListItems()).to.equal(
allAnnotationDisplayNames.length + SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS.length
);
allAnnotationDisplayNames.forEach((annotation) => {
expect(getByText(annotation)).to.exist;
});
Expand Down Expand Up @@ -187,8 +190,15 @@ describe("<AnnotationList />", () => {

it("does not exist when no annotations are filtered", () => {
// Arrange
const state = {
...initialState,
selection: {
...initialState.selection,
filters: [],
},
};
const { store } = configureMockStore({
state: mergeState(initialState, {
state: mergeState(state, {
metadata: {
annotations: annotationsJson.map(
(annotation) => new Annotation(annotation)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ const FILTERS_APPLIED_COLOR_INDICATOR = "#0b9aab"; // style guide torquise
*/
export default function AnnotationFilter(props: FilterProps) {
const { annotationName, iconColor, styleOverrides } = props;

const fileFilters = useSelector(selection.selectors.getAnnotationFilters);
const fileFilters = useSelector(selection.selectors.getFileFilters);

const annotationIsFiltered = React.useMemo(
() => fileFilters.some((filter) => filter.name === annotationName),
Expand All @@ -38,7 +37,7 @@ export default function AnnotationFilter(props: FilterProps) {
return <AnnotationFilterForm annotationName={annotationName} />;
},
directionalHint: DirectionalHint.rightTopEdge,
title: "Exclusively Include",
title: `Filter by ${annotationName}`,
shouldFocusOnMount: true,
items: [{ key: "placeholder" }], // necessary to have a non-empty items list to have `onRenderMenuList` called
};
Expand Down
7 changes: 5 additions & 2 deletions packages/core/components/AnnotationSidebar/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import { DnDItem } from "../../components/DnDList/DnDList";
import Annotation from "../../entity/Annotation";
import FileFilter from "../../entity/FileFilter";
import { metadata, selection } from "../../state";
import { TOP_LEVEL_FILE_ANNOTATION_NAMES } from "../../constants";

export const getAnnotationListItems = createSelector(
[
metadata.selectors.getSortedAnnotations,
metadata.selectors.getSupportedAnnotations,
selection.selectors.getAvailableAnnotationsForHierarchy,
selection.selectors.getAnnotationHierarchy,
selection.selectors.getAnnotationFilters,
selection.selectors.getFileFilters,
],
(
annotations: Annotation[],
Expand All @@ -37,6 +38,8 @@ export const getAnnotationListItems = createSelector(
filtered: filteredAnnotationNames.has(annotation.name),
id: annotation.name,
title: annotation.displayName,
type: annotation.type,
isFileProperty: TOP_LEVEL_FILE_ANNOTATION_NAMES.includes(annotation.name),
}))
// Sort the filtered annotations to the top
.sort((a, b) => (a.filtered && !b.filtered ? -1 : 1))
Expand Down
32 changes: 23 additions & 9 deletions packages/core/components/AnnotationSidebar/test/selectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { annotationsJson } from "../../../entity/Annotation/mocks";
import * as annotationSelectors from "../selectors";
import { initialState } from "../../../state";
import FileFilter from "../../../entity/FileFilter";
import { SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS } from "../../../constants";

describe("<AnnotationSidebar /> selectors", () => {
describe("getAnnotationListItems", () => {
Expand All @@ -18,9 +19,12 @@ describe("<AnnotationSidebar /> selectors", () => {
});

const listItems = annotationSelectors.getAnnotationListItems(state);
expect(listItems.length).to.equal(annotationsJson.length);
expect(listItems.length).to.equal(
annotationsJson.length + SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS.length
);

const first = listItems[0]; // items are sorted according to Annotation::sort
// items are sorted according to Annotation::sort but file properties go first
const first = listItems[SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS.length];
expect(first).to.have.property("id");
expect(first).to.have.property("description", "AICS cell line");
expect(first).to.have.property("title", "Cell line");
Expand All @@ -31,17 +35,23 @@ describe("<AnnotationSidebar /> selectors", () => {
new FileFilter("Cell Line", "AICS-0"),
new FileFilter("Date Created", "01/10/15"),
];
const state = mergeState(initialState, {
metadata: {
annotations: map(annotationsJson, (annotation) => new Annotation(annotation)),
},
const filteredState = {
...initialState,
selection: {
...initialState.selection,
filters,
},
};
const state = mergeState(filteredState, {
metadata: {
annotations: map(annotationsJson, (annotation) => new Annotation(annotation)),
},
});

const listItems = annotationSelectors.getAnnotationListItems(state);
expect(listItems.length).to.equal(annotationsJson.length);
expect(listItems.length).to.equal(
annotationsJson.length + SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS.length
);

listItems.forEach((item) => {
const filtered = filters.findIndex((f) => f.name === item.id) !== -1;
Expand All @@ -64,7 +74,9 @@ describe("<AnnotationSidebar /> selectors", () => {
});

const listItems = annotationSelectors.getAnnotationListItems(state);
expect(listItems.length).to.equal(annotationsJson.length);
expect(listItems.length).to.equal(
annotationsJson.length + SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS.length
);

listItems.forEach((item) => {
const disabled = !availableAnnotationsForHierarchySet.has(item.id);
Expand All @@ -85,7 +97,9 @@ describe("<AnnotationSidebar /> selectors", () => {
});

const listItems = annotationSelectors.getAnnotationListItems(state);
expect(listItems.length).to.equal(annotationsJson.length);
expect(listItems.length).to.equal(
annotationsJson.length + SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS.length
);

listItems.forEach((item) => {
expect(item).to.have.property("disabled", false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.filter-input, .date-range-box {
border: none;
flex: auto;
}

.date-range-box {
display: flex;
}

.date-range-box {
max-height: 32px;
overflow: hidden;
}

.date-range-separator {
display: flex;
font-size: small;
padding: 0 2px;
}

.date-range-separator i {
margin: auto;
}
Loading
Loading