diff --git a/packages/core/components/AnnotationFilterForm/SearchBoxForm/index.tsx b/packages/core/components/AnnotationFilterForm/SearchBoxForm/index.tsx index 9bc734365..5e51199e0 100644 --- a/packages/core/components/AnnotationFilterForm/SearchBoxForm/index.tsx +++ b/packages/core/components/AnnotationFilterForm/SearchBoxForm/index.tsx @@ -18,7 +18,9 @@ interface SearchBoxFormProps { onSelect: (item: ListItem) => void; onSelectAll: () => void; onSearch: (filterValue: string) => void; + onToggleFuzzySearch: () => void; fieldName: string; + fuzzySearchEnabled?: boolean; defaultValue: FileFilter | undefined; } @@ -28,6 +30,18 @@ interface SearchBoxFormProps { */ export default function SearchBoxForm(props: SearchBoxFormProps) { const [isListPicking, setIsListPicking] = React.useState(false); + const [isFuzzySearching, setIsFuzzySearching] = React.useState( + props?.fuzzySearchEnabled || false + ); + const defaultSearchBox = props?.fuzzySearchEnabled ? "search-box-fuzzy" : "search-box-exact"; + + function onSearchSubmitted(value: string) { + // Make sure fuzzy search is synchronized in state + if (isFuzzySearching !== props?.fuzzySearchEnabled) { + props.onToggleFuzzySearch(); + } + props.onSearch(value); + } return (
@@ -35,11 +49,15 @@ export default function SearchBoxForm(props: SearchBoxFormProps) { { // Clear the selection if the user switches to the search box // and the default value is not in the list (i.e. not deselectable) - if (props.defaultValue && !props.items.some((item) => item.selected)) { + if ( + selection?.key === "list-picker" && + props.defaultValue && + !props.items.some((item) => item.selected) + ) { props.onDeselectAll(); } + setIsFuzzySearching(selection?.key === "search-box-fuzzy"); setIsListPicking(selection?.key === "list-picker"); }} /> @@ -70,7 +93,7 @@ export default function SearchBoxForm(props: SearchBoxFormProps) {
diff --git a/packages/core/components/AnnotationFilterForm/SearchBoxForm/test/SearchBoxForm.test.tsx b/packages/core/components/AnnotationFilterForm/SearchBoxForm/test/SearchBoxForm.test.tsx index 462ded041..db1abce9f 100644 --- a/packages/core/components/AnnotationFilterForm/SearchBoxForm/test/SearchBoxForm.test.tsx +++ b/packages/core/components/AnnotationFilterForm/SearchBoxForm/test/SearchBoxForm.test.tsx @@ -15,13 +15,16 @@ describe("", () => { onSelectAll={noop} onDeselect={noop} onDeselectAll={noop} + onToggleFuzzySearch={noop} items={[{ value: "foo", selected: false, displayValue: "foo" }]} onSearch={noop} defaultValue={undefined} /> ); - // Sanity check + // Consistency checks + expect(() => getByTestId("list-picker")).to.throw(); + fireEvent.click(getByText("Partial match search")); expect(() => getByTestId("list-picker")).to.throw(); // Select 'List picker' filter type diff --git a/packages/core/components/AnnotationFilterForm/index.tsx b/packages/core/components/AnnotationFilterForm/index.tsx index 1a5976c33..2dc3a6591 100644 --- a/packages/core/components/AnnotationFilterForm/index.tsx +++ b/packages/core/components/AnnotationFilterForm/index.tsx @@ -14,6 +14,7 @@ import NumberRangePicker from "../NumberRangePicker"; import SearchBoxForm from "./SearchBoxForm"; import DateRangePicker from "../DateRangePicker"; import { interaction, selection } from "../../state"; +import FuzzyFilter from "../../entity/FuzzyFilter"; import styles from "./AnnotationFilterForm.module.css"; @@ -30,6 +31,7 @@ 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, @@ -51,8 +53,23 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { })); }, [props.annotation, annotationValues, filtersForAnnotation]); + const fuzzySearchEnabled: boolean = React.useMemo(() => { + return ( + fuzzyFilters?.some((filter) => filter.annotationName === props.annotation.name) || false + ); + }, [fuzzyFilters, props.annotation]); + + const onToggleFuzzySearch = () => { + const fuzzyFilter = new FuzzyFilter(props.annotation.name); + fuzzySearchEnabled + ? dispatch(selection.actions.removeFuzzyFilter(fuzzyFilter)) + : dispatch(selection.actions.addFuzzyFilter(fuzzyFilter)); + }; + const onDeselectAll = () => { dispatch(selection.actions.removeFileFilter(filtersForAnnotation)); + // Remove fuzzy filter if present + if (fuzzySearchEnabled) onToggleFuzzySearch(); }; const onDeselect = (item: ListItem) => { @@ -158,6 +175,7 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { return ( diff --git a/packages/core/components/DirectoryTree/index.tsx b/packages/core/components/DirectoryTree/index.tsx index a2d17ffa8..dc509a920 100644 --- a/packages/core/components/DirectoryTree/index.tsx +++ b/packages/core/components/DirectoryTree/index.tsx @@ -38,15 +38,17 @@ interface FileListProps { export default function DirectoryTree(props: FileListProps) { const dispatch = useDispatch(); const fileService = useSelector(interaction.selectors.getFileService); + const fuzzyFilters = useSelector(selection.selectors.getFuzzyFilters); const globalFilters = useSelector(selection.selectors.getFileFilters); const sortColumn = useSelector(selection.selectors.getSortColumn); const fileSet = React.useMemo(() => { return new FileSet({ fileService: fileService, filters: globalFilters, + fuzzyFilters: fuzzyFilters, sort: sortColumn, }); - }, [fileService, globalFilters, sortColumn]); + }, [fileService, fuzzyFilters, globalFilters, sortColumn]); // On a up arrow key or down arrow key press this event will update the file list selection & focused file // to be either the row above (if the up arrow was pressed) or the row below (if the down arrow was pressed) diff --git a/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx b/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx index c8d0d51d1..25aa800b6 100644 --- a/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx +++ b/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx @@ -64,6 +64,7 @@ describe("", () => { selection: { annotationHierarchy: [fooAnnotation.name, barAnnotation.name], displayAnnotations: [...baseDisplayAnnotations, fooAnnotation, barAnnotation], + fuzzyFilters: [], }, }); @@ -116,6 +117,15 @@ describe("", () => { return makeFmsFile(idx, [foo]); }); + // A set of files maps to the following query string: foo=fir&fuzzy=foo + const fooFuzzyFiles = range(totalFilesCount).map((idx) => { + const foo = { + name: fooAnnotation.name, + values: [topLevelHierarchyValues[0]], + }; + return makeFmsFile(idx, [foo]); + }); + const responseStubs: ResponseStub[] = [ { when: (config) => @@ -186,6 +196,21 @@ describe("", () => { }, }, }, + { + when: (config) => { + const url = new URL(_get(config, "url", "")); + return ( + url.pathname.includes(HttpFileService.BASE_FILES_URL) && + url.searchParams.get(fooAnnotation.name) === "fir" && + url.searchParams.get("fuzzy") === fooAnnotation.name + ); + }, + respondWith: { + data: { + data: fooFuzzyFiles, + }, + }, + }, ]; const mockHttpClient = createMockHttpClient(responseStubs); const annotationService = new HttpAnnotationService({ baseUrl, httpClient: mockHttpClient }); diff --git a/packages/core/components/DirectoryTree/useDirectoryHierarchy.tsx b/packages/core/components/DirectoryTree/useDirectoryHierarchy.tsx index bc9aab0b5..d0e1ccf35 100644 --- a/packages/core/components/DirectoryTree/useDirectoryHierarchy.tsx +++ b/packages/core/components/DirectoryTree/useDirectoryHierarchy.tsx @@ -105,6 +105,7 @@ const useDirectoryHierarchy = ( const annotationService = useSelector(interaction.selectors.getAnnotationService); const fileService = useSelector(interaction.selectors.getFileService); const selectedFileFilters = useSelector(selection.selectors.getFileFilters); + const fuzzyFilters = useSelector(selection.selectors.getFuzzyFilters); const sortColumn = useSelector(selection.selectors.getSortColumn); const [state, dispatch] = React.useReducer(reducer, INITIAL_STATE); @@ -152,18 +153,28 @@ const useDirectoryHierarchy = ( if (isRoot) { values = await annotationService.fetchRootHierarchyValues( hierarchy, - selectedFileFilters + selectedFileFilters, + fuzzyFilters ); } else { values = await annotationService.fetchHierarchyValuesUnderPath( hierarchy, pathToNode, - selectedFileFilters + selectedFileFilters, + fuzzyFilters ); } const filteredValues = values.filter((value) => { if (!isEmpty(userSelectedFiltersForCurrentAnnotation)) { + if ( + fuzzyFilters?.some( + (fuzzy) => fuzzy.annotationName === annotationNameAtDepth + ) + ) { + // There can only be one selected filter with fuzzy search + return value.includes(userSelectedFiltersForCurrentAnnotation[0]); + } return userSelectedFiltersForCurrentAnnotation.includes(value); } @@ -211,6 +222,7 @@ const useDirectoryHierarchy = ( const childNodeFileSet = new FileSet({ fileService, filters, + fuzzyFilters, sort: sortColumn, }); @@ -255,6 +267,7 @@ const useDirectoryHierarchy = ( collapsed, fileService, fileSet, + fuzzyFilters, hierarchy, isRoot, isLeaf, diff --git a/packages/core/components/QueryPart/QueryFilter.tsx b/packages/core/components/QueryPart/QueryFilter.tsx index 2e6e3b67d..2f5977e85 100644 --- a/packages/core/components/QueryPart/QueryFilter.tsx +++ b/packages/core/components/QueryPart/QueryFilter.tsx @@ -7,6 +7,7 @@ import AnnotationPicker from "../AnnotationPicker"; import AnnotationFilterForm from "../AnnotationFilterForm"; import Tutorial from "../../entity/Tutorial"; import FileFilter from "../../entity/FileFilter"; +import FuzzyFilter from "../../entity/FuzzyFilter"; import { metadata, selection } from "../../state"; import Annotation from "../../entity/Annotation"; @@ -29,13 +30,14 @@ 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) ) - ) - } + ); + dispatch(selection.actions.removeFuzzyFilter(new FuzzyFilter(annotation))); + }} onRenderAddMenuList={() => ( ", () => { }, }); - // Sanity check + // Consistency check expect(screen.getByRole("searchbox").value).to.equal("bar"); // Hit reset diff --git a/packages/core/entity/FileExplorerURL/index.ts b/packages/core/entity/FileExplorerURL/index.ts index 823ac2fad..c5c475fee 100644 --- a/packages/core/entity/FileExplorerURL/index.ts +++ b/packages/core/entity/FileExplorerURL/index.ts @@ -3,6 +3,7 @@ import FileFilter from "../FileFilter"; import FileFolder from "../FileFolder"; import FileSort, { SortOrder } from "../FileSort"; import { AICS_FMS_DATA_SOURCE_NAME } from "../../constants"; +import FuzzyFilter from "../FuzzyFilter"; export interface Source { name: string; @@ -17,12 +18,14 @@ export interface FileExplorerURLComponents { sourceMetadata?: Source; filters: FileFilter[]; openFolders: FileFolder[]; + fuzzyFilters?: FuzzyFilter[]; sortColumn?: FileSort; } export const EMPTY_QUERY_COMPONENTS: FileExplorerURLComponents = { hierarchy: [], filters: [], + fuzzyFilters: [], openFolders: [], sources: [], }; @@ -48,6 +51,7 @@ export const DEFAULT_AICS_FMS_QUERY: FileExplorerURLComponents = { openFolders: [], sources: [{ name: AICS_FMS_DATA_SOURCE_NAME }], filters: [PAST_YEAR_FILTER], + fuzzyFilters: [], sortColumn: new FileSort(AnnotationName.UPLOADED, SortOrder.DESC), }; @@ -83,6 +87,9 @@ export default class FileExplorerURL { urlComponents.filters?.forEach((filter) => { params.append("filter", JSON.stringify(filter.toJSON())); }); + urlComponents.fuzzyFilters?.forEach((fuzzyFilter) => { + params.append("fuzzy", JSON.stringify(fuzzyFilter.toJSON())); + }); urlComponents.openFolders?.map((folder) => { params.append("openFolder", JSON.stringify(folder.fileFolder)); }); @@ -130,9 +137,13 @@ export default class FileExplorerURL { const unparsedFilters = params.getAll("filter"); const unparsedSources = params.getAll("source"); const hierarchy = params.getAll("group"); + const unparsedFuzzyFilters = params.getAll("fuzzy"); const unparsedSort = params.get("sort"); const hierarchyDepth = hierarchy.length; + const parsedFuzzyFilters = unparsedFuzzyFilters + ? unparsedFuzzyFilters.map((unparsedFuzzyFilter) => JSON.parse(unparsedFuzzyFilter)) + : undefined; const parsedSort = unparsedSort ? JSON.parse(unparsedSort) : undefined; if ( parsedSort && @@ -149,6 +160,11 @@ export default class FileExplorerURL { filters: unparsedFilters .map((unparsedFilter) => JSON.parse(unparsedFilter)) .map((parsedFilter) => new FileFilter(parsedFilter.name, parsedFilter.value)), + fuzzyFilters: parsedFuzzyFilters + ? parsedFuzzyFilters.map( + (parsedFuzzyFilter) => new FuzzyFilter(parsedFuzzyFilter.annotationName) + ) + : undefined, 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 8ddb1186f..1988b12c0 100644 --- a/packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts +++ b/packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts @@ -5,6 +5,7 @@ import AnnotationName from "../../Annotation/AnnotationName"; import FileFilter from "../../FileFilter"; import FileFolder from "../../FileFolder"; import FileSort, { SortOrder } from "../../FileSort"; +import FuzzyFilter from "../../FuzzyFilter"; describe("FileExplorerURL", () => { const mockSource: Source = { @@ -27,6 +28,10 @@ describe("FileExplorerURL", () => { { name: "Cas9", value: "spCas9" }, { name: "Donor Plasmid", value: "ACTB-mEGFP" }, ]; + const expectedFuzzyFilters = [ + { annotationName: AnnotationName.FILE_NAME }, + { annotationName: AnnotationName.FILE_PATH }, + ]; const expectedOpenFolders = [ ["AICS-0"], ["AICS-0", "ACTB-mEGFP"], @@ -36,6 +41,9 @@ describe("FileExplorerURL", () => { const components: FileExplorerURLComponents = { hierarchy: expectedAnnotationNames, filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)), + fuzzyFilters: expectedFuzzyFilters.map( + (fuzzyFilter) => new FuzzyFilter(fuzzyFilter.annotationName) + ), openFolders: expectedOpenFolders.map((folder) => new FileFolder(folder)), sortColumn: new FileSort(AnnotationName.FILE_SIZE, SortOrder.DESC), sources: [mockSource], @@ -46,7 +54,7 @@ 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%7D&filter=%7B%22name%22%3A%22Donor+Plasmid%22%2C%22value%22%3A%22ACTB-mEGFP%22%7D&fuzzy=%7B%22annotationName%22%3A%22file_name%22%7D&fuzzy=%7B%22annotationName%22%3A%22file_path%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" ); }); @@ -55,6 +63,7 @@ describe("FileExplorerURL", () => { const components: FileExplorerURLComponents = { hierarchy: [], filters: [], + fuzzyFilters: [], openFolders: [], sources: [], }; @@ -99,6 +108,10 @@ describe("FileExplorerURL", () => { { name: "Cas9", value: "spCas9" }, { name: "Donor Plasmid", value: "ACTB-mEGFP" }, ]; + const expectedFuzzyFilters = [ + { annotationName: AnnotationName.FILE_NAME }, + { annotationName: AnnotationName.FILE_PATH }, + ]; const expectedOpenFolders = [ ["3500000654"], ["3500000654", "ACTB-mEGFP"], @@ -108,6 +121,9 @@ describe("FileExplorerURL", () => { const components: FileExplorerURLComponents = { hierarchy: expectedAnnotationNames, filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)), + fuzzyFilters: expectedFuzzyFilters.map( + (fuzzyFilter) => new FuzzyFilter(fuzzyFilter.annotationName) + ), openFolders: expectedOpenFolders.map((folder) => new FileFolder(folder)), sortColumn: new FileSort(AnnotationName.UPLOADED, SortOrder.DESC), sources: [mockSource], @@ -127,6 +143,7 @@ describe("FileExplorerURL", () => { const components: FileExplorerURLComponents = { hierarchy: [], filters: [], + fuzzyFilters: [], openFolders: [], sortColumn: undefined, sources: [], diff --git a/packages/core/entity/FileSelection/index.ts b/packages/core/entity/FileSelection/index.ts index 2ec8de15b..7c41d20da 100644 --- a/packages/core/entity/FileSelection/index.ts +++ b/packages/core/entity/FileSelection/index.ts @@ -523,6 +523,7 @@ export default class FileSelection { ascending: fileSet.sort.order === SortOrder.ASC, } : undefined, + fuzzy: fileSet?.fuzzyFilters?.map((filter) => filter.annotationName), })); } diff --git a/packages/core/entity/FileSet/index.ts b/packages/core/entity/FileSet/index.ts index dd81c47a8..8f1768336 100644 --- a/packages/core/entity/FileSet/index.ts +++ b/packages/core/entity/FileSet/index.ts @@ -7,10 +7,12 @@ import FileService from "../../services/FileService"; import FileServiceNoop from "../../services/FileService/FileServiceNoop"; import SQLBuilder from "../SQLBuilder"; import FileDetail from "../FileDetail"; +import FuzzyFilter from "../FuzzyFilter"; interface Opts { fileService: FileService; filters: FileFilter[]; + fuzzyFilters?: FuzzyFilter[]; maxCacheSize: number; sort?: FileSort; } @@ -37,6 +39,7 @@ export default class FileSet { private readonly fileService: FileService; private readonly _filters: FileFilter[]; public readonly sort?: FileSort; + public readonly fuzzyFilters?: FuzzyFilter[]; private totalFileCount: number | undefined; private indexesForFilesCurrentlyLoading: Set = new Set(); @@ -45,10 +48,15 @@ export default class FileSet { } constructor(opts: Partial = {}) { - const { fileService, filters, maxCacheSize, sort } = defaults({}, opts, DEFAULT_OPTS); + const { fileService, filters, fuzzyFilters, maxCacheSize, sort } = defaults( + {}, + opts, + DEFAULT_OPTS + ); this.cache = new LRUCache({ max: maxCacheSize }); this._filters = filters; + this.fuzzyFilters = fuzzyFilters; this.sort = sort; this.fileService = fileService; @@ -169,6 +177,15 @@ export default class FileSet { query.push(this.sort.toQueryString()); } + if (this.fuzzyFilters?.length) { + const sortedFuzzyFilters = [...this.fuzzyFilters].sort((a, b) => + a.toQueryString().localeCompare(b.toQueryString()) + ); + query.push( + map(sortedFuzzyFilters, (filterName) => filterName.toQueryString()).join("&") + ); + } + return join(query, "&"); } diff --git a/packages/core/entity/FuzzyFilter/index.ts b/packages/core/entity/FuzzyFilter/index.ts new file mode 100644 index 000000000..f8ce4173b --- /dev/null +++ b/packages/core/entity/FuzzyFilter/index.ts @@ -0,0 +1,25 @@ +/** + * A simple container to represent a fuzzy filter to apply to a file set. + * Responsible for serializing itself into a URL query string friendly format. + */ +export default class FuzzyFilter { + public readonly annotationName: string; + + constructor(annotationName: string) { + this.annotationName = annotationName; + } + + public toQueryString(): string { + return `fuzzy=${this.annotationName}`; + } + + public toJSON(): Record { + return { + annotationName: this.annotationName, + }; + } + + public equals(other?: FuzzyFilter): boolean { + return !!other && this.annotationName === other.annotationName; + } +} diff --git a/packages/core/services/AnnotationService/HttpAnnotationService/index.ts b/packages/core/services/AnnotationService/HttpAnnotationService/index.ts index ff235cad5..c24ac9edf 100644 --- a/packages/core/services/AnnotationService/HttpAnnotationService/index.ts +++ b/packages/core/services/AnnotationService/HttpAnnotationService/index.ts @@ -5,12 +5,14 @@ import HttpServiceBase from "../../HttpServiceBase"; import Annotation, { AnnotationResponse } from "../../../entity/Annotation"; import FileFilter from "../../../entity/FileFilter"; import { TOP_LEVEL_FILE_ANNOTATIONS, TOP_LEVEL_FILE_ANNOTATION_NAMES } from "../../../constants"; +import FuzzyFilter from "../../../entity/FuzzyFilter"; enum QueryParam { FILTER = "filter", HIERARCHY = "hierarchy", ORDER = "order", PATH = "path", + FUZZY = "fuzzy", } /** @@ -50,7 +52,8 @@ export default class HttpAnnotationService extends HttpServiceBase implements An public async fetchRootHierarchyValues( hierarchy: string[], - filters: FileFilter[] + filters: FileFilter[], + fuzzyFilters?: FuzzyFilter[] ): Promise { // It's important that we fetch values for the correct (i.e., first) level of the hierarchy. // But after that, sort the levels so that we can effectively cache the result @@ -63,6 +66,10 @@ export default class HttpAnnotationService extends HttpServiceBase implements An QueryParam.FILTER, filters.map((f) => f.toQueryString()) ), + this.buildQueryParams( + QueryParam.FUZZY, + fuzzyFilters?.map((f) => f.annotationName) || [] + ), ] .filter((param) => !!param) .join("&"); @@ -76,7 +83,8 @@ export default class HttpAnnotationService extends HttpServiceBase implements An public async fetchHierarchyValuesUnderPath( hierarchy: string[], path: string[], - filters: FileFilter[] + filters: FileFilter[], + fuzzyFilters?: FuzzyFilter[] ): Promise { const queryParams = [ this.buildQueryParams(QueryParam.ORDER, hierarchy), @@ -85,6 +93,10 @@ export default class HttpAnnotationService extends HttpServiceBase implements An QueryParam.FILTER, filters.map((f) => f.toQueryString()) ), + this.buildQueryParams( + QueryParam.FUZZY, + fuzzyFilters?.map((f) => f.annotationName) || [] + ), ] .filter((param) => !!param) .join("&"); diff --git a/packages/core/services/AnnotationService/index.ts b/packages/core/services/AnnotationService/index.ts index 2f70902f9..473313ce0 100644 --- a/packages/core/services/AnnotationService/index.ts +++ b/packages/core/services/AnnotationService/index.ts @@ -1,16 +1,22 @@ import Annotation from "../../entity/Annotation"; import FileFilter from "../../entity/FileFilter"; +import FuzzyFilter from "../../entity/FuzzyFilter"; export type AnnotationValue = string | number | boolean | Date; export default interface AnnotationService { fetchValues(annotation: string): Promise; fetchAnnotations(): Promise; - fetchRootHierarchyValues(hierarchy: string[], filters: FileFilter[]): Promise; + fetchRootHierarchyValues( + hierarchy: string[], + filters: FileFilter[], + fuzzyFilters?: FuzzyFilter[] + ): Promise; fetchHierarchyValuesUnderPath( hierarchy: string[], path: string[], - filters: FileFilter[] + filters: FileFilter[], + fuzzyFilters?: FuzzyFilter[] ): Promise; fetchAvailableAnnotationsForHierarchy(annotations: string[]): Promise; } diff --git a/packages/core/services/FileService/index.ts b/packages/core/services/FileService/index.ts index e06b4aa3d..5e5355041 100644 --- a/packages/core/services/FileService/index.ts +++ b/packages/core/services/FileService/index.ts @@ -33,6 +33,7 @@ export interface Selection { annotationName: string; ascending: boolean; }; + fuzzy?: string[]; } export default interface FileService { diff --git a/packages/core/state/selection/actions.ts b/packages/core/state/selection/actions.ts index d78d43690..62f42c9ca 100644 --- a/packages/core/state/selection/actions.ts +++ b/packages/core/state/selection/actions.ts @@ -13,6 +13,7 @@ import { FileExplorerURLComponents, Source, } from "../../entity/FileExplorerURL"; +import FuzzyFilter from "../../entity/FuzzyFilter"; const STATE_BRANCH_NAME = "selection"; @@ -75,6 +76,65 @@ export function removeFileFilter(filter: FileFilter | FileFilter[]): RemoveFileF }; } +/** + * SET_FUZZY_FILTERS + * + * Intention to set, wholesale, a list of FuzzyFilters into application state. This should not be dispatched + * by UI components; dispatch either an ADD_FUZZY_FILTER or REMOVE_FUZZY_FILTER action. Those actions will + * trigger the `modifyFuzzyFilters` logic, which will then dispatch this action. + */ +export const SET_FUZZY_FILTERS = makeConstant(STATE_BRANCH_NAME, "set-fuzzy-filters"); + +export interface SetFuzzyFiltersAction { + payload?: FuzzyFilter[]; + type: string; +} + +export function setFuzzyFilters(fuzzyFilters?: FuzzyFilter[]): SetFuzzyFiltersAction { + return { + payload: fuzzyFilters, + type: SET_FUZZY_FILTERS, + }; +} + +/** + * ADD_FUZZY_FILTER + * + * Intention to apply a FuzzyFilter. + */ +export const ADD_FUZZY_FILTER = makeConstant(STATE_BRANCH_NAME, "add-fuzzy-filter"); + +export interface AddFuzzyFilterAction { + payload: FuzzyFilter | FuzzyFilter[]; + type: string; +} + +export function addFuzzyFilter(filter: FuzzyFilter | FuzzyFilter[]): AddFuzzyFilterAction { + return { + payload: filter, + type: ADD_FUZZY_FILTER, + }; +} + +/** + * REMOVE_FUZZY_FILTER + * + * Intention to remove a currently applied FuzzyFilter. + */ +export const REMOVE_FUZZY_FILTER = makeConstant(STATE_BRANCH_NAME, "remove-fuzzy-filter"); + +export interface RemoveFuzzyFilterAction { + payload: FuzzyFilter | FuzzyFilter[]; + type: string; +} + +export function removeFuzzyFilter(filter: FuzzyFilter | FuzzyFilter[]): RemoveFuzzyFilterAction { + return { + payload: filter, + type: REMOVE_FUZZY_FILTER, + }; +} + /** * SORT_COLUMN * diff --git a/packages/core/state/selection/logics.ts b/packages/core/state/selection/logics.ts index 9bed5877b..adce4ce00 100644 --- a/packages/core/state/selection/logics.ts +++ b/packages/core/state/selection/logics.ts @@ -39,6 +39,9 @@ import { CHANGE_SOURCE_METADATA, ChangeSourceMetadataAction, changeSourceMetadata, + ADD_FUZZY_FILTER, + REMOVE_FUZZY_FILTER, + setFuzzyFilters, } from "./actions"; import { interaction, metadata, ReduxLogicDeps, selection } from "../"; import * as selectionSelectors from "./selectors"; @@ -48,10 +51,10 @@ import FileFilter from "../../entity/FileFilter"; import FileFolder from "../../entity/FileFolder"; import FileSelection from "../../entity/FileSelection"; import FileSet from "../../entity/FileSet"; +import FuzzyFilter from "../../entity/FuzzyFilter"; import HttpAnnotationService from "../../services/AnnotationService/HttpAnnotationService"; import { DataSource } from "../../services/DataSourceService"; import DataSourcePreparationError from "../../errors/DataSourcePreparationError"; - /** * Interceptor responsible for transforming payload of SELECT_FILE actions to account for whether the intention is to * add to existing selected files state, to replace existing selection state, or to remove a file from the existing @@ -269,6 +272,50 @@ const modifyFileFilters = createLogic({ type: [ADD_FILE_FILTER, REMOVE_FILE_FILTER], }); +/** + * Interceptor responsible for transforming ADD_FUZZY_FILTER and REMOVE_FUZZY_FILTER + * actions into a concrete list of ordered FuzzyFilters that can be stored directly in + * application state under `selections.fuzzyFilters`. + */ +const modifyFuzzyFilters = createLogic({ + transform(deps: ReduxLogicDeps, next, reject) { + const { action, getState } = deps; + + const previousFuzzyFilters = selectionSelectors.getFuzzyFilters(getState()) || []; + let nextFuzzyFilters: FuzzyFilter[]; + + const incomingFuzzyFilters = castArray(action.payload); + if (action.type === ADD_FUZZY_FILTER) { + nextFuzzyFilters = uniqWith( + [...previousFuzzyFilters, ...incomingFuzzyFilters], + (existing, incoming) => { + return existing.equals(incoming); + } + ); + } else { + nextFuzzyFilters = previousFuzzyFilters.filter((existing) => { + return !incomingFuzzyFilters.some((incoming) => incoming.equals(existing)); + }); + } + + const sortedNextFuzzyFilters = sortBy(nextFuzzyFilters, ["annotationName"]); + + const filtersAreUnchanged = + previousFuzzyFilters.length === sortedNextFuzzyFilters.length && + previousFuzzyFilters.every((existing) => + sortedNextFuzzyFilters.some((incoming) => incoming.equals(existing)) + ); + + if (filtersAreUnchanged) { + reject && reject(action); + return; + } + + next(setFuzzyFilters(sortedNextFuzzyFilters)); + }, + type: [ADD_FUZZY_FILTER, REMOVE_FUZZY_FILTER], +}); + /** * Interceptor responsible for transforming TOGGLE_FILE_FOLDER_COLLAPSE actions into * SET_OPEN_FILE_FOLDERS actions by determining whether the file folder is to be considered @@ -302,6 +349,7 @@ const decodeFileExplorerURLLogics = createLogic({ const { hierarchy, filters, + fuzzyFilters, openFolders, sortColumn, sources, @@ -313,6 +361,7 @@ const decodeFileExplorerURLLogics = createLogic({ dispatch(changeDataSources(sources)); dispatch(setAnnotationHierarchy(hierarchy)); dispatch(setFileFilters(filters)); + dispatch(setFuzzyFilters(fuzzyFilters)); dispatch(setOpenFileFolders(openFolders)); dispatch(setSortColumn(sortColumn)); }); @@ -660,6 +709,7 @@ export default [ selectFile, modifyAnnotationHierarchy, modifyFileFilters, + modifyFuzzyFilters, toggleFileFolderCollapse, decodeFileExplorerURLLogics, selectNearbyFile, diff --git a/packages/core/state/selection/reducer.ts b/packages/core/state/selection/reducer.ts index 7d0c036da..b072b1309 100644 --- a/packages/core/state/selection/reducer.ts +++ b/packages/core/state/selection/reducer.ts @@ -14,6 +14,7 @@ import { SET_ANNOTATION_HIERARCHY, SET_AVAILABLE_ANNOTATIONS, SET_FILE_FILTERS, + SET_FUZZY_FILTERS, SET_FILE_SELECTION, SET_OPEN_FILE_FOLDERS, RESIZE_COLUMN, @@ -41,6 +42,7 @@ import { import FileSort, { SortOrder } from "../../entity/FileSort"; import Tutorial from "../../entity/Tutorial"; import { Source } from "../../entity/FileExplorerURL"; +import FuzzyFilter from "../../entity/FuzzyFilter"; export interface SelectionStateBranch { annotationHierarchy: string[]; @@ -54,6 +56,7 @@ export interface SelectionStateBranch { fileGridColumnCount: number; fileSelection: FileSelection; filters: FileFilter[]; + fuzzyFilters?: FuzzyFilter[]; isDarkTheme: boolean; openFileFolders: FileFolder[]; recentAnnotations: string[]; @@ -82,6 +85,7 @@ export const initialState = { fileGridColumnCount: THUMBNAIL_SIZE_TO_NUM_COLUMNS.LARGE, fileSelection: new FileSelection(), filters: [], + fuzzyFilters: [], openFileFolders: [], recentAnnotations: [], shouldDisplaySmallFont: false, @@ -118,6 +122,10 @@ export default makeReducer( // Reset file selections when file filters change fileSelection: new FileSelection(), }), + [SET_FUZZY_FILTERS]: (state, action) => ({ + ...state, + fuzzyFilters: action.payload, + }), [SORT_COLUMN]: (state, action) => { if (state.sortColumn?.annotationName === action.payload) { // If already sorting by this column descending diff --git a/packages/core/state/selection/selectors.ts b/packages/core/state/selection/selectors.ts index 6ed04e7d2..a57db18ca 100644 --- a/packages/core/state/selection/selectors.ts +++ b/packages/core/state/selection/selectors.ts @@ -19,6 +19,7 @@ export const getColumnWidths = (state: State) => state.selection.columnWidths; export const getFileGridColumnCount = (state: State) => state.selection.fileGridColumnCount; export const getFileFilters = (state: State) => state.selection.filters; export const getFileSelection = (state: State) => state.selection.fileSelection; +export const getFuzzyFilters = (state: State) => state.selection.fuzzyFilters; export const getIsDarkTheme = (state: State) => state.selection.isDarkTheme; export const getOpenFileFolders = (state: State) => state.selection.openFileFolders; export const getRecentAnnotations = (state: State) => state.selection.recentAnnotations; @@ -45,6 +46,7 @@ export const getCurrentQueryParts = createSelector( [ getAnnotationHierarchy, getFileFilters, + getFuzzyFilters, getOpenFileFolders, getSortColumn, getSelectedDataSources, @@ -53,6 +55,7 @@ export const getCurrentQueryParts = createSelector( ( hierarchy, filters, + fuzzyFilters, openFolders, sortColumn, sources, @@ -60,6 +63,7 @@ export const getCurrentQueryParts = createSelector( ): FileExplorerURLComponents => ({ hierarchy, filters, + fuzzyFilters, openFolders, sortColumn, sources, diff --git a/packages/web/src/components/Home/Features.tsx b/packages/web/src/components/Home/Features.tsx index 5d6e673dc..a09f21afb 100644 --- a/packages/web/src/components/Home/Features.tsx +++ b/packages/web/src/components/Home/Features.tsx @@ -10,7 +10,7 @@ import styles from "./Features.module.css"; * Component responsible for rendering the features section of the home page. */ export default function Features() { - const wrapStackTokens: IStackTokens = { childrenGap: 12 + ' ' + 30 }; + const wrapStackTokens: IStackTokens = { childrenGap: 12 + " " + 30 }; const [{ activeFeatureIndex, activeSlideIndex }, setActiveSlideIndices] = React.useState({ activeFeatureIndex: 0, activeSlideIndex: 0, @@ -73,44 +73,44 @@ export default function Features() { return (
- - -
- {FEATURE_OPTIONS.map((feature, index) => ( - - ))} -
-
- -
- -
- {activeFeature.slides.map((_, index) => ( -
-

{activeSlide.caption}

-
-
+ + +
+ {FEATURE_OPTIONS.map((feature, index) => ( + + ))} +
+
+ +
+ +
+ {activeFeature.slides.map((_, index) => ( +
+

{activeSlide.caption}

+
+
);