Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/enable optional fuzzy search #154

Closed
wants to merge 9 commits into from
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -28,18 +30,34 @@ 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 (
<div className={classNames(props.className, styles.container)}>
<h3 className={styles.title}>{props.title}</h3>
<ChoiceGroup
className={styles.choiceGroup}
label="Filter type"
defaultSelectedKey={isListPicking ? "list-picker" : "search-box"}
defaultSelectedKey={isListPicking ? "list-picker" : defaultSearchBox}
options={[
{
key: "search-box",
text: "Search box",
key: "search-box-exact",
text: "Exact match search",
},
{
key: "search-box-fuzzy",
text: "Partial match search",
},
{
key: "list-picker",
Expand All @@ -50,9 +68,14 @@ export default function SearchBoxForm(props: SearchBoxFormProps) {
onChange={(_, selection) => {
// 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");
}}
/>
Expand All @@ -70,7 +93,7 @@ export default function SearchBoxForm(props: SearchBoxFormProps) {
<SearchBox
defaultValue={props.defaultValue}
onReset={props.onDeselectAll}
onSearch={props.onSearch}
onSearch={onSearchSubmitted}
placeholder={`Search by ${props.fieldName}`}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ describe("<SearchBoxForm/>", () => {
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
Expand Down
19 changes: 19 additions & 0 deletions packages/core/components/AnnotationFilterForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
Expand All @@ -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) => {
Expand Down Expand Up @@ -158,13 +175,15 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) {
return (
<SearchBoxForm
className={styles.picker}
onToggleFuzzySearch={onToggleFuzzySearch}
items={items}
onSelect={onSelect}
onDeselect={onDeselect}
onSelectAll={onSelectAll}
onDeselectAll={onDeselectAll}
onSearch={onSearch}
fieldName={props.annotation.displayName}
fuzzySearchEnabled={fuzzySearchEnabled}
title={`Filter by ${props.annotation.displayName}`}
defaultValue={filtersForAnnotation?.[0]}
/>
Expand Down
4 changes: 3 additions & 1 deletion packages/core/components/DirectoryTree/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe("<DirectoryTree />", () => {
selection: {
annotationHierarchy: [fooAnnotation.name, barAnnotation.name],
displayAnnotations: [...baseDisplayAnnotations, fooAnnotation, barAnnotation],
fuzzyFilters: [],
},
});

Expand Down Expand Up @@ -116,6 +117,15 @@ describe("<DirectoryTree />", () => {
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) =>
Expand Down Expand Up @@ -186,6 +196,21 @@ describe("<DirectoryTree />", () => {
},
},
},
{
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 });
Expand Down
17 changes: 15 additions & 2 deletions packages/core/components/DirectoryTree/useDirectoryHierarchy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -211,6 +222,7 @@ const useDirectoryHierarchy = (
const childNodeFileSet = new FileSet({
fileService,
filters,
fuzzyFilters,
sort: sortColumn,
});

Expand Down Expand Up @@ -255,6 +267,7 @@ const useDirectoryHierarchy = (
collapsed,
fileService,
fileSet,
fuzzyFilters,
hierarchy,
isRoot,
isLeaf,
Expand Down
8 changes: 5 additions & 3 deletions packages/core/components/QueryPart/QueryFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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={() => (
<AnnotationPicker
id={Tutorial.FILE_ATTRIBUTE_FILTER_ID}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/components/SearchBox/test/SearchBox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe("<SearchBox/>", () => {
},
});

// Sanity check
// Consistency check
expect(screen.getByRole<HTMLInputElement>("searchbox").value).to.equal("bar");

// Hit reset
Expand Down
16 changes: 16 additions & 0 deletions packages/core/entity/FileExplorerURL/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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: [],
};
Expand All @@ -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),
};

Expand Down Expand Up @@ -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));
});
Expand Down Expand Up @@ -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 &&
Expand All @@ -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
Expand Down
Loading
Loading