mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-10 07:25:44 +01:00
Context sensitive search (#2102)
Extend global search to search context-sensitive in repositories and namespaces.
This commit is contained in:
48
scm-ui/ui-api/src/NamespaceAndNameContext.tsx
Normal file
48
scm-ui/ui-api/src/NamespaceAndNameContext.tsx
Normal 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>;
|
||||
};
|
||||
@@ -70,3 +70,4 @@ export { default as ApiProvider } from "./ApiProvider";
|
||||
export * from "./ApiProvider";
|
||||
|
||||
export * from "./LegacyContext";
|
||||
export * from "./NamespaceAndNameContext";
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user