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);