diff --git a/packages/core/components/AnnotationFilterForm/AnnotationFilterForm.module.css b/packages/core/components/AnnotationFilterForm/AnnotationFilterForm.module.css
index 4b61689b6..cfbee08d4 100644
--- a/packages/core/components/AnnotationFilterForm/AnnotationFilterForm.module.css
+++ b/packages/core/components/AnnotationFilterForm/AnnotationFilterForm.module.css
@@ -19,6 +19,10 @@
padding-bottom: 16px;
}
+.choice-group {
+ margin-bottom: 6px;
+}
+
.footer {
background-color: var(--primary-background-color);
padding: 16px 30px 16px;
diff --git a/packages/core/components/AnnotationFilterForm/SearchBoxForm/SearchBoxForm.module.css b/packages/core/components/AnnotationFilterForm/SearchBoxForm/SearchBoxForm.module.css
index 5e171d390..6fd74581e 100644
--- a/packages/core/components/AnnotationFilterForm/SearchBoxForm/SearchBoxForm.module.css
+++ b/packages/core/components/AnnotationFilterForm/SearchBoxForm/SearchBoxForm.module.css
@@ -1,54 +1,54 @@
-.choice-group {
+.toggle {
margin-bottom: 6px;
}
-.choice-group input:disabled + label {
+.toggle input:disabled + label {
cursor: not-allowed;
opacity: 0.5;
}
-.choice-group :is(input, label, span) {
+.toggle :is(input, label, span), .toggle-label {
color: var(--primary-text-color);
+ font-size: var(--l-paragraph-size);
+ font-weight: 500;
}
-.choice-group label > span {
- font-size: var(--xs-paragraph-size);
+.toggle label:hover > span {
+ color: var(--highlight-text-color) !important;
}
-.choice-group label:hover > span {
- color: var(--highlight-text-color) !important;
+.toggle-pill-off {
+ border-color: var(--border-color) !important;
+ background-color: unset !important;
}
-.choice-group label::before, .choice-group label:hover::before {
- background-color: var(--highlight-text-color);
- border-color: var(--highlight-text-color);
+.toggle-pill-off {
+ border-color: var(--border-color) !important;
}
-.choice-group label::after {
- background-color: var(--aqua);
- border-color: var(--aqua);
+.toggle-pill-off > span {
+ background-color: var(--border-color) !important;
}
-.choice-group label:hover::after {
- background-color: var(--bright-aqua) !important;
- border-color: var(--bright-aqua) !important;
+.toggle-pill-off:hover > span {
+ background-color: var(--primary-text-color) !important;
}
-.choice-group > div > div {
- display: flex;
+.toggle-pill-on {
+ border: none !important;
+ background-color: var(--aqua) !important;
}
-.choice-group > div > div > div:not(:first-child) {
- margin-left: 6px;
+.toggle-pill-on > span {
+ background-color: var(--highlight-hover-text-color) !important;
}
-.container {
- padding: 8px;
+.toggle-pill-on:hover {
+ background-color: var(--dark-aqua) !important;
}
-.list-picker {
- padding: 0;
- width: 100%;
+.container {
+ padding: 8px;
}
.title {
diff --git a/packages/core/components/AnnotationFilterForm/SearchBoxForm/index.tsx b/packages/core/components/AnnotationFilterForm/SearchBoxForm/index.tsx
index 1cca4a6b5..27eb2df02 100644
--- a/packages/core/components/AnnotationFilterForm/SearchBoxForm/index.tsx
+++ b/packages/core/components/AnnotationFilterForm/SearchBoxForm/index.tsx
@@ -1,9 +1,10 @@
+import { Toggle } from "@fluentui/react";
import classNames from "classnames";
import * as React from "react";
import { ListItem } from "../../ListPicker/ListRow";
import SearchBox from "../../SearchBox";
-import FileFilter from "../../../entity/FileFilter";
+import FileFilter, { FilterType } from "../../../entity/FileFilter";
import styles from "./SearchBoxForm.module.css";
@@ -15,22 +16,44 @@ interface SearchBoxFormProps {
onDeselectAll: () => void;
onSelect?: (item: ListItem) => void;
onSelectAll: () => void;
- onSearch: (filterValue: string) => void;
+ onSearch: (filterValue: string, type: FilterType) => void;
fieldName: string;
+ fuzzySearchEnabled?: boolean;
defaultValue: FileFilter | undefined;
}
/**
* This component renders a simple form for searching on text values
+ * with a toggle for exact vs fuzzy (non-exact) search matching
*/
export default function SearchBoxForm(props: SearchBoxFormProps) {
+ const [isFuzzySearching, setIsFuzzySearching] = React.useState(!!props?.fuzzySearchEnabled);
+
+ function onSearchSubmitted(value: string) {
+ props.onSearch(value, isFuzzySearching ? FilterType.FUZZY : FilterType.DEFAULT);
+ }
+
return (
{props.title}
+
setIsFuzzySearching(!!checked)}
+ title={`Turn ${isFuzzySearching ? "off" : "on"} fuzzy search (non-exact searching)`}
+ styles={{
+ label: styles.toggleLabel,
+ pill: isFuzzySearching ? styles.togglePillOn : styles.togglePillOff,
+ }}
+ />
diff --git a/packages/core/components/AnnotationFilterForm/SearchBoxForm/test/SearchBoxForm.test.tsx b/packages/core/components/AnnotationFilterForm/SearchBoxForm/test/SearchBoxForm.test.tsx
index 4460f4550..6710c27a6 100644
--- a/packages/core/components/AnnotationFilterForm/SearchBoxForm/test/SearchBoxForm.test.tsx
+++ b/packages/core/components/AnnotationFilterForm/SearchBoxForm/test/SearchBoxForm.test.tsx
@@ -2,32 +2,68 @@ import { render, fireEvent } from "@testing-library/react";
import { expect } from "chai";
import { noop } from "lodash";
import * as React from "react";
+import sinon from "sinon";
import SearchBoxForm from "..";
describe("", () => {
- // TODO: Update with the fuzzy search toggle when filters are complete
- it.skip("renders a list picker when 'List picker' option is selection", () => {
+ it("renders clickable fuzzy search toggle", () => {
+ // Arrange
+ const onSearch = sinon.spy();
+
+ const { getByText, getByRole } = render(
+
+ );
+
+ // Consistency checks
+ expect(getByText("Off")).to.exist;
+ expect(onSearch.called).to.equal(false);
+
// Act
- const { getByText, getByTestId } = render(
+ fireEvent.click(getByRole("switch"));
+ // Enter values
+ fireEvent.change(getByRole("searchbox"), {
+ target: {
+ value: "bar",
+ },
+ });
+ fireEvent.keyDown(getByRole("searchbox"), {
+ key: "Enter",
+ code: "Enter",
+ keyCode: 13,
+ charCode: 13,
+ });
+
+ // Assert
+ expect(getByText("On")).to.exist;
+ expect(onSearch.called).to.equal(true);
+ });
+
+ it("defaults to on when fuzzy searching prop is passed as true", () => {
+ // Arrange
+ const { getByText, getByRole } = render(
);
+ // Consistency check
+ expect(getByText("On")).to.exist;
- // Sanity check
- expect(() => getByTestId("list-picker")).to.throw();
-
- // Select 'List picker' filter type
- fireEvent.click(getByText("List picker"));
+ // Act
+ fireEvent.click(getByRole("switch"));
- expect(() => getByTestId("list-picker")).to.not.throw();
+ // Assert
+ expect(getByText("Off")).to.exist;
});
});
diff --git a/packages/core/components/AnnotationFilterForm/index.tsx b/packages/core/components/AnnotationFilterForm/index.tsx
index 7f08b3005..7c3ed7c11 100644
--- a/packages/core/components/AnnotationFilterForm/index.tsx
+++ b/packages/core/components/AnnotationFilterForm/index.tsx
@@ -1,4 +1,4 @@
-import { Spinner, SpinnerSize } from "@fluentui/react";
+import { IChoiceGroupOption, Spinner, SpinnerSize } from "@fluentui/react";
import classNames from "classnames";
import { isNil } from "lodash";
import * as React from "react";
@@ -6,6 +6,7 @@ import { useDispatch, useSelector } from "react-redux";
import useAnnotationValues from "./useAnnotationValues";
import SearchBoxForm from "./SearchBoxForm";
+import ChoiceGroup from "../ChoiceGroup";
import DateRangePicker from "../DateRangePicker";
import ListPicker from "../ListPicker";
import { ListItem } from "../ListPicker/ListRow";
@@ -13,7 +14,7 @@ import NumberRangePicker from "../NumberRangePicker";
import Annotation from "../../entity/Annotation";
import AnnotationName from "../../entity/Annotation/AnnotationName";
import { AnnotationType } from "../../entity/AnnotationFormatter";
-import FileFilter from "../../entity/FileFilter";
+import FileFilter, { FilterType } from "../../entity/FileFilter";
import { interaction, selection } from "../../state";
import styles from "./AnnotationFilterForm.module.css";
@@ -31,17 +32,32 @@ interface AnnotationFilterFormProps {
export default function AnnotationFilterForm(props: AnnotationFilterFormProps) {
const dispatch = useDispatch();
const allFilters = useSelector(selection.selectors.getFileFilters);
+ const fuzzyFilters = useSelector(selection.selectors.getFuzzyFilters);
const annotationService = useSelector(interaction.selectors.getAnnotationService);
const [annotationValues, isLoading, errorMessage] = useAnnotationValues(
props.annotation.name,
annotationService
);
+ const fuzzySearchEnabled = React.useMemo(
+ () => !!fuzzyFilters?.some((filter) => filter.name === props.annotation.name),
+ [fuzzyFilters, props.annotation]
+ );
+
const filtersForAnnotation = React.useMemo(
() => allFilters.filter((filter) => filter.name === props.annotation.name),
[allFilters, props.annotation]
);
+ // Assume all filters use same type
+ const defaultFilterType = React.useMemo(
+ () => filtersForAnnotation?.[0]?.type ?? FilterType.DEFAULT,
+ [filtersForAnnotation]
+ );
+
+ const [filterType, setFilterType] = React.useState(defaultFilterType);
+
+ // Propagate regular file filter values from state into UI
const items = React.useMemo(() => {
const appliedFilters = new Set(filtersForAnnotation.map((filter) => filter.value));
@@ -53,48 +69,64 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) {
}, [props.annotation, annotationValues, filtersForAnnotation]);
const onDeselectAll = () => {
+ // remove all regular filters for this annotation
dispatch(selection.actions.removeFileFilter(filtersForAnnotation));
};
const onDeselect = (item: ListItem) => {
- const fileFilter = new FileFilter(
- props.annotation.name,
- isNil(props.annotation.valueOf(item.value))
- ? item.value
- : props.annotation.valueOf(item.value)
- );
- dispatch(selection.actions.removeFileFilter(fileFilter));
+ dispatch(selection.actions.removeFileFilter(createFileFilter(item)));
};
const onSelect = (item: ListItem) => {
- const fileFilter = new FileFilter(
+ dispatch(selection.actions.changeFileFilterType(props.annotation.name, FilterType.DEFAULT));
+ dispatch(selection.actions.addFileFilter(createFileFilter(item)));
+ };
+
+ // TODO: Should this select ALL or just the visible items in list?
+ const onSelectAll = () => {
+ dispatch(selection.actions.changeFileFilterType(props.annotation.name, FilterType.DEFAULT));
+ dispatch(selection.actions.addFileFilter(items.map((item) => createFileFilter(item))));
+ };
+
+ const createFileFilter = (item: ListItem) => {
+ return new FileFilter(
props.annotation.name,
isNil(props.annotation.valueOf(item.value))
? item.value
- : props.annotation.valueOf(item.value)
+ : props.annotation.valueOf(item.value),
+ filterType
);
- dispatch(selection.actions.addFileFilter(fileFilter));
};
- const onSelectAll = () => {
- const filters = items.map(
- (item) =>
- new FileFilter(
- props.annotation.name,
- isNil(props.annotation.valueOf(item.value))
- ? item.value
- : props.annotation.valueOf(item.value)
- )
- );
- dispatch(selection.actions.addFileFilter(filters));
+ const onFilterTypeOptionChange = (option: IChoiceGroupOption | undefined) => {
+ // Verify that filter type is changing to avoid dispatching unnecessary clean-up actions
+ if (!!option?.key && option?.key !== filterType) {
+ setFilterType(option.key as FilterType);
+ // Selecting ANY or NONE should automatically re-trigger search and re-render dom,
+ // but selecting SOME shouldn't trigger anything until a value is selected
+ // or a search term is entered
+ switch (option.key) {
+ case FilterType.DEFAULT:
+ return; // No further action needed, dispatch on search instead
+ case FilterType.EXCLUDE:
+ case FilterType.ANY:
+ default:
+ dispatch(
+ selection.actions.changeFileFilterType(
+ props.annotation.name,
+ option.key as FilterType
+ )
+ );
+ }
+ }
};
- function onSearch(filterValue: string) {
+ function onSearch(filterValue: string, type: FilterType = FilterType.DEFAULT) {
if (filterValue && filterValue.trim()) {
dispatch(
selection.actions.setFileFilters([
...allFilters.filter((filter) => filter.name !== props.annotation.name),
- new FileFilter(props.annotation.name, filterValue),
+ new FileFilter(props.annotation.name, filterValue, type),
])
);
}
@@ -163,6 +195,7 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) {
onSelectAll={onSelectAll}
onDeselectAll={onDeselectAll}
onSearch={onSearch}
+ fuzzySearchEnabled={fuzzySearchEnabled}
fieldName={props.annotation.displayName}
defaultValue={filtersForAnnotation?.[0]}
/>
@@ -179,8 +212,37 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) {
Filter {props.annotation.displayName} by
+ 0 ? "(s)" : ""}`,
+ },
+ {
+ key: FilterType.ANY,
+ text: "Any value",
+ },
+ {
+ key: FilterType.EXCLUDE,
+ text: "No value (blank)",
+ },
+ ]}
+ onChange={(_, option) => onFilterTypeOptionChange(option)}
+ />
- {searchFormType()}
+ {filterType === FilterType.DEFAULT || filterType === FilterType.FUZZY ? (
+ searchFormType()
+ ) : (
+
+ All files with {filterType === FilterType.EXCLUDE ? "no " : "any "}
+ value for {props.annotation.displayName}
+
+ )}
);
}
diff --git a/packages/core/components/ChoiceGroup/ChoiceGroup.module.css b/packages/core/components/ChoiceGroup/ChoiceGroup.module.css
new file mode 100644
index 000000000..63d12b99a
--- /dev/null
+++ b/packages/core/components/ChoiceGroup/ChoiceGroup.module.css
@@ -0,0 +1,47 @@
+
+.choice-group input:disabled + label {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+.choice-group :is(input, label, span) {
+ color: var(--primary-text-color);
+}
+
+.choice-group label > span {
+ font-size: var(--s-paragraph-size);
+ margin-right: 5px;
+ margin-top: 2px;
+ padding-left: 24px !important;
+}
+
+.choice-group label:hover > span {
+ color: var(--highlight-text-color) !important;
+}
+
+.choice-group label::before, .choice-group label:hover::before {
+ background-color: unset !important;
+ border-color: var(--aqua);
+ width: 16px;
+ height: 16px;
+ top: 2.1px;
+ left: 2.1px;
+}
+
+.choice-group label::after {
+ background-color: var(--aqua);
+ border-color: var(--aqua);
+}
+
+.choice-group label:hover::after {
+ background-color: var(--bright-aqua) !important;
+ border-color: var(--bright-aqua) !important;
+}
+
+.choice-group > div > div {
+ display: flex;
+}
+
+.choice-group > div > div > div:not(:first-child) {
+ margin-left: 6px;
+}
diff --git a/packages/core/components/ChoiceGroup/index.tsx b/packages/core/components/ChoiceGroup/index.tsx
new file mode 100644
index 000000000..891b13d55
--- /dev/null
+++ b/packages/core/components/ChoiceGroup/index.tsx
@@ -0,0 +1,30 @@
+import { ChoiceGroup, IChoiceGroupOption } from "@fluentui/react";
+import * as React from "react";
+
+import styles from "./ChoiceGroup.module.css";
+
+interface Props {
+ className?: string;
+ disabled?: boolean;
+ onChange: (
+ ev?: React.FormEvent,
+ option?: IChoiceGroupOption | undefined
+ ) => void;
+ options: IChoiceGroupOption[];
+ defaultSelectedKey?: string | number;
+}
+
+/**
+ * Custom styled wrapper for default fluentui component
+ */
+export default function BaseChoiceGroup(props: Props) {
+ return (
+
+ );
+}
diff --git a/packages/core/components/DirectoryTree/useDirectoryHierarchy.tsx b/packages/core/components/DirectoryTree/useDirectoryHierarchy.tsx
index 6cb14b46d..850878c12 100644
--- a/packages/core/components/DirectoryTree/useDirectoryHierarchy.tsx
+++ b/packages/core/components/DirectoryTree/useDirectoryHierarchy.tsx
@@ -104,6 +104,9 @@ const useDirectoryHierarchy = (
const hierarchy = useSelector(selection.selectors.getAnnotationHierarchy);
const annotationService = useSelector(interaction.selectors.getAnnotationService);
const fileService = useSelector(interaction.selectors.getFileService);
+ const fuzzyFilters = useSelector(selection.selectors.getFuzzyFilters);
+ const excludeFilters = useSelector(selection.selectors.getAnnotationsFilteredOut);
+ const includeFilters = useSelector(selection.selectors.getAnnotationsRequired);
const selectedFileFilters = useSelector(selection.selectors.getFileFilters);
const sortColumn = useSelector(selection.selectors.getSortColumn);
const [state, dispatch] = React.useReducer(reducer, {
@@ -167,10 +170,17 @@ const useDirectoryHierarchy = (
}
const filteredValues = values.filter((value) => {
+ if (includeFilters?.some((filter) => filter.name === annotationNameAtDepth))
+ return true;
if (!isEmpty(userSelectedFiltersForCurrentAnnotation)) {
+ if (
+ fuzzyFilters?.some((fuzzy) => fuzzy.name === annotationNameAtDepth)
+ ) {
+ // There can only be one selected value for fuzzy search, so reverse match
+ return value.includes(userSelectedFiltersForCurrentAnnotation[0]);
+ }
return userSelectedFiltersForCurrentAnnotation.includes(value);
}
-
return true;
});
@@ -257,9 +267,12 @@ const useDirectoryHierarchy = (
annotationService,
currentNode,
collapsed,
+ excludeFilters,
fileService,
fileSet,
+ fuzzyFilters,
hierarchy,
+ includeFilters,
isRoot,
isLeaf,
selectedFileFilters,
diff --git a/packages/core/components/NumberRangePicker/NumberRangePicker.module.css b/packages/core/components/NumberRangePicker/NumberRangePicker.module.css
index fc1ff4d40..790cd5d9a 100644
--- a/packages/core/components/NumberRangePicker/NumberRangePicker.module.css
+++ b/packages/core/components/NumberRangePicker/NumberRangePicker.module.css
@@ -74,6 +74,10 @@
flex-grow: 1;
}
+.input-field {
+ flex-grow: 1;
+}
+
.input-field input {
background-color: var(--secondary-background-color);
border-radius: var(--small-border-radius);
diff --git a/packages/core/components/QueryPart/QueryFilter.tsx b/packages/core/components/QueryPart/QueryFilter.tsx
index f252577d0..39153047a 100644
--- a/packages/core/components/QueryPart/QueryFilter.tsx
+++ b/packages/core/components/QueryPart/QueryFilter.tsx
@@ -5,10 +5,10 @@ import { useDispatch, useSelector } from "react-redux";
import QueryPart from ".";
import AnnotationPicker from "../AnnotationPicker";
import AnnotationFilterForm from "../AnnotationFilterForm";
+import Annotation from "../../entity/Annotation";
+import FileFilter, { FilterType } from "../../entity/FileFilter";
import Tutorial from "../../entity/Tutorial";
-import FileFilter from "../../entity/FileFilter";
import { metadata, selection } from "../../state";
-import Annotation from "../../entity/Annotation";
interface Props {
disabled?: boolean;
@@ -31,13 +31,13 @@ export default function QueryFilter(props: Props) {
title="Filter"
disabled={props.disabled}
tutorialId={Tutorial.FILTER_HEADER_ID}
- onDelete={(annotation) =>
+ onDelete={(annotation) => {
dispatch(
selection.actions.removeFileFilter(
props.filters.filter((filter) => filter.name === annotation)
)
- )
- }
+ );
+ }}
onRenderAddMenuList={() => (
)}
rows={Object.entries(filtersGroupedByName).map(([annotationName, filters]) => {
- const operator = filters.length > 1 ? "ONE OF" : "EQUALS";
+ let operator = "EQUALS";
+ if (filters.length > 1) operator = "ONE OF";
+ else if (filters[0].type === FilterType.ANY) operator = "ANY VALUE";
+ else if (filters[0].type === FilterType.EXCLUDE) operator = "NO VALUE";
+ else if (filters[0].type === FilterType.FUZZY) operator = "CONTAINS";
+
const valueDisplay = map(filters, (filter) => filter.displayValue).join(", ");
return {
id: filters[0].name,
diff --git a/packages/core/entity/FileExplorerURL/index.ts b/packages/core/entity/FileExplorerURL/index.ts
index 823ac2fad..1fdb5cd77 100644
--- a/packages/core/entity/FileExplorerURL/index.ts
+++ b/packages/core/entity/FileExplorerURL/index.ts
@@ -148,7 +148,10 @@ export default class FileExplorerURL {
: undefined,
filters: unparsedFilters
.map((unparsedFilter) => JSON.parse(unparsedFilter))
- .map((parsedFilter) => new FileFilter(parsedFilter.name, parsedFilter.value)),
+ .map(
+ (parsedFilter) =>
+ new FileFilter(parsedFilter.name, parsedFilter.value, parsedFilter.type)
+ ),
sources: unparsedSources.map((unparsedSource) => JSON.parse(unparsedSource)),
sourceMetadata: unparsedSourceMetadata ? JSON.parse(unparsedSourceMetadata) : undefined,
openFolders: unparsedOpenFolders
diff --git a/packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts b/packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts
index 0c0f37660..c75b04015 100644
--- a/packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts
+++ b/packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts
@@ -3,6 +3,9 @@ import { expect } from "chai";
import FileExplorerURL, { FileExplorerURLComponents, Source } from "..";
import AnnotationName from "../../Annotation/AnnotationName";
import FileFilter from "../../FileFilter";
+import ExcludeFilter from "../../FileFilter/ExcludeFilter";
+import FuzzyFilter from "../../FileFilter/FuzzyFilter";
+import IncludeFilter from "../../FileFilter/IncludeFilter";
import FileFolder from "../../FileFolder";
import FileSort, { SortOrder } from "../../FileSort";
@@ -46,8 +49,63 @@ describe("FileExplorerURL", () => {
// Assert
expect(result).to.be.equal(
- "group=Cell+Line&group=Donor+Plasmid&group=Lifting%3F&filter=%7B%22name%22%3A%22Cas9%22%2C%22value%22%3A%22spCas9%22%7D&filter=%7B%22name%22%3A%22Donor+Plasmid%22%2C%22value%22%3A%22ACTB-mEGFP%22%7D&openFolder=%5B%22AICS-0%22%5D&openFolder=%5B%22AICS-0%22%2C%22ACTB-mEGFP%22%5D&openFolder=%5B%22AICS-0%22%2C%22ACTB-mEGFP%22%2Cfalse%5D&openFolder=%5B%22AICS-0%22%2C%22ACTB-mEGFP%22%2Ctrue%5D&source=%7B%22name%22%3A%22Fake+Collection%22%2C%22type%22%3A%22csv%22%7D&sort=%7B%22annotationName%22%3A%22file_size%22%2C%22order%22%3A%22DESC%22%7D"
+ "group=Cell+Line&group=Donor+Plasmid&group=Lifting%3F&filter=%7B%22name%22%3A%22Cas9%22%2C%22value%22%3A%22spCas9%22%2C%22type%22%3A%22default%22%7D&filter=%7B%22name%22%3A%22Donor+Plasmid%22%2C%22value%22%3A%22ACTB-mEGFP%22%2C%22type%22%3A%22default%22%7D&openFolder=%5B%22AICS-0%22%5D&openFolder=%5B%22AICS-0%22%2C%22ACTB-mEGFP%22%5D&openFolder=%5B%22AICS-0%22%2C%22ACTB-mEGFP%22%2Cfalse%5D&openFolder=%5B%22AICS-0%22%2C%22ACTB-mEGFP%22%2Ctrue%5D&source=%7B%22name%22%3A%22Fake+Collection%22%2C%22type%22%3A%22csv%22%7D&sort=%7B%22annotationName%22%3A%22file_size%22%2C%22order%22%3A%22DESC%22%7D"
);
+ /** URL decodes to:
+ * group=Cell+Line&group=Donor+Plasmid&group=Lifting?
+ * &filter={"name":"Cas9","value":"spCas9","type":"default"}
+ * &filter={"name":"Donor+Plasmid","value":"ACTB-mEGFP","type":"default"}
+ * &openFolder=["AICS-0"]&openFolder=["AICS-0","ACTB-mEGFP"]
+ * &openFolder=["AICS-0","ACTB-mEGFP",false]
+ * &openFolder=["AICS-0","ACTB-mEGFP",true]
+ * &source={"name":"Fake+Collection","type":"csv"}
+ * &sort={"annotationName":"file_size","order":"DESC"}"
+ */
+ });
+
+ it("Encodes filters with fuzzy, include, and exclude filters applied", () => {
+ // Arrange
+ const expectedAnnotationNames = ["Cell Line"];
+ const expectedFilters = [{ name: AnnotationName.FILE_NAME, value: "testname.csv" }];
+ const expectedFuzzyFilters = [
+ { annotationName: AnnotationName.FILE_PATH, value: "/test/path" },
+ ];
+ const expectedIncludeFilters = [{ annotationName: "Cell Line" }];
+ const expectedExcludeFilters = [{ annotationName: "Gene" }];
+
+ const components: FileExplorerURLComponents = {
+ hierarchy: expectedAnnotationNames,
+ filters: [
+ ...expectedFilters.map(({ name, value }) => new FileFilter(name, value)),
+ ...expectedFuzzyFilters.map(
+ (fuzzyFilter) => new FuzzyFilter(fuzzyFilter.annotationName)
+ ),
+ ...expectedExcludeFilters.map(
+ (excludeFilter) => new ExcludeFilter(excludeFilter.annotationName)
+ ),
+ ...expectedIncludeFilters.map(
+ (includeFilter) => new IncludeFilter(includeFilter.annotationName)
+ ),
+ ],
+ openFolders: [],
+ sortColumn: new FileSort(AnnotationName.FILE_SIZE, SortOrder.DESC),
+ sources: [mockSource],
+ };
+ // Act
+ const result = FileExplorerURL.encode(components);
+
+ // Assert
+ expect(result).to.be.equal(
+ "group=Cell+Line&filter=%7B%22name%22%3A%22file_name%22%2C%22value%22%3A%22testname.csv%22%2C%22type%22%3A%22default%22%7D&filter=%7B%22name%22%3A%22file_path%22%2C%22value%22%3A%22%22%2C%22type%22%3A%22fuzzy%22%7D&filter=%7B%22name%22%3A%22Gene%22%2C%22value%22%3A%22%22%2C%22type%22%3A%22exclude%22%7D&filter=%7B%22name%22%3A%22Cell+Line%22%2C%22value%22%3A%22%22%2C%22type%22%3A%22include%22%7D&source=%7B%22name%22%3A%22Fake+Collection%22%2C%22type%22%3A%22csv%22%7D&sort=%7B%22annotationName%22%3A%22file_size%22%2C%22order%22%3A%22DESC%22%7D"
+ );
+ /** URL decodes to
+ * group=Cell+Line&filter={"name":"file_name","value":"testname.csv","type":"default"}
+ * &filter={"name":"file_path","value":"","type":"fuzzy"}
+ * &filter={"name":"Gene","value":"","type":"exclude"}
+ * &filter={"name":"Cell+Line","value":"","type":"include"}
+ * &source={"name":"Fake+Collection","type":"csv"}
+ * &sort={"annotationName":"file_size","order":"DESC"}
+ */
});
it("Encodes empty state", () => {
@@ -99,6 +157,12 @@ describe("FileExplorerURL", () => {
{ name: "Cas9", value: "spCas9" },
{ name: "Donor Plasmid", value: "ACTB-mEGFP" },
];
+ const expectedFuzzyFilters = [
+ { annotationName: AnnotationName.FILE_NAME },
+ { annotationName: AnnotationName.FILE_PATH },
+ ];
+ const expectedIncludeFilters = [{ annotationName: "Gene" }];
+ const expectedExcludeFilters = [{ annotationName: "Cell Line" }];
const expectedOpenFolders = [
["3500000654"],
["3500000654", "ACTB-mEGFP"],
@@ -107,7 +171,18 @@ describe("FileExplorerURL", () => {
];
const components: FileExplorerURLComponents = {
hierarchy: expectedAnnotationNames,
- filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)),
+ filters: [
+ ...expectedFilters.map(({ name, value }) => new FileFilter(name, value)),
+ ...expectedFuzzyFilters.map(
+ (fuzzyFilter) => new FuzzyFilter(fuzzyFilter.annotationName)
+ ),
+ ...expectedExcludeFilters.map(
+ (excludeFilter) => new ExcludeFilter(excludeFilter.annotationName)
+ ),
+ ...expectedIncludeFilters.map(
+ (includeFilter) => new IncludeFilter(includeFilter.annotationName)
+ ),
+ ],
openFolders: expectedOpenFolders.map((folder) => new FileFolder(folder)),
sortColumn: new FileSort(AnnotationName.UPLOADED, SortOrder.DESC),
sourceMetadata: undefined,
@@ -120,7 +195,7 @@ describe("FileExplorerURL", () => {
const result = FileExplorerURL.decode(encodedUrlWithWhitespace);
// Assert
- expect(result).to.be.deep.equal(components);
+ expect(result).to.deep.equal(components);
});
it("Decodes to empty app state", () => {
@@ -139,7 +214,7 @@ describe("FileExplorerURL", () => {
const result = FileExplorerURL.decode(encodedUrl);
// Assert
- expect(result).to.be.deep.equal(components);
+ expect(result).to.deep.equal(components);
});
it("Removes folders that are too deep for hierachy", () => {
@@ -154,7 +229,7 @@ describe("FileExplorerURL", () => {
// Act / Assert
const { openFolders } = FileExplorerURL.decode(encodedUrl);
- expect(openFolders).to.be.deep.equal([new FileFolder(["AICS-0"])]);
+ expect(openFolders).to.deep.equal([new FileFolder(["AICS-0"])]);
});
it("Throws error when sort order is not DESC or ASC", () => {
diff --git a/packages/core/entity/FileFilter/ExcludeFilter.ts b/packages/core/entity/FileFilter/ExcludeFilter.ts
new file mode 100644
index 000000000..e5da49eae
--- /dev/null
+++ b/packages/core/entity/FileFilter/ExcludeFilter.ts
@@ -0,0 +1,12 @@
+import FileFilter, { FilterType } from "../FileFilter";
+
+/**
+ * A simple wrapper for filter that indicates which annotations
+ * should exclude a file from the fileset regardless of value;
+ * e.g., only return files that have null/blank values for this annotation.
+ */
+export default class ExcludeFilter extends FileFilter {
+ constructor(annotationName: string) {
+ super(annotationName, "", FilterType.EXCLUDE);
+ }
+}
diff --git a/packages/core/entity/FileFilter/FuzzyFilter.ts b/packages/core/entity/FileFilter/FuzzyFilter.ts
new file mode 100644
index 000000000..e9f8ac900
--- /dev/null
+++ b/packages/core/entity/FileFilter/FuzzyFilter.ts
@@ -0,0 +1,11 @@
+import FileFilter, { FilterType } from "../FileFilter";
+
+/**
+ * A simple wrapper for filters where annotation should regex match/contain
+ * the value but doesn't need to equal it exctly
+ */
+export default class FuzzyFilter extends FileFilter {
+ constructor(annotationName: string, annotationValue = "") {
+ super(annotationName, annotationValue, FilterType.FUZZY);
+ }
+}
diff --git a/packages/core/entity/FileFilter/IncludeFilter.ts b/packages/core/entity/FileFilter/IncludeFilter.ts
new file mode 100644
index 000000000..87c8f59cd
--- /dev/null
+++ b/packages/core/entity/FileFilter/IncludeFilter.ts
@@ -0,0 +1,12 @@
+import FileFilter, { FilterType } from "../FileFilter";
+
+/**
+ * A simple wrapper for filter that indicates which annotations should make a file
+ * be included in the fileset regardless of value;
+ * e.g., return files that have any non-null value for this annnotation.
+ */
+export default class IncludeFilter extends FileFilter {
+ constructor(annotationName: string) {
+ super(annotationName, "", FilterType.ANY);
+ }
+}
diff --git a/packages/core/entity/FileFilter/index.ts b/packages/core/entity/FileFilter/index.ts
index a65103551..950582b5a 100644
--- a/packages/core/entity/FileFilter/index.ts
+++ b/packages/core/entity/FileFilter/index.ts
@@ -1,6 +1,7 @@
export interface FileFilterJson {
name: string;
value: any;
+ type?: FilterType;
}
// Filter with formatted value
@@ -10,6 +11,14 @@ export interface Filter {
displayValue: string;
}
+// These also correspond to query param names
+export enum FilterType {
+ ANY = "include",
+ EXCLUDE = "exclude",
+ FUZZY = "fuzzy",
+ DEFAULT = "default",
+}
+
/**
* Stub for a filter used to constrain a listing of files to those that match a particular condition. Should be
* serializable to a URL query string-friendly format.
@@ -17,14 +26,20 @@ export interface Filter {
export default class FileFilter {
private readonly annotationName: string;
private readonly annotationValue: any;
+ private filterType: FilterType;
public static isFileFilter(candidate: any): candidate is FileFilter {
return candidate instanceof FileFilter;
}
- constructor(annotationName: string, annotationValue: any) {
+ constructor(
+ annotationName: string,
+ annotationValue: any,
+ filterType: FilterType = FilterType.DEFAULT
+ ) {
this.annotationName = annotationName;
this.annotationValue = annotationValue;
+ this.filterType = filterType;
}
public get name() {
@@ -35,7 +50,24 @@ export default class FileFilter {
return this.annotationValue;
}
+ public get type() {
+ return this.filterType;
+ }
+
+ public set type(filterType: FilterType) {
+ this.filterType = filterType;
+ }
+
public toQueryString(): string {
+ switch (this.type) {
+ case FilterType.ANY:
+ return `include=${this.annotationName}`;
+ case FilterType.EXCLUDE:
+ return `exclude=${this.annotationName}`;
+ case FilterType.FUZZY:
+ if (this.value === "") return `fuzzy=${this.annotationName}`;
+ return `${this.annotationName}=${this.annotationValue}&fuzzy=${this.annotationName}`;
+ }
return `${this.annotationName}=${this.annotationValue}`;
}
@@ -43,13 +75,15 @@ export default class FileFilter {
return {
name: this.annotationName,
value: this.annotationValue,
+ type: this.filterType,
};
}
public equals(target: FileFilter): boolean {
return (
this.annotationName === target.annotationName &&
- this.annotationValue === target.annotationValue
+ this.annotationValue === target.annotationValue &&
+ this.filterType === target.filterType
);
}
}
diff --git a/packages/core/entity/FileFilter/test/FileFilter.test.ts b/packages/core/entity/FileFilter/test/FileFilter.test.ts
new file mode 100644
index 000000000..3553d4833
--- /dev/null
+++ b/packages/core/entity/FileFilter/test/FileFilter.test.ts
@@ -0,0 +1,76 @@
+import { expect } from "chai";
+
+import FileFilter, { FilterType } from "../";
+import IncludeFilter from "../IncludeFilter";
+import ExcludeFilter from "../ExcludeFilter";
+import FuzzyFilter from "../FuzzyFilter";
+
+describe("FileFilter", () => {
+ describe("equals", () => {
+ it("is backwards compatible when no type argument is provided", () => {
+ // Arrange
+ const fileFilterNoType = new FileFilter("Annotation name", "test value");
+ const fileFilterWithType = new FileFilter(
+ "Annotation name",
+ "test value",
+ FilterType.DEFAULT
+ );
+
+ // Act/Assert
+ expect(fileFilterNoType.equals(fileFilterWithType));
+ });
+ it("returns true for include filter subtype and parent class", () => {
+ // Arrange
+ const fileFilterIncludeConstructor = new IncludeFilter("Annotation name");
+ const fileFilterParentConstructor = new FileFilter(
+ "Annotation name",
+ "",
+ FilterType.ANY
+ );
+
+ // Act/Assert
+ expect(fileFilterIncludeConstructor.equals(fileFilterParentConstructor));
+ });
+ it("returns true for exclude filter subtype and parent class", () => {
+ // Arrange
+ const fileFilterExcludeConstructor = new ExcludeFilter("Annotation name");
+ const fileFilterParentConstructor = new FileFilter(
+ "Annotation name",
+ "",
+ FilterType.EXCLUDE
+ );
+
+ // Act/Assert
+ expect(fileFilterExcludeConstructor.equals(fileFilterParentConstructor));
+ });
+ it("returns true for fuzzy filter subtype and parent class", () => {
+ // Arrange
+ const fileFilterFuzzyConstructor = new FuzzyFilter(
+ "Annotation name",
+ "annotation value"
+ );
+ const fileFilterParentConstructor = new FileFilter(
+ "Annotation name",
+ "annotation value",
+ FilterType.FUZZY
+ );
+
+ // Act/Assert
+ expect(fileFilterFuzzyConstructor.equals(fileFilterParentConstructor));
+ });
+ it("returns false for different filter subtypes", () => {
+ // Arrange
+ const fileFilter = new FileFilter("Annotation name", "annotation value");
+ const fileFilterFuzzyConstructor = new FuzzyFilter(
+ "Annotation name",
+ "annotation value"
+ );
+ const fileFilterExcludeConstructor = new ExcludeFilter("Annotation name");
+ const fileFilterIncludeConstructor = new IncludeFilter("Annotation name");
+
+ // Act/Assert
+ expect(!fileFilterFuzzyConstructor.equals(fileFilter));
+ expect(!fileFilterIncludeConstructor.equals(fileFilterExcludeConstructor));
+ });
+ });
+});
diff --git a/packages/core/entity/FileSelection/index.ts b/packages/core/entity/FileSelection/index.ts
index 2ec8de15b..3b0bb688f 100644
--- a/packages/core/entity/FileSelection/index.ts
+++ b/packages/core/entity/FileSelection/index.ts
@@ -1,6 +1,6 @@
import { find, isArray, reject } from "lodash";
-import FileFilter from "../FileFilter";
+import FileFilter, { FilterType } from "../FileFilter";
import FileSet from "../FileSet";
import { SortOrder } from "../FileSort";
import NumericRange from "../NumericRange";
@@ -509,13 +509,14 @@ export default class FileSelection {
*/
public toCompactSelectionList(): Selection[] {
return [...this.groupByFileSet().entries()].map(([fileSet, selectedRanges]) => ({
- filters: fileSet.filters.reduce(
- (accum, filter) => ({
- ...accum,
- [filter.name]: [...(accum[filter.name] || []), filter.value],
- }),
- {} as { [index: string]: any }
- ),
+ filters: fileSet.filters.reduce((accum, filter) => {
+ if (filter.type === FilterType.DEFAULT) {
+ return {
+ ...accum,
+ [filter.name]: [...(accum[filter.name] || []), filter.value],
+ };
+ } else return accum;
+ }, {} as { [index: string]: any }),
indexRanges: selectedRanges.map((range) => range.toJSON()),
sort: fileSet.sort
? {
@@ -523,6 +524,9 @@ export default class FileSelection {
ascending: fileSet.sort.order === SortOrder.ASC,
}
: undefined,
+ fuzzy: fileSet?.fuzzyFilters?.map((filter) => filter.name),
+ include: fileSet?.includeFilters?.map((filter) => filter.name),
+ exclude: fileSet?.excludeFilters?.map((filter) => filter.name),
}));
}
diff --git a/packages/core/entity/FileSelection/test/FileSelection.test.ts b/packages/core/entity/FileSelection/test/FileSelection.test.ts
index 7d5a88634..5a12775a8 100644
--- a/packages/core/entity/FileSelection/test/FileSelection.test.ts
+++ b/packages/core/entity/FileSelection/test/FileSelection.test.ts
@@ -8,6 +8,9 @@ import NumericRange from "../../NumericRange";
import FileSelection, { FocusDirective } from "..";
import FileDetail from "../../FileDetail";
import FileFilter from "../../FileFilter";
+import FuzzyFilter from "../../FileFilter/FuzzyFilter";
+import IncludeFilter from "../../FileFilter/IncludeFilter";
+import ExcludeFilter from "../../FileFilter/ExcludeFilter";
import { IndexError, ValueError } from "../../../errors";
import HttpFileService from "../../../services/FileService/HttpFileService";
import FileDownloadServiceNoop from "../../../services/FileDownloadService/FileDownloadServiceNoop";
@@ -371,7 +374,7 @@ describe("FileSelection", () => {
const fileDetails = await selection.fetchAllDetails();
// Assert
- expect(fileDetails).to.be.deep.equal(expectedDetails);
+ expect(fileDetails).to.deep.equal(expectedDetails);
});
});
@@ -671,9 +674,18 @@ describe("FileSelection", () => {
describe("toCompactSelectionList", () => {
it("produces array of selections grouped by fileset", () => {
// Arrange
+ const fuzzyFilterNames: string[] = ["fuzzyFilterName1", "fuzzyFilterName2"];
+ const includeFilterName = "includeFilterName";
+ const excludeFilterName = "excludeFilterName";
const fileSet1 = new FileSet();
const fileSet2 = new FileSet({
- filters: [new FileFilter("foo", "bar")],
+ filters: [
+ new FileFilter("filterName", "filterValue"),
+ new FuzzyFilter(fuzzyFilterNames[0]),
+ new FuzzyFilter(fuzzyFilterNames[1]),
+ new IncludeFilter(includeFilterName),
+ new ExcludeFilter(excludeFilterName),
+ ],
});
const selection = new FileSelection()
.select({ fileSet: fileSet1, index: 3, sortOrder: 0 })
@@ -687,12 +699,15 @@ describe("FileSelection", () => {
// Assert
expect(selections.length).to.equal(2);
expect(selections[0].filters).to.be.empty;
- expect(selections[0].indexRanges).to.be.deep.equal([
+ expect(selections[0].indexRanges).to.deep.equal([
new NumericRange(3).toJSON(),
new NumericRange(12, 15).toJSON(),
]);
- expect(selections[1].filters).to.be.deep.equal({ foo: ["bar"] });
- expect(selections[1].indexRanges).to.be.deep.equal([
+ expect(selections[1].filters).to.deep.equal({ filterName: ["filterValue"] });
+ expect(selections[1].fuzzy).to.deep.equal([fuzzyFilterNames[0], fuzzyFilterNames[1]]);
+ expect(selections[1].include).to.deep.equal([includeFilterName]);
+ expect(selections[1].exclude).to.deep.equal([excludeFilterName]);
+ expect(selections[1].indexRanges).to.deep.equal([
new NumericRange(8, 10).toJSON(),
new NumericRange(33).toJSON(),
]);
diff --git a/packages/core/entity/FileSet/index.ts b/packages/core/entity/FileSet/index.ts
index 8e3c73835..3ca11962c 100644
--- a/packages/core/entity/FileSet/index.ts
+++ b/packages/core/entity/FileSet/index.ts
@@ -1,8 +1,11 @@
import { defaults, find, join, map, uniqueId } from "lodash";
import LRUCache from "lru-cache";
-import FileFilter from "../FileFilter";
+import FileFilter, { FilterType } from "../FileFilter";
import FileSort from "../FileSort";
+import FuzzyFilter from "../FileFilter/FuzzyFilter";
+import ExcludeFilter from "../FileFilter/ExcludeFilter";
+import IncludeFilter from "../FileFilter/IncludeFilter";
import FileService from "../../services/FileService";
import FileServiceNoop from "../../services/FileService/FileServiceNoop";
import SQLBuilder from "../SQLBuilder";
@@ -36,6 +39,9 @@ export default class FileSet {
private cache: LRUCache;
private readonly fileService: FileService;
private readonly _filters: FileFilter[];
+ public readonly fuzzyFilters?: FuzzyFilter[];
+ public readonly excludeFilters?: ExcludeFilter[];
+ public readonly includeFilters?: IncludeFilter[];
public readonly sort?: FileSort;
private totalFileCount: number | undefined;
private indexesForFilesCurrentlyLoading: Set = new Set();
@@ -49,6 +55,9 @@ export default class FileSet {
this.cache = new LRUCache({ max: maxCacheSize });
this._filters = filters;
+ this.fuzzyFilters = filters.filter((f) => f.type === FilterType.FUZZY);
+ this.excludeFilters = filters.filter((f) => f.type === FilterType.EXCLUDE);
+ this.includeFilters = filters.filter((f) => f.type === FilterType.ANY);
this.sort = sort;
this.fileService = fileService;
@@ -160,8 +169,9 @@ export default class FileSet {
public toQueryString(): string {
// filters must be sorted in order to ensure requests can be effectively cached
// according to their url
- const sortedFilters = [...this.filters].sort((a, b) =>
- a.toQueryString().localeCompare(b.toQueryString())
+ const sortedFilters = [...this.filters].sort(
+ (a, b) =>
+ a.type.localeCompare(b.type) || a.toQueryString().localeCompare(b.toQueryString())
);
const query = map(sortedFilters, (filter) => filter.toQueryString());
diff --git a/packages/core/entity/FileSet/test/FileSet.test.ts b/packages/core/entity/FileSet/test/FileSet.test.ts
index 77f859c53..d31ffae6c 100644
--- a/packages/core/entity/FileSet/test/FileSet.test.ts
+++ b/packages/core/entity/FileSet/test/FileSet.test.ts
@@ -6,6 +6,9 @@ import FileSet from "../";
import FileFilter from "../../FileFilter";
import FileSort, { SortOrder } from "../../FileSort";
import { makeFileDetailMock } from "../../FileDetail/mocks";
+import FuzzyFilter from "../../FileFilter/FuzzyFilter";
+import IncludeFilter from "../../FileFilter/IncludeFilter";
+import ExcludeFilter from "../../FileFilter/ExcludeFilter";
import HttpFileService from "../../../services/FileService/HttpFileService";
import FileDownloadServiceNoop from "../../../services/FileDownloadService/FileDownloadServiceNoop";
@@ -13,6 +16,12 @@ describe("FileSet", () => {
const scientistEqualsJane = new FileFilter("scientist", "jane");
const matrigelIsHard = new FileFilter("matrigel_is_hardened", true);
const dateCreatedDescending = new FileSort("date_created", SortOrder.DESC);
+ const fuzzyFileName = new FuzzyFilter("file_name");
+ const fuzzyFilePath = new FuzzyFilter("file_path");
+ const anyGene = new IncludeFilter("gene");
+ const anyKind = new IncludeFilter("kind");
+ const noCellLine = new ExcludeFilter("cell_line");
+ const noCellBatch = new ExcludeFilter("cell_batch");
describe("toQueryString", () => {
it("returns an empty string if file set represents a query with no filters and no sorting applied", () => {
@@ -29,6 +38,24 @@ describe("FileSet", () => {
);
});
+ it("includes name-only filters (fuzzy, include and exclude) in query string", () => {
+ const fileSet = new FileSet({
+ filters: [fuzzyFileName, noCellLine, anyGene],
+ });
+ expect(fileSet.toQueryString()).equals(
+ "exclude=cell_line&fuzzy=file_name&include=gene"
+ );
+ });
+
+ // Enforce query param order for cacheing efficiency. Same args should register as same query regardless of order
+ it("includes sort after name-only filters in query string", () => {
+ const fileSet = new FileSet({
+ filters: [fuzzyFileName],
+ sort: dateCreatedDescending,
+ });
+ expect(fileSet.toQueryString()).equals("fuzzy=file_name&sort=date_created(DESC)");
+ });
+
it("produces the same query string when given the same filters in different order", () => {
const fileSet1 = new FileSet({
filters: [scientistEqualsJane, matrigelIsHard],
@@ -39,6 +66,24 @@ describe("FileSet", () => {
expect(fileSet1.toQueryString()).to.equal(fileSet2.toQueryString());
});
+
+ it("produces the same query string when given the same name-only filters in different orders", () => {
+ const fuzzyFilters = [fuzzyFileName, fuzzyFilePath];
+ const includeFilters = [anyGene, anyKind];
+ const excludeFilters = [noCellBatch, noCellLine];
+ const fileSet1 = new FileSet({
+ filters: [...fuzzyFilters, ...includeFilters, ...excludeFilters],
+ });
+ const fileSet2 = new FileSet({
+ filters: [
+ ...excludeFilters.reverse(),
+ ...fuzzyFilters.reverse(),
+ ...includeFilters.reverse(),
+ ],
+ });
+
+ expect(fileSet1.toQueryString()).to.equal(fileSet2.toQueryString());
+ });
});
describe("fetchFileRange", () => {
diff --git a/packages/core/services/AnnotationService/HttpAnnotationService/index.ts b/packages/core/services/AnnotationService/HttpAnnotationService/index.ts
index ff235cad5..3a6d7b649 100644
--- a/packages/core/services/AnnotationService/HttpAnnotationService/index.ts
+++ b/packages/core/services/AnnotationService/HttpAnnotationService/index.ts
@@ -7,8 +7,11 @@ import FileFilter from "../../../entity/FileFilter";
import { TOP_LEVEL_FILE_ANNOTATIONS, TOP_LEVEL_FILE_ANNOTATION_NAMES } from "../../../constants";
enum QueryParam {
+ EXCLUDE = "exclude",
FILTER = "filter",
+ FUZZY = "fuzzy",
HIERARCHY = "hierarchy",
+ INCLUDE = "include",
ORDER = "order",
PATH = "path",
}
diff --git a/packages/core/services/FileService/index.ts b/packages/core/services/FileService/index.ts
index e06b4aa3d..a540f2da4 100644
--- a/packages/core/services/FileService/index.ts
+++ b/packages/core/services/FileService/index.ts
@@ -33,6 +33,9 @@ export interface Selection {
annotationName: string;
ascending: boolean;
};
+ fuzzy?: string[];
+ exclude?: string[];
+ include?: string[];
}
export default interface FileService {
diff --git a/packages/core/state/interaction/logics.ts b/packages/core/state/interaction/logics.ts
index ab24e2be6..784f63a5b 100644
--- a/packages/core/state/interaction/logics.ts
+++ b/packages/core/state/interaction/logics.ts
@@ -551,7 +551,7 @@ const refresh = createLogic({
* dispatching appropriate modal changes
*/
const setIsSmallScreen = createLogic({
- process(deps: ReduxLogicDeps, dispatch) {
+ process(deps: ReduxLogicDeps, dispatch, done) {
const { payload: isSmallScreen } = deps.action as SetIsSmallScreenAction;
const isDisplayingSmallScreenModal = interactionSelectors.getIsDisplayingSmallScreenWarning(
deps.getState()
@@ -570,6 +570,7 @@ const setIsSmallScreen = createLogic({
// Don't dispatch hide if a different modal is open
dispatch(hideVisibleModal());
}
+ done();
},
type: SET_IS_SMALL_SCREEN,
});
diff --git a/packages/core/state/selection/actions.ts b/packages/core/state/selection/actions.ts
index d78d43690..30cda6ce7 100644
--- a/packages/core/state/selection/actions.ts
+++ b/packages/core/state/selection/actions.ts
@@ -1,7 +1,7 @@
import { makeConstant } from "@aics/redux-utils";
import Annotation from "../../entity/Annotation";
-import FileFilter from "../../entity/FileFilter";
+import FileFilter, { FilterType } from "../../entity/FileFilter";
import FileFolder from "../../entity/FileFolder";
import FileSelection from "../../entity/FileSelection";
import FileSet from "../../entity/FileSet";
@@ -75,6 +75,31 @@ export function removeFileFilter(filter: FileFilter | FileFilter[]): RemoveFileF
};
}
+/**
+ * CHANGE_FILE_FILTER_TYPE
+ *
+ * Intention to change the type of any currently applied FileFilter
+ */
+export const CHANGE_FILE_FILTER_TYPE = makeConstant(STATE_BRANCH_NAME, "change-file-filter-type");
+
+export interface ChangeFileFilterTypeAction {
+ payload: {
+ annotationName: string;
+ type: FilterType;
+ };
+ type: string;
+}
+
+export function changeFileFilterType(
+ annotationName: string,
+ type: FilterType
+): ChangeFileFilterTypeAction {
+ return {
+ payload: { annotationName, type },
+ type: CHANGE_FILE_FILTER_TYPE,
+ };
+}
+
/**
* SORT_COLUMN
*
diff --git a/packages/core/state/selection/logics.ts b/packages/core/state/selection/logics.ts
index 1f88b2805..0911855fb 100644
--- a/packages/core/state/selection/logics.ts
+++ b/packages/core/state/selection/logics.ts
@@ -39,12 +39,13 @@ import {
CHANGE_SOURCE_METADATA,
ChangeSourceMetadataAction,
changeSourceMetadata,
+ CHANGE_FILE_FILTER_TYPE,
} from "./actions";
import { interaction, metadata, ReduxLogicDeps, selection } from "../";
import * as selectionSelectors from "./selectors";
import Annotation from "../../entity/Annotation";
import FileExplorerURL from "../../entity/FileExplorerURL";
-import FileFilter from "../../entity/FileFilter";
+import FileFilter, { FilterType } from "../../entity/FileFilter";
import FileFolder from "../../entity/FileFolder";
import FileSelection from "../../entity/FileSelection";
import FileSet from "../../entity/FileSet";
@@ -226,7 +227,7 @@ const setAvailableAnnotationsLogic = createLogic({
});
/**
- * Interceptor responsible for transforming ADD_FILE_FILTER and REMOVE_FILE_FILTER
+ * Interceptor responsible for transforming ADD_FILE_FILTER, REMOVE_FILE_FILTER, and CHANGE_FILE_FILTER_TYPE
* actions into a concrete list of ordered FileFilters that can be stored directly in
* application state under `selections.filters`.
*/
@@ -237,18 +238,57 @@ const modifyFileFilters = createLogic({
const previousFilters = selectionSelectors.getFileFilters(getState());
let nextFilters: FileFilter[];
- const incomingFilters = castArray(action.payload);
- if (action.type === ADD_FILE_FILTER) {
- nextFilters = uniqWith(
- [...previousFilters, ...incomingFilters],
- (existing, incoming) => {
- return existing.equals(incoming);
- }
- );
+ if (action.type === CHANGE_FILE_FILTER_TYPE) {
+ switch (action.payload.type) {
+ // For include/exclude, remove all previous filters for this annotation
+ // and replace with a new single filter
+ case FilterType.ANY:
+ case FilterType.EXCLUDE:
+ const newFilter = new FileFilter(
+ action.payload.annotationName,
+ "",
+ action.payload.type
+ );
+ nextFilters = [
+ ...previousFilters.filter(
+ (filter) => filter.name !== action.payload.annotationName
+ ),
+ newFilter,
+ ];
+ break;
+ // For default/fuzzy, toggle the type for existing default/fuzzy filters but keep their value,
+ // and fully remove include/exclude filters
+ case FilterType.FUZZY:
+ default:
+ nextFilters = previousFilters
+ .filter((filter) => {
+ return !(
+ filter.name === action.payload.annotationName &&
+ (filter.type === FilterType.ANY ||
+ filter.type === FilterType.EXCLUDE)
+ );
+ })
+ .map((filter) => {
+ if (filter.name === action.payload.annotationName) {
+ filter.type = action.payload.type;
+ }
+ return filter;
+ });
+ }
} else {
- nextFilters = previousFilters.filter((existing) => {
- return !incomingFilters.some((incoming) => incoming.equals(existing));
- });
+ const incomingFilters = castArray(action.payload);
+ if (action.type === ADD_FILE_FILTER) {
+ nextFilters = uniqWith(
+ [...previousFilters, ...incomingFilters],
+ (existing, incoming) => {
+ return existing.equals(incoming);
+ }
+ );
+ } else {
+ nextFilters = previousFilters.filter((existing) => {
+ return !incomingFilters.some((incoming) => incoming.equals(existing));
+ });
+ }
}
const sortedNextFilters = sortBy(nextFilters, ["name", "value"]);
@@ -266,7 +306,7 @@ const modifyFileFilters = createLogic({
next(setFileFilters(sortedNextFilters));
},
- type: [ADD_FILE_FILTER, REMOVE_FILE_FILTER],
+ type: [ADD_FILE_FILTER, REMOVE_FILE_FILTER, CHANGE_FILE_FILTER_TYPE],
});
/**
diff --git a/packages/core/state/selection/reducer.ts b/packages/core/state/selection/reducer.ts
index 7d0c036da..de1e01194 100644
--- a/packages/core/state/selection/reducer.ts
+++ b/packages/core/state/selection/reducer.ts
@@ -78,14 +78,14 @@ export const initialState = {
},
dataSources: [],
displayAnnotations: [],
- isDarkTheme: true,
fileGridColumnCount: THUMBNAIL_SIZE_TO_NUM_COLUMNS.LARGE,
fileSelection: new FileSelection(),
filters: [],
+ isDarkTheme: true,
openFileFolders: [],
+ queries: [],
recentAnnotations: [],
shouldDisplaySmallFont: false,
- queries: [],
shouldDisplayThumbnailView: false,
};
diff --git a/packages/core/state/selection/selectors.ts b/packages/core/state/selection/selectors.ts
index 6ed04e7d2..9d478dbd3 100644
--- a/packages/core/state/selection/selectors.ts
+++ b/packages/core/state/selection/selectors.ts
@@ -4,7 +4,7 @@ import { createSelector } from "reselect";
import { State } from "../";
import Annotation from "../../entity/Annotation";
import FileExplorerURL, { FileExplorerURLComponents } from "../../entity/FileExplorerURL";
-import FileFilter from "../../entity/FileFilter";
+import FileFilter, { FilterType } from "../../entity/FileFilter";
import { getAnnotations } from "../metadata/selectors";
import { AICS_FMS_DATA_SOURCE_NAME } from "../../constants";
@@ -41,6 +41,22 @@ export const isQueryingAicsFms = createSelector(
(dataSources): boolean => dataSources[0]?.name === AICS_FMS_DATA_SOURCE_NAME
);
+export const getFuzzyFilters = createSelector([getFileFilters], (filters): FileFilter[] =>
+ filters.filter((filter) => filter.type === FilterType.FUZZY)
+);
+
+export const getAnnotationsFilteredOut = createSelector([getFileFilters], (filters): FileFilter[] =>
+ filters.filter((filter) => filter.type === FilterType.EXCLUDE)
+);
+
+export const getAnnotationsRequired = createSelector([getFileFilters], (filters): FileFilter[] =>
+ filters.filter((filter) => filter.type === FilterType.ANY)
+);
+
+export const getDefaultFileFilters = createSelector([getFileFilters], (filters): FileFilter[] =>
+ filters.filter((filter) => filter.type === FilterType.DEFAULT)
+);
+
export const getCurrentQueryParts = createSelector(
[
getAnnotationHierarchy,
@@ -106,6 +122,7 @@ export const getGroupedByFilterName = createSelector(
name: filter.name,
value: filter.value,
displayValue: annotation?.getDisplayValue(filter.value),
+ type: filter?.type || FilterType.DEFAULT,
};
}).filter((filter) => filter.displayValue !== undefined);
return groupBy(filters, (filter) => filter.displayName);