Context sensitive search (#2102)

Extend global search to search context-sensitive in repositories and namespaces.
This commit is contained in:
Eduard Heimbuch
2022-08-04 11:29:05 +02:00
parent 6c82142643
commit 550ebefd93
34 changed files with 1061 additions and 308 deletions

View File

@@ -0,0 +1,48 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { createContext, FC, useContext, useState } from "react";
export type NamespaceAndNameContext = {
namespace?: string;
setNamespace: (namespace: string) => void;
name?: string;
setName: (name: string) => void;
};
const Context = createContext<NamespaceAndNameContext | undefined>(undefined);
export const useNamespaceAndNameContext = () => {
const context = useContext(Context);
if (!context) {
throw new Error("useNamespaceAndNameContext can't be used outside of ApiProvider");
}
return context;
};
export const NamespaceAndNameContextProvider: FC = ({ children }) => {
const [namespace, setNamespace] = useState("");
const [name, setName] = useState("");
return <Context.Provider value={{ namespace, setNamespace, name, setName }}>{children}</Context.Provider>;
};

View File

@@ -70,3 +70,4 @@ export { default as ApiProvider } from "./ApiProvider";
export * from "./ApiProvider";
export * from "./LegacyContext";
export * from "./NamespaceAndNameContext";

View File

@@ -30,11 +30,20 @@ import { useQueries, useQuery } from "react-query";
import { useEffect, useState } from "react";
import SYNTAX from "./help/search/syntax";
import MODAL from "./help/search/modal";
import { useRepository } from "./repositories";
import { useNamespace } from "./namespaces";
export type SearchOptions = {
type: string;
page?: number;
pageSize?: number;
namespaceContext?: string;
repositoryNameContext?: string;
};
type SearchLinks = {
links: Link[];
isLoading: boolean;
};
const defaultSearchOptions: SearchOptions = {
@@ -43,24 +52,29 @@ const defaultSearchOptions: SearchOptions = {
const isString = (str: string | undefined): str is string => !!str;
export const useSearchTypes = () => {
return useSearchLinks()
.map((link) => link.name)
.filter(isString);
export const useSearchTypes = (options?: SearchOptions) => {
const searchLinks = useSearchLinks(options);
if (searchLinks?.isLoading) {
return [];
}
return searchLinks?.links?.map((link) => link.name).filter(isString) || [];
};
export const useSearchableTypes = () => useIndexJsonResource<SearchableType[]>("searchableTypes");
export const useSearchCounts = (types: string[], query: string) => {
const searchLinks = useSearchLinks();
export const useSearchCounts = (types: string[], query: string, options?: SearchOptions) => {
const { links, isLoading } = useSearchLinks(options);
const result: { [type: string]: ApiResultWithFetching<number> } = {};
const queries = useQueries(
types.map((type) => ({
queryKey: ["search", type, query, "count"],
queryFn: () =>
apiClient.get(`${findLink(searchLinks, type)}?q=${query}&countOnly=true`).then((response) => response.json()),
apiClient.get(`${findLink(links, type)}?q=${query}&countOnly=true`).then((response) => response.json()),
enabled: !isLoading,
}))
);
const result: { [type: string]: ApiResultWithFetching<number> } = {};
queries.forEach((q, i) => {
result[types[i]] = {
isLoading: q.isLoading,
@@ -69,6 +83,7 @@ export const useSearchCounts = (types: string[], query: string) => {
data: (q.data as QueryResult)?.totalHits,
};
});
return result;
};
@@ -81,28 +96,55 @@ const findLink = (links: Link[], name: string) => {
throw new Error(`could not find search link for ${name}`);
};
const useSearchLinks = () => {
const useSearchLinks = (options?: SearchOptions): SearchLinks => {
const links = useIndexLinks();
const { data: namespace, isLoading: namespaceLoading } = useNamespace(options?.namespaceContext || "");
const { data: repo, isLoading: repoLoading } = useRepository(
options?.namespaceContext || "",
options?.repositoryNameContext || ""
);
if (options?.repositoryNameContext) {
return { links: repo?._links["search"] as Link[], isLoading: repoLoading };
}
if (options?.namespaceContext) {
return { links: namespace?._links["search"] as Link[], isLoading: namespaceLoading };
}
const searchLinks = links["search"];
if (!searchLinks) {
throw new Error("could not find search links in index");
throw new Error("could not find useInternalSearch links in index");
}
if (!Array.isArray(searchLinks)) {
throw new Error("search links returned in wrong format, array is expected");
throw new Error("useInternalSearch links returned in wrong format, array is expected");
}
return searchLinks as Link[];
return { links: searchLinks, isLoading: false };
};
const useSearchLink = (name: string) => {
const searchLinks = useSearchLinks();
return findLink(searchLinks, name);
const useSearchLink = (options: SearchOptions) => {
const { links, isLoading } = useSearchLinks(options);
if (isLoading) {
return undefined;
}
return findLink(links, options.type);
};
export const useOmniSearch = (query: string, optionParam = defaultSearchOptions): ApiResult<QueryResult> => {
const options = { ...defaultSearchOptions, ...optionParam };
const link = useSearchLink({ ...options, repositoryNameContext: "", namespaceContext: "" });
return useInternalSearch(query, options, link);
};
export const useSearch = (query: string, optionParam = defaultSearchOptions): ApiResult<QueryResult> => {
const options = { ...defaultSearchOptions, ...optionParam };
const link = useSearchLink(options.type);
const link = useSearchLink(options);
return useInternalSearch(query, options, link);
};
const useInternalSearch = (query: string, options: SearchOptions, link?: string) => {
const queryParams: Record<string, string> = {};
queryParams.q = query;
if (options.page) {
@@ -115,7 +157,7 @@ export const useSearch = (query: string, optionParam = defaultSearchOptions): Ap
["search", options.type, queryParams],
() => apiClient.get(`${link}?${createQueryString(queryParams)}`).then((response) => response.json()),
{
enabled: query?.length > 1,
enabled: query?.length > 1 && !!link,
}
);
};

View File

@@ -22,7 +22,13 @@
* SOFTWARE.
*/
import { concat, getNamespaceAndPageFromMatch, getQueryStringFromLocation, withEndingSlash } from "./urls";
import {
concat,
getNamespaceAndPageFromMatch,
getQueryStringFromLocation,
getValueStringFromLocationByKey,
withEndingSlash,
} from "./urls";
describe("tests for withEndingSlash", () => {
it("should append missing slash", () => {
@@ -102,3 +108,28 @@ describe("tests for getQueryStringFromLocation", () => {
expect(getQueryStringFromLocation(location)).toBeUndefined();
});
});
describe("tests for getValueStringFromLocationByKey", () => {
function createLocation(search: string) {
return {
search,
};
}
it("should return the value string", () => {
const location = createLocation("?name=abc");
expect(getValueStringFromLocationByKey(location, "name")).toBe("abc");
});
it("should return value string from multiple parameters", () => {
const location = createLocation("?x=a&y=b&q=abc&z=c");
expect(getValueStringFromLocationByKey(location, "x")).toBe("a");
expect(getValueStringFromLocationByKey(location, "y")).toBe("b");
expect(getValueStringFromLocationByKey(location, "z")).toBe("c");
});
it("should return undefined if q is not available", () => {
const location = createLocation("?x=a&y=b&z=c");
expect(getValueStringFromLocationByKey(location, "namespace")).toBeUndefined();
});
});

View File

@@ -93,6 +93,15 @@ export function getQueryStringFromLocation(location: { search?: string }): strin
}
}
export function getValueStringFromLocationByKey(location: { search?: string }, key: string): string | undefined {
if (location.search) {
const value = queryString.parse(location.search)[key];
if (value && !Array.isArray(value)) {
return value;
}
}
}
export function stripEndingSlash(url: string) {
if (url.endsWith("/")) {
return url.substring(0, url.length - 1);