This is an automated email from the ASF dual-hosted git repository. pawarprasad123 pushed a commit to branch ATLAS-5246-v1 in repository https://gitbox.apache.org/repos/asf/atlas.git
commit d3a75e45d3b283b866e7cb714a72d58b2dc166ff Author: Prasad Pawar <[email protected]> AuthorDate: Fri May 8 12:53:43 2026 +0530 ATLAS-5287: ATLAS UI: Dashboard- search bar with dropdown (#616) --- .../src/components/GlobalSearch/QuickSearch.tsx | 1185 ++++++++++++-------- dashboard/src/utils/scopedQuickSearchUtils.ts | 466 ++++++++ .../src/views/SideBar/SideBarTree/SideBarTree.tsx | 23 +- 3 files changed, 1165 insertions(+), 509 deletions(-) diff --git a/dashboard/src/components/GlobalSearch/QuickSearch.tsx b/dashboard/src/components/GlobalSearch/QuickSearch.tsx index 272ee107d..c8eba9490 100644 --- a/dashboard/src/components/GlobalSearch/QuickSearch.tsx +++ b/dashboard/src/components/GlobalSearch/QuickSearch.tsx @@ -16,518 +16,715 @@ */ import { - Autocomplete, - CircularProgress, - InputAdornment, - Stack, - TextField, - Typography + Autocomplete, + CircularProgress, + FormControl, + InputAdornment, + MenuItem, + Select, + Stack, + TextField, + Typography, + type SelectChangeEvent } from "@mui/material"; -import { useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { getGlobalSearchResult } from "../../api/apiMethods/searchApiMethod"; import DisplayImage from "../EntityDisplayImage"; import SearchIcon from "@mui/icons-material/Search"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { entityStateReadOnly } from "../../utils/Enum"; import { - extractKeyValueFromEntity, - isEmpty, - serverError + extractKeyValueFromEntity, + isEmpty, + serverError } from "../../utils/Utils"; import parse from "autosuggest-highlight/parse"; import match from "autosuggest-highlight/match"; import ClickAwayListener from "@mui/material/ClickAwayListener"; import AdvancedSearch from "./AdvancedSearch"; import { - HandleValuesType, - QuickSearchOptionListType, - SuggestionDataType + HandleValuesType, + QuickSearchOptionListType, + SuggestionDataType } from "../../models/globalSearchType"; import { AxiosResponse } from "axios"; import { CustomButton } from "@components/muiComponents"; +import { useAppSelector } from "@hooks/reducerHook"; +import { + buildBusinessMetadataScopedOptions, + buildClassificationScopedOptions, + buildEntitiesTreeForQuickSearch, + buildGlossaryTermsTreeForQuickSearch, + entityTreeToScopedOptions, + glossaryTreeToScopedOptions, + type QuickSearchScope, + type ScopedQuickSearchOption +} from "@utils/scopedQuickSearchUtils"; +import { + navigateToBasicTextQuery, + navigateToBusinessMetadataDetailPage, + navigateToClassificationDetailPage, + navigateToGlossaryTermDetailPage, + navigateToServiceTypeEntitySearch +} from "@utils/dashboardSearchUtils"; + +interface GlobalOptionRow { + title: string; + types: string; + entityObj?: any; + scoped?: ScopedQuickSearchOption; +} + +const SCOPE_SELECT_ID = "quick-search-scope-select"; + +const SCOPE_LABELS: Record<QuickSearchScope, string> = { + default: "Select All", + entity: "Entity", + classification: "Classification", + glossary: "Glossary / Terms", + businessMetadata: "Business Metadata" +}; const QuickSearch = () => { - const navigate = useNavigate(); - const location = useLocation(); - const toastId = useRef(null); - const searchParams = new URLSearchParams(location.search); - const [options, setOptions] = useState<any>([]); - const [open, setOpen] = useState<boolean>(false); - const [openAdvanceSearch, setOpenAdvanceSearch] = useState<boolean>(false); - const [loading, setLoading] = useState<boolean>(false); - const [value, setValue] = useState<string>(""); - - const getData = async (searchTerm: string) => { - let entities: QuickSearchOptionListType = []; - let suggestionNames: QuickSearchOptionListType = []; - let quickSearchResp: AxiosResponse | null = null; - let suggestionSearchResp: AxiosResponse | null = null; - try { - setLoading(true); - quickSearchResp = await getGlobalSearchResult("quick", { - params: { query: searchTerm, limit: 5, offset: 0 } - }); - setLoading(false); - } catch (error) { - setLoading(false); - console.error("Error fetching quick search results:", error); - serverError(error, toastId); - } - - try { - setLoading(true); - suggestionSearchResp = await getGlobalSearchResult("suggestions", { - params: { prefixString: searchTerm } - }); - setLoading(false); - } catch (error) { - setLoading(false); - console.error("Error fetching suggestion search results:", error); - serverError(error, toastId); - } - const { searchResults = [] } = quickSearchResp?.data || {}; - const { suggestions = [] }: SuggestionDataType = - suggestionSearchResp?.data || {}; - - entities = !isEmpty(searchResults?.entities) - ? searchResults?.entities?.map((entityDef: any) => { - const { name }: { name: string; found: boolean; key: any } = - extractKeyValueFromEntity(entityDef); - return { - title: `${name}`, - parent: entityDef.typeName, - types: "Entities", - entityObj: entityDef - }; - }) - : [{ title: "No Entities Found", types: "Entities" }]; - - suggestionNames = !isEmpty(suggestions) - ? suggestions.map((suggestion: any) => { - return { - title: `${suggestion}`, - types: "Suggestions" - }; - }) - : [{ title: "No Suggestions Found", types: "Suggestions" }]; - - setOptions([...entities, ...suggestionNames]); - }; - - const onInputChange = (_event: any, value: string) => { - // Sanitize input: remove any potential script tags and validate - const sanitizedValue = value ? value.trim() : ""; - - if (sanitizedValue) { - setOpen(true); - getData(sanitizedValue); - } else { - setOptions([]); - setValue(""); - setOpen(false); - } - }; - - const handleValues = (option: HandleValuesType | string | null) => { - // Handle case when option is a string (direct search query) - if (typeof option === "string") { - const queryValue = option.trim(); - if (queryValue) { - setOpen(false); - setOptions([]); - // URLSearchParams automatically encodes the value, preventing XSS - // Additional validation: ensure query is not empty after trim - const sanitizedQuery = queryValue || "*"; - searchParams.set("query", sanitizedQuery); - searchParams.set("searchType", "basic"); - navigate( - { - pathname: `/search/searchResult`, - search: searchParams.toString() - }, - { replace: true } - ); - } - return; - } - - // Handle case when option is null or undefined - if (!option || typeof option !== "object") { - return; - } - - const { entityObj, title, types } = option; - - // Validate that title exists and is not undefined - if (!title || title === "undefined" || typeof title !== "string") { - return; - } - - // Sanitize title: trim and validate - const sanitizedTitle = title.trim(); - if (!sanitizedTitle) { - return; - } - - setOpen(false); - setOptions([]); - // URLSearchParams automatically encodes the value, preventing XSS - searchParams.set("query", sanitizedTitle); - searchParams.set("searchType", "basic"); - - if (types === "Entities" && entityObj && entityObj.guid) { - navigate( - { - pathname: `/detailPage/${entityObj.guid}` - }, - { replace: true } - ); - } else { - navigate( - { - pathname: `/search/searchResult`, - search: searchParams.toString() - }, - { replace: true } - ); - } - }; - - const handleClickAway = () => { - setOpen(false); - }; - - const handleCloseModal = () => { - setOpenAdvanceSearch(false); - }; - - return ( - <> - <Stack - direction="row" - className="global-search-stack" - alignItems="center" - gap="0.5rem" - > - <ClickAwayListener onClickAway={handleClickAway}> - <Autocomplete - open={open} - loading={loading} - onKeyDown={(e) => { - const code = e.keyCode || e.which; - - switch (code) { - case 13: { // Enter key - e.preventDefault(); - const inputValue = (e.target as HTMLInputElement).value.trim(); - - // If input is empty, use "*" for wildcard search (matching classic UI behavior) - const searchQuery = inputValue === "" ? "*" : inputValue; - - // Try to find exact match in options - const activeOption = options.find( - (option: { title: any }) => - typeof option !== "string" && - option.title === searchQuery - ); - - if (activeOption) { - // If exact match found, use that option - handleValues(activeOption as HandleValuesType); - } else { - // If no match found, trigger basic search with typed value (matching classic UI behavior) - handleValues(searchQuery); - } - break; - } - case 9: // Tab key - case 27: // Escape key - setOpen(false); - break; - default: - break; - } - }} - freeSolo - id="global-search" - disablePortal - className="global-search-autocomplete" - sx={{ - "& + .MuiAutocomplete-popper .MuiAutocomplete-option": { - backgroundColor: "white" - }, - "& + .MuiAutocomplete-popper .MuiAutocomplete-option:hover": { - backgroundColor: "#c7e3ff" - } - }} - value={value} - onChange={(_event: any, newValue: any) => { - if (newValue) { - // Only update options if newValue is a valid option object - if (typeof newValue === "object" && newValue.title) { - setOptions([newValue, ...options]); - } - setValue(newValue); - // Only call handleValues if newValue is a valid option - if (typeof newValue === "object" && newValue.title && newValue.title !== "undefined") { - handleValues(newValue); - } - } else { - setValue(""); - } - }} - clearOnBlur={false} - autoComplete={true} - includeInputInList - noOptionsText={"No Entities"} - disableClearable - onInputChange={onInputChange} - getOptionLabel={(option: string | QuickSearchOptionListType) => { - if (typeof option === "string") { - return option; - } - // Safely extract title, defaulting to empty string if undefined - const title = (option as any)?.title; - return title && typeof title === "string" ? title : ""; - }} - renderOption={(props, option, { inputValue }) => { - const { entityObj, types, parent } = - typeof option !== "string" && - "entityObj" in option && - "types" in option && - "parent" in option - ? (option as { - entityObj: { status?: string; guid?: string }; - types: string; - parent: string; - }) - : { entityObj: null, types: "", parent: "" }; - typeof option !== "string" && - "entityObj" in option && - "types" in option - ? option - : { entityObj: null, types: "", parent: "" }; - const title = - typeof option !== "string" && "title" in option - ? option.title - : option; - - // Validate and sanitize title to prevent XSS - const safeTitle = typeof title === "string" ? title : ""; - - // Validate guid to prevent XSS in URL - const guid = (entityObj as { guid?: string })?.guid; - const safeGuid = guid && typeof guid === "string" ? guid : ""; - const href = safeGuid ? `/detailPage/${safeGuid}` : "#"; - - const { name }: { name: string; found: boolean; key: any } = - extractKeyValueFromEntity(entityObj); - - // Safely handle name extraction - const safeName = name && typeof name === "string" ? name : ""; - - const matches = match( - types === "Entities" ? safeName : safeTitle, - inputValue || "", - { - findAllOccurrences: true, - insideWords: true - } - ); - const parts = parse( - types === "Entities" ? safeName : safeTitle, - matches - ); - return ( - <Stack - flexDirection="row" - component="li" - className="global-search-options" - sx={{ - "& > span": { - mr: 2, - flexShrink: 0 - } - }} - {...props} - onClick={() => { - if (typeof option !== "string") { - handleValues(option as unknown as HandleValuesType); - } - }} - > - {types === "Entities" && !isEmpty(entityObj) ? ( - <Link - className="entity-name text-decoration-none" - style={{ - maxWidth: "100%", - width: "100%", - color: "black", - textDecoration: "none", - display: "inline-flex", - alignItems: "center", - flexWrap: "wrap" - }} - to={{ - pathname: href - }} - color={ - entityObj?.status && - entityStateReadOnly[entityObj.status] - ? "error" - : "primary" - } - > - {" "} - {types === "Entities" && !isEmpty(entityObj) && ( - <DisplayImage entity={entityObj} /> - )}{" "} - {types === "Entities" && !isEmpty(entityObj) - ? parts.map((part, index) => ( - <Stack - flexDirection="row" - key={index} - style={{ - fontWeight: part.highlight ? "bold" : "regular" - }} - > - {entityObj?.guid !== "-1" && !part.highlight ? ( - <Link - className="entity-name text-blue text-decoration-none" - style={{ - color: "black", - textDecoration: "none", - maxWidth: "100%", - width: "100%" - }} - to={{ - pathname: href - }} - color={ - entityObj?.status && - entityStateReadOnly[entityObj.status] - ? "error" - : "primary" - } - > - {part.text} - </Link> - ) : ( - part.text - )} - </Stack> - )) - : parts.map((part, index) => ( - <Stack - flexDirection="row" - key={index} - style={{ - fontWeight: part.highlight ? "bold" : "regular" - }} - > - {part.text} - </Stack> - ))} - {types === "Entities" && - !isEmpty(entityObj) && - ` (${parent})`} - </Link> - ) : ( - parts.map((part, index) => ( - <Typography - component="p" - key={index} - className="global-search-options-text" - sx={{ - fontWeight: part.highlight ? "bold" : "regular" - }} - > - {" "} - {part.text} - </Typography> - )) - )} - </Stack> - ); - }} - renderInput={(params) => ( - <TextField - {...params} - placeholder="Search Entities..." - fullWidth - onClick={() => { - setOpen(true); - }} - className="text-black-default" - InputProps={{ - style: { - padding: "1px 10px", - borderRadius: "4px", - color: "white !important", - opacity: 1 - }, - ...params.InputProps, - type: "search", - endAdornment: ( - <InputAdornment position="start"> - {loading ? ( - <CircularProgress sx={{ color: "gray" }} size={15} /> - ) : ( - <SearchIcon fontSize="small" /> - )} - </InputAdornment> - ) - }} - /> - )} - groupBy={(option) => - typeof option !== "string" && "types" in option - ? String(option.types) - : "" - } - options={options} - filterOptions={(options) => options} - /> - </ClickAwayListener> - - <CustomButton - variant="outlined" - size="small" - sx={{ - backgroundColor: "white !important", - color: "#4a90e2 !important", - borderColor: "#dddddd !important", - "&:hover": { - backgroundColor: "rgba(74, 144, 226, 0.08) !important", - // borderColor: "#4a90e2 !important", - color: "#4a90e2 !important" - } - }} - onClick={() => { - setOpenAdvanceSearch(true); - }} - > - <Typography - sx={{ - color: "#4a90e2 !important", - fontWeight: "600 !important", - fontSize: "0.875rem !important" - }} - display="inline" - > - Advanced - </Typography> - </CustomButton> - </Stack> - - {openAdvanceSearch && ( - <AdvancedSearch - openAdvanceSearch={openAdvanceSearch} - handleCloseModal={handleCloseModal} - /> - )} - </> - ); + const navigate = useNavigate(); + const location = useLocation(); + const toastId = useRef(null); + const searchParams = new URLSearchParams(location.search); + const [options, setOptions] = useState<GlobalOptionRow[]>([]); + const [open, setOpen] = useState<boolean>(false); + const [openAdvanceSearch, setOpenAdvanceSearch] = useState<boolean>(false); + const [loading, setLoading] = useState<boolean>(false); + const [inputText, setInputText] = useState<string>(""); + const [scope, setScope] = useState<QuickSearchScope>("default"); + + const { typeHeaderData } = useAppSelector((state: any) => state.typeHeader); + const { metricsData } = useAppSelector((state: any) => state.metrics); + const { allEntityTypesData } = useAppSelector((state: any) => state.allEntityTypes); + const { classificationData } = useAppSelector((state: any) => state.classification); + const { glossaryData } = useAppSelector((state: any) => state.glossary); + const { businessMetaData } = useAppSelector((state: any) => state.businessMetaData); + + const entityTree = useMemo( + () => + buildEntitiesTreeForQuickSearch( + typeHeaderData, + metricsData?.data?.entity, + allEntityTypesData?.category + ), + [typeHeaderData, metricsData, allEntityTypesData] + ); + + const rebuildScopedOptions = useCallback( + (term: string): GlobalOptionRow[] => { + if (scope === "entity") { + return entityTreeToScopedOptions(entityTree, term).map((o) => ({ + title: o.title, + types: o.group, + scoped: o + })); + } + if (scope === "classification") { + return buildClassificationScopedOptions( + classificationData, + metricsData?.data?.tag?.tagEntities, + term + ).map((o) => ({ + title: o.title, + types: o.group, + scoped: o + })); + } + if (scope === "glossary") { + const gTree = buildGlossaryTermsTreeForQuickSearch(glossaryData); + return glossaryTreeToScopedOptions(gTree, term).map((o) => ({ + title: o.title, + types: o.group, + scoped: o + })); + } + if (scope === "businessMetadata") { + return buildBusinessMetadataScopedOptions( + businessMetaData?.businessMetadataDefs, + term + ).map((o) => ({ + title: o.title, + types: o.group, + scoped: o + })); + } + return []; + }, + [ + scope, + entityTree, + classificationData, + metricsData, + glossaryData, + businessMetaData + ] + ); + + const getGlobalSearchData = async (searchTerm: string) => { + let entities: QuickSearchOptionListType = []; + let suggestionNames: QuickSearchOptionListType = []; + let quickSearchResp: AxiosResponse | null = null; + let suggestionSearchResp: AxiosResponse | null = null; + try { + setLoading(true); + quickSearchResp = await getGlobalSearchResult("quick", { + params: { query: searchTerm, limit: 5, offset: 0 } + }); + setLoading(false); + } catch (error) { + setLoading(false); + console.error("Error fetching quick search results:", error); + serverError(error, toastId); + } + + try { + setLoading(true); + suggestionSearchResp = await getGlobalSearchResult("suggestions", { + params: { prefixString: searchTerm } + }); + setLoading(false); + } catch (error) { + setLoading(false); + console.error("Error fetching suggestion search results:", error); + serverError(error, toastId); + } + const { searchResults = [] } = quickSearchResp?.data || {}; + const { suggestions = [] }: SuggestionDataType = + suggestionSearchResp?.data || {}; + + entities = !isEmpty(searchResults?.entities) + ? searchResults?.entities?.map((entityDef: any) => { + const { name }: { name: string; found: boolean; key: any } = + extractKeyValueFromEntity(entityDef); + return { + title: `${name}`, + parent: entityDef.typeName, + types: "Entities", + entityObj: entityDef + }; + }) + : [{ title: "No Entities Found", types: "Entities" }]; + + suggestionNames = !isEmpty(suggestions) + ? suggestions.map((suggestion: any) => { + return { + title: `${suggestion}`, + types: "Suggestions" + }; + }) + : [{ title: "No Suggestions Found", types: "Suggestions" }]; + + setOptions([...entities, ...suggestionNames] as GlobalOptionRow[]); + }; + + const onInputChange = (_event: unknown, value: string) => { + const sanitizedValue = value ? value.trim() : ""; + setInputText(value ?? ""); + if (!sanitizedValue) { + setOptions([]); + setOpen(false); + return; + } + setOpen(true); + if (scope === "default") { + void getGlobalSearchData(sanitizedValue); + } else { + setOptions(rebuildScopedOptions(sanitizedValue)); + } + }; + + const handleScopedSelection = (opt: ScopedQuickSearchOption) => { + setOpen(false); + setOptions([]); + setInputText(""); + if (opt.kind === "entity-type" && opt.entityTypeName) { + navigateToBasicTextQuery(navigate, opt.entityTypeName); + return; + } + if (opt.kind === "entity-service" && opt.serviceUnderlyingTypeNames?.length) { + navigateToServiceTypeEntitySearch( + navigate, + opt.serviceUnderlyingTypeNames, + false + ); + return; + } + if (opt.kind === "classification" && opt.classificationName) { + navigateToClassificationDetailPage(navigate, opt.classificationName); + return; + } + if ( + opt.kind === "glossary-term" && + opt.termGuid && + opt.glossaryGuid && + opt.termId && + opt.termParent + ) { + navigateToGlossaryTermDetailPage(navigate, { + termGuid: opt.termGuid, + termId: opt.termId, + glossaryGuid: opt.glossaryGuid, + parentName: opt.termParent + }); + return; + } + if (opt.kind === "business-metadata" && opt.bmGuid) { + navigateToBusinessMetadataDetailPage(navigate, opt.bmGuid); + } + }; + + const handleValues = (option: HandleValuesType | GlobalOptionRow | string | null) => { + if (typeof option === "string") { + const queryValue = option.trim(); + if (!queryValue) return; + setOpen(false); + setOptions([]); + setInputText(""); + if (scope !== "default") { + return; + } + const sanitizedQuery = queryValue || "*"; + searchParams.set("query", sanitizedQuery); + searchParams.set("searchType", "basic"); + navigate( + { + pathname: `/search/searchResult`, + search: searchParams.toString() + }, + { replace: true } + ); + return; + } + + if (!option || typeof option !== "object") return; + + const row = option as GlobalOptionRow; + if (row.scoped) { + handleScopedSelection(row.scoped); + return; + } + + const { entityObj, title, types } = row as HandleValuesType; + if (!title || title === "undefined" || typeof title !== "string") return; + + const sanitizedTitle = title.trim(); + if (!sanitizedTitle) return; + + setOpen(false); + setOptions([]); + setInputText(""); + + searchParams.set("query", sanitizedTitle); + searchParams.set("searchType", "basic"); + + if (types === "Entities" && entityObj && entityObj.guid) { + navigate( + { + pathname: `/detailPage/${entityObj.guid}` + }, + { replace: true } + ); + } else { + navigate( + { + pathname: `/search/searchResult`, + search: searchParams.toString() + }, + { replace: true } + ); + } + }; + + const handleSubmitSearch = () => { + const q = inputText.trim(); + if (scope !== "default") { + const activeOption = options.find((o) => o.title === q); + if (activeOption?.scoped) { + handleScopedSelection(activeOption.scoped); + } + return; + } + if (!q) { + handleValues("*"); + return; + } + const activeOption = options.find( + (o) => typeof o !== "string" && o.title === q + ); + if (activeOption) { + handleValues(activeOption as HandleValuesType); + } else { + handleValues(q); + } + }; + + const handleScopeChange = (e: SelectChangeEvent<QuickSearchScope>) => { + const v = e.target.value as QuickSearchScope; + setScope(v); + setOptions([]); + setInputText(""); + setOpen(false); + }; + + const handleClickAway = () => { + setOpen(false); + }; + + const handleCloseModal = () => { + setOpenAdvanceSearch(false); + }; + + const inputPlaceholder = + scope === "default" ? "Search Entities..." : "Contains text..."; + + return ( + <> + <Stack + direction="row" + className="global-search-stack" + alignItems="center" + gap="0.5rem" + > + <FormControl + size="small" + sx={{ minWidth: 160, backgroundColor: "white", borderRadius: 1 }} + > + <Select<QuickSearchScope> + id={SCOPE_SELECT_ID} + value={scope} + onChange={handleScopeChange} + aria-label="Search scope" + displayEmpty + renderValue={(v) => SCOPE_LABELS[v as QuickSearchScope]} + > + <MenuItem value="default">Select All</MenuItem> + <MenuItem value="entity">Entity</MenuItem> + <MenuItem value="classification">Classification</MenuItem> + <MenuItem value="glossary">Glossary / Terms</MenuItem> + <MenuItem value="businessMetadata">Business Metadata</MenuItem> + </Select> + </FormControl> + + <ClickAwayListener onClickAway={handleClickAway}> + <Autocomplete + open={open} + loading={loading} + inputValue={inputText} + onInputChange={onInputChange} + onKeyDown={(e) => { + const code = e.keyCode || e.which; + switch (code) { + case 13: { + e.preventDefault(); + handleSubmitSearch(); + break; + } + case 9: + case 27: + setOpen(false); + break; + default: + break; + } + }} + freeSolo + id="global-search" + disablePortal + className="global-search-autocomplete" + sx={{ + minWidth: 280, + flex: 1, + "& + .MuiAutocomplete-popper .MuiAutocomplete-option": { + backgroundColor: "white" + }, + "& + .MuiAutocomplete-popper .MuiAutocomplete-option:hover": { + backgroundColor: "#c7e3ff" + } + }} + value={null} + onChange={(_event: unknown, newValue: unknown) => { + if (newValue && typeof newValue === "object") { + const o = newValue as GlobalOptionRow; + if (o.title) { + handleValues(o); + } + } + }} + clearOnBlur={false} + autoComplete + includeInputInList + noOptionsText={scope === "default" ? "No Entities" : "No matches"} + getOptionLabel={(option: string | GlobalOptionRow) => { + if (typeof option === "string") return option; + return option?.title && typeof option.title === "string" + ? option.title + : ""; + }} + renderOption={(props, option, { inputValue }) => { + const row = option as GlobalOptionRow; + if (row.scoped) { + const title = row.title; + const matches = match(title, inputValue || "", { + findAllOccurrences: true, + insideWords: true + }); + const parts = parse(title, matches); + return ( + <li {...props} key={row.scoped.id}> + <Typography component="span" variant="body2"> + {parts.map((part, index) => ( + <span + key={index} + style={{ + fontWeight: part.highlight ? 700 : 400 + }} + > + {part.text} + </span> + ))} + </Typography> + </li> + ); + } + + const { entityObj, types, parent } = + typeof option !== "string" && + "entityObj" in option && + "types" in option && + "parent" in option + ? (option as { + entityObj: { status?: string; guid?: string }; + types: string; + parent: string; + }) + : { entityObj: null, types: "", parent: "" }; + const title = + typeof option !== "string" && "title" in option + ? option.title + : option; + const safeTitle = typeof title === "string" ? title : ""; + const guid = (entityObj as { guid?: string })?.guid; + const safeGuid = guid && typeof guid === "string" ? guid : ""; + const href = safeGuid ? `/detailPage/${safeGuid}` : "#"; + const { name }: { name: string; found: boolean; key: any } = + extractKeyValueFromEntity(entityObj); + const safeName = name && typeof name === "string" ? name : ""; + const matches = match( + types === "Entities" ? safeName : safeTitle, + inputValue || "", + { + findAllOccurrences: true, + insideWords: true + } + ); + const parts = parse( + types === "Entities" ? safeName : safeTitle, + matches + ); + return ( + <Stack + flexDirection="row" + component="li" + className="global-search-options" + sx={{ + "& > span": { + mr: 2, + flexShrink: 0 + } + }} + {...props} + onClick={() => { + handleValues(option as GlobalOptionRow); + }} + > + {types === "Entities" && !isEmpty(entityObj) ? ( + <Link + className="entity-name text-decoration-none" + style={{ + maxWidth: "100%", + width: "100%", + color: "black", + textDecoration: "none", + display: "inline-flex", + alignItems: "center", + flexWrap: "wrap" + }} + to={{ pathname: href }} + color={ + entityObj?.status && + entityStateReadOnly[entityObj.status] + ? "error" + : "primary" + } + > + {types === "Entities" && !isEmpty(entityObj) && ( + <DisplayImage entity={entityObj} /> + )} + {types === "Entities" && !isEmpty(entityObj) + ? parts.map((part, index) => ( + <Stack + flexDirection="row" + key={index} + style={{ + fontWeight: part.highlight ? "bold" : "regular" + }} + > + {entityObj?.guid !== "-1" && !part.highlight ? ( + <Link + className="entity-name text-blue text-decoration-none" + style={{ + color: "black", + textDecoration: "none", + maxWidth: "100%", + width: "100%" + }} + to={{ pathname: href }} + color={ + entityObj?.status && + entityStateReadOnly[entityObj.status] + ? "error" + : "primary" + } + > + {part.text} + </Link> + ) : ( + part.text + )} + </Stack> + )) + : parts.map((part, index) => ( + <Stack + flexDirection="row" + key={index} + style={{ + fontWeight: part.highlight ? "bold" : "regular" + }} + > + {part.text} + </Stack> + ))} + {types === "Entities" && + !isEmpty(entityObj) && + ` (${parent})`} + </Link> + ) : ( + parts.map((part, index) => ( + <Typography + component="p" + key={index} + className="global-search-options-text" + sx={{ + fontWeight: part.highlight ? "bold" : "regular" + }} + > + {part.text} + </Typography> + )) + )} + </Stack> + ); + }} + renderInput={(params) => ( + <TextField + {...params} + placeholder={inputPlaceholder} + fullWidth + onClick={() => { + setOpen(true); + }} + className="text-black-default" + InputProps={{ + style: { + padding: "1px 10px", + borderRadius: "4px", + color: "#1a1a1a", + backgroundColor: "white" + }, + ...params.InputProps, + type: "search", + endAdornment: ( + <InputAdornment position="end"> + {loading ? ( + <CircularProgress sx={{ color: "gray" }} size={15} /> + ) : ( + <SearchIcon fontSize="small" sx={{ color: "gray" }} /> + )} + </InputAdornment> + ) + }} + inputProps={{ + ...params.inputProps, + "aria-label": "Global search" + }} + /> + )} + groupBy={(option) => + typeof option !== "string" && "types" in option + ? String((option as GlobalOptionRow).types) + : "" + } + options={options} + filterOptions={(x) => x} + /> + </ClickAwayListener> + + <CustomButton + variant="contained" + size="small" + sx={{ + backgroundColor: "#4a90e2 !important", + color: "#fff !important", + textTransform: "none", + fontWeight: 600 + }} + onClick={handleSubmitSearch} + aria-label="Run search" + > + Search + </CustomButton> + + <CustomButton + variant="outlined" + size="small" + sx={{ + backgroundColor: "white !important", + color: "#4a90e2 !important", + borderColor: "#dddddd !important", + "&:hover": { + backgroundColor: "rgba(74, 144, 226, 0.08) !important", + color: "#4a90e2 !important" + } + }} + onClick={() => { + setOpenAdvanceSearch(true); + }} + > + <Typography + sx={{ + color: "#4a90e2 !important", + fontWeight: "600 !important", + fontSize: "0.875rem !important" + }} + display="inline" + > + Advanced + </Typography> + </CustomButton> + </Stack> + + {openAdvanceSearch && ( + <AdvancedSearch + openAdvanceSearch={openAdvanceSearch} + handleCloseModal={handleCloseModal} + /> + )} + </> + ); }; export default QuickSearch; diff --git a/dashboard/src/utils/scopedQuickSearchUtils.ts b/dashboard/src/utils/scopedQuickSearchUtils.ts new file mode 100644 index 000000000..81a97c44e --- /dev/null +++ b/dashboard/src/utils/scopedQuickSearchUtils.ts @@ -0,0 +1,466 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { TreeNode } from "@models/treeStructureType"; +import type { + ChildrenInterface, + ServiceTypeInterface, + TypeHeaderInterface +} from "@models/entityTreeType"; +import { addOnEntities } from "./Enum"; +import { customSortBy, customSortByObjectKeys, isEmpty } from "./Utils"; + +export type QuickSearchScope = + | "default" + | "entity" + | "classification" + | "glossary" + | "businessMetadata"; + +export type ScopedOptionKind = + | "entity-service" + | "entity-type" + | "classification" + | "glossary-term" + | "business-metadata"; + +export interface ScopedQuickSearchOption { + id: string; + title: string; + group: string; + kind: ScopedOptionKind; + entityTypeName?: string; + serviceUnderlyingTypeNames?: string[]; + classificationName?: string; + termGuid?: string; + glossaryGuid?: string; + termId?: string; + termParent?: string; + bmGuid?: string; +} + +interface MetricsEntitySnapshot { + entityActive?: Record<string, number>; + entityDeleted?: Record<string, number>; +} + +interface GlossaryTermCategoryRow { + parentCategoryGuid?: string; + categoryGuid?: string; + displayText?: string; + termGuid?: string; +} + +interface GlossaryApiRow { + name: string; + guid: string; + categories?: GlossaryTermCategoryRow[]; + terms?: GlossaryTermCategoryRow[]; +} + +const DEFAULT_SERVICE = "other_types"; + +const generateServiceTypeArr = ( + entityCountArr: ServiceTypeInterface[], + serviceType: string, + children: ChildrenInterface, + entityCount: number +) => { + const existing = entityCountArr.find( + (obj): obj is ServiceTypeInterface => + typeof obj === "object" && obj !== null && serviceType in obj + ); + if (existing) { + const bucket = (existing as Record<string, { + children: ChildrenInterface[]; + totalCount: number; + }>)[serviceType]; + if (bucket) { + bucket.children.push(children); + bucket.totalCount += entityCount; + } + } else { + entityCountArr.push({ + [serviceType]: { + children: [children], + name: serviceType, + totalCount: entityCount + } + } as ServiceTypeInterface); + } +}; + +const pushRootEntityToTree = ( + entities: ServiceTypeInterface[], + allEntityCategory: string | undefined +) => { + const rootEntityChildren: ChildrenInterface = { + gType: "Entity", + guid: addOnEntities[0], + id: addOnEntities[0], + name: addOnEntities[0], + type: allEntityCategory, + text: addOnEntities[0] + }; + const hasOther = entities.some((obj) => obj["other_types"] !== undefined); + if (hasOther) { + const idx = entities.findIndex((obj) => "other_types" in obj); + (entities[idx] as ServiceTypeInterface)["other_types"].children.push( + rootEntityChildren + ); + } else { + entities.push({ + other_types: { + name: "other_types", + children: [rootEntityChildren], + totalCount: 0 + } + } as ServiceTypeInterface); + } + return entities; +}; + +/** + * Mirrors {@link EntitiesTree} group view (service type → entity typedefs). + */ +export const buildEntitiesTreeForQuickSearch = ( + typeHeaderData: TypeHeaderInterface[] | null | undefined, + metricsEntity: MetricsEntitySnapshot | null | undefined, + allEntityCategory: string | undefined +): TreeNode[] => { + if (!Array.isArray(typeHeaderData) || !typeHeaderData.length || !metricsEntity) { + return []; + } + const active = metricsEntity.entityActive || {}; + const deleted = metricsEntity.entityDeleted || {}; + const newArr: ServiceTypeInterface[] = []; + + typeHeaderData.forEach((entity) => { + let { serviceType = DEFAULT_SERVICE, category, name, guid } = entity; + if (category !== "ENTITY") return; + const entityCount = + Number(active[name] ?? 0) + Number(deleted[name] ?? 0); + const modelName = entityCount ? `${name} (${entityCount})` : name; + const children: ChildrenInterface = { + text: modelName, + name, + type: category, + gType: "Entity", + guid, + id: guid + }; + generateServiceTypeArr(newArr, serviceType, children, entityCount); + }); + + pushRootEntityToTree(newArr, allEntityCategory); + + const child = (childs: ChildrenInterface[]) => { + if (!childs?.length) return []; + return customSortBy( + childs.map((obj) => ({ + id: obj.name as string, + label: obj.text as string, + types: "child" + })), + ["label"] + ); + }; + + const sorted = customSortByObjectKeys(newArr); + return sorted.map((entity: ServiceTypeInterface) => { + const key = Object.keys(entity)[0]; + const entityData = entity[key]; + return { + id: entityData.name, + label: + entityData.totalCount === 0 + ? entityData.name + : `${entityData.name} (${entityData.totalCount})`, + children: child(entityData.children), + types: "parent" + }; + }); +}; + +const filterTreeNodes = (treeData: TreeNode[], searchTerm: string): TreeNode[] => { + const q = searchTerm.trim().toLowerCase(); + if (!q) return treeData; + return treeData + .filter( + (node) => + node.label?.toLowerCase().includes(q) || + node.children?.some((child) => child.label?.toLowerCase().includes(q)) + ) + .map((node) => { + const parentMatches = node.label?.toLowerCase().includes(q) ?? false; + const kids = node.children?.length + ? parentMatches + ? node.children + : node.children.filter((c) => c.label?.toLowerCase().includes(q)) + : undefined; + return { ...node, children: kids }; + }); +}; + +export const entityTreeToScopedOptions = ( + tree: TreeNode[], + searchTerm: string +): ScopedQuickSearchOption[] => { + const filtered = filterTreeNodes(tree, searchTerm); + const out: ScopedQuickSearchOption[] = []; + for (const parent of filtered) { + const underlying = + parent.children?.map((c) => c.id).filter(Boolean) ?? []; + out.push({ + id: `svc:${parent.id}`, + title: parent.label, + group: "Entity", + kind: "entity-service", + serviceUnderlyingTypeNames: underlying + }); + for (const child of parent.children ?? []) { + out.push({ + id: `type:${child.id}`, + title: child.label, + group: "Entity", + kind: "entity-type", + entityTypeName: child.id + }); + } + } + return out; +}; + +interface ClassificationDefRow { + name: string; + subTypes: string[]; + guid: string; + superTypes: string[]; +} + +/** Depth-first classification list (same traversal as sidebar tree). */ +export const buildClassificationScopedOptions = ( + classificationData: { classificationDefs: ClassificationDefRow[] } | null, + tagEntities: Record<string, number> | undefined, + searchTerm: string +): ScopedQuickSearchOption[] => { + if (!classificationData?.classificationDefs?.length) return []; + const defs = classificationData.classificationDefs; + const byName = new Map(defs.map((d) => [d.name, d])); + const q = searchTerm.trim().toLowerCase(); + const out: ScopedQuickSearchOption[] = []; + const seen = new Set<string>(); + + const visit = (typeName: string) => { + if (seen.has(typeName)) return; + seen.add(typeName); + const def = byName.get(typeName); + if (!def) return; + const count = tagEntities?.[typeName]; + const label = + count !== undefined ? `${typeName} (${count})` : typeName; + if (!q || label.toLowerCase().includes(q) || typeName.toLowerCase().includes(q)) { + out.push({ + id: `cls:${def.guid}:${typeName}`, + title: label, + group: "Classification", + kind: "classification", + classificationName: typeName + }); + } + (def.subTypes ?? []).forEach((st) => visit(st)); + }; + + defs + .filter((d) => isEmpty(d.superTypes)) + .forEach((d) => visit(d.name)); + + return customSortBy(out, ["title"]); +}; + +/** Build glossary tree for “terms” mode (see {@link GlossaryTree} glossaryType true). */ +export const buildGlossaryTermsTreeForQuickSearch = ( + glossaryData: GlossaryApiRow[] | null | undefined +): TreeNode[] => { + if (!glossaryData?.length) return []; + const glossaryType = true; + + const toServiceRows: Record<string, { + name: string; + children: ChildrenInterface[]; + id: string; + types: string; + parent: string; + guid: string; + }>[] = glossaryData.map((glossary) => { + const categoryRelation = + glossary.categories?.filter((o) => o.parentCategoryGuid !== undefined) ?? + []; + + const getChildren = (glossaries: { + children: NonNullable<GlossaryApiRow["terms"]>; + parent: string; + }) => { + if (isEmpty(glossaries.children)) return []; + return glossaries.children + .map((glossariesType) => { + const getChild = () => + categoryRelation + .map((obj) => { + if (obj.parentCategoryGuid === glossariesType.categoryGuid) { + return { + name: obj.displayText, + id: obj.displayText, + children: [] as ChildrenInterface[], + types: "child", + parent: glossaries.parent, + cGuid: glossaryType ? obj.termGuid : obj.categoryGuid, + guid: glossary.guid + }; + } + return undefined; + }) + .filter(Boolean) as ChildrenInterface[]; + if (glossariesType.parentCategoryGuid === undefined) { + return { + name: glossariesType.displayText, + id: glossariesType.displayText, + children: getChild(), + types: "child", + parent: glossaries.parent, + cGuid: glossaryType + ? glossariesType.termGuid + : glossariesType.categoryGuid, + guid: glossary.guid + } as ChildrenInterface; + } + return undefined; + }) + .filter(Boolean) as ChildrenInterface[]; + }; + + const children = getChildren({ + children: glossary?.terms ?? [], + parent: glossary.name + }); + + return { + [glossary.name]: { + name: glossary.name, + children: children || [], + id: glossary.guid, + types: "parent", + parent: glossary.name, + guid: glossary.guid + } + }; + }); + + const child = (childs: ChildrenInterface[]): TreeNode[] => { + if (!childs?.length) return []; + return customSortBy( + childs.map((obj) => ({ + id: obj?.name as string, + label: obj?.name as string, + children: + obj?.children !== undefined + ? child(obj.children.filter(Boolean) as ChildrenInterface[]) + : [], + types: obj?.types, + parent: obj?.parent, + guid: obj?.guid, + cGuid: obj?.cGuid + })), + ["label"] + ); + }; + + const sorted = customSortByObjectKeys(toServiceRows as ServiceTypeInterface[]); + return sorted.map((entity: Record<string, { + name: string; + children: ChildrenInterface[]; + types: string; + parent: string; + guid: string; + }>) => { + const g = entity[Object.keys(entity)[0]]; + return { + id: g.name, + label: g.name, + children: child((g.children ?? []) as ChildrenInterface[]), + types: g.types, + parent: g.parent, + guid: g.guid + }; + }); +}; + +const collectGlossaryTermOptions = ( + nodes: TreeNode[], + glossaryLabel: string, + out: ScopedQuickSearchOption[] +) => { + for (const n of nodes) { + if (n.cGuid && n.types === "child" && n.id) { + out.push({ + id: `term:${n.cGuid}`, + title: `${n.label} (${glossaryLabel})`, + group: "Glossary / Terms", + kind: "glossary-term", + termGuid: n.cGuid as string, + glossaryGuid: (n.guid as string) ?? "", + termId: n.id, + termParent: (n.parent as string) ?? glossaryLabel + }); + } + if (n.children?.length) { + collectGlossaryTermOptions(n.children, glossaryLabel, out); + } + } +}; + +export const glossaryTreeToScopedOptions = ( + tree: TreeNode[], + searchTerm: string +): ScopedQuickSearchOption[] => { + const filtered = filterTreeNodes(tree, searchTerm); + const out: ScopedQuickSearchOption[] = []; + for (const parent of filtered) { + collectGlossaryTermOptions(parent.children ?? [], parent.label, out); + } + return customSortBy(out, ["title"]); +}; + +export const buildBusinessMetadataScopedOptions = ( + businessMetadataDefs: Array<{ name: string; guid: string }> | undefined, + searchTerm: string +): ScopedQuickSearchOption[] => { + if (!businessMetadataDefs?.length) return []; + const q = searchTerm.trim().toLowerCase(); + return customSortBy( + businessMetadataDefs + .filter((d) => !q || d.name.toLowerCase().includes(q)) + .map((d) => ({ + id: `bm:${d.guid}`, + title: d.name, + group: "Business Metadata", + kind: "business-metadata" as const, + bmGuid: d.guid + })), + ["title"] + ); +}; diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index 02f5e27b6..bc9e444c2 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -64,7 +64,7 @@ import { globalSearchFilterInitialQuery, isEmpty } from "@utils/Utils"; import { attributeFilter } from "@utils/CommonViewFunction"; import { cloneDeep } from "@utils/Helper"; import LaunchOutlinedIcon from "@mui/icons-material/LaunchOutlined"; -import { getGlossaryImportTmpl } from "@api/apiMethods/glossaryApiMethod"; +import { downloadGlossaryImportTemplate } from "@utils/glossaryImportFlow"; import { toast } from "react-toastify"; import { EnumTypeDefData, TreeNode } from "@models/treeStructureType"; import ImportDialog from "@components/ImportDialog"; @@ -969,33 +969,26 @@ const BarTreeView: FC<{ const downloadFile = async () => { try { - let apiResp: any = {}; - if (treeName == "Entities") { - apiResp = await getBusinessMetadataImportTmpl({}); - } else if (treeName == "Glossary") { - apiResp = await getGlossaryImportTmpl({}); - } - let text: string = ""; - if (apiResp) { - text = apiResp.data; + if (treeName == "Glossary") { + await downloadGlossaryImportTemplate(); + return; } + const apiResp: any = await getBusinessMetadataImportTmpl({}); + const text: string = apiResp ? apiResp.data : ""; const blob = new Blob([text], { type: "text/plain" }); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - if (treeName == "Entities") { - link.setAttribute("download", "template_business_metadata"); - } else if (treeName == "Glossary") { - link.setAttribute("download", "template"); - } + link.setAttribute("download", "template_business_metadata"); document.body.appendChild(link); link.click(); document.body.removeChild(link); + window.URL.revokeObjectURL(url); } catch { /* ignore download error */ }
