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) => (
- changeFeature(index)}
- role="tab"
- aria-selected={activeFeature.id === feature.id}
- key={`feature-${feature.id}`}
- >
- {feature.text}
-
- ))}
-
-
-
-
-
-
- {activeFeature.slides.map((_, index) => (
- changeFeature(activeFeatureIndex, index)}
- role="tab"
- aria-selected={activeSlideIndex === index}
- key={`slide-${index}`}
- />
- ))}
-
-
{activeSlide.caption}
-
-
+
+
+
+ {FEATURE_OPTIONS.map((feature, index) => (
+ changeFeature(index)}
+ role="tab"
+ aria-selected={activeFeature.id === feature.id}
+ key={`feature-${feature.id}`}
+ >
+ {feature.text}
+
+ ))}
+
+
+
+
+
+
+ {activeFeature.slides.map((_, index) => (
+ changeFeature(activeFeatureIndex, index)}
+ role="tab"
+ aria-selected={activeSlideIndex === index}
+ key={`slide-${index}`}
+ />
+ ))}
+
+
{activeSlide.caption}
+
+
);